[FEAT] Add pixel editor
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [2.4.0] - 2026-01-03
|
||||
- Add pixel editor
|
||||
|
||||
## [2.3.0] - 2026-01-01
|
||||
- Add authentication
|
||||
- You can now save projects and open them
|
||||
|
||||
504
src/components/DrawTab.vue
Normal file
504
src/components/DrawTab.vue
Normal file
@@ -0,0 +1,504 @@
|
||||
<template>
|
||||
<div class="draw-tab h-full w-full flex flex-col">
|
||||
<!-- Overview Mode -->
|
||||
<div v-if="!selectedFrame" class="h-full overflow-auto p-4">
|
||||
<div v-for="layer in layers" :key="layer.id" class="mb-6">
|
||||
<h3 class="text-sm font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-3">
|
||||
{{ layer.name }}
|
||||
<span class="text-xs font-normal text-gray-400 dark:text-gray-500 ml-2">({{ layer.sprites.length }} frames)</span>
|
||||
</h3>
|
||||
<div class="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-2">
|
||||
<button
|
||||
v-for="(sprite, index) in layer.sprites"
|
||||
:key="sprite.id"
|
||||
@click="selectFrame(layer.id, index, sprite)"
|
||||
class="aspect-square bg-white dark:bg-gray-800 rounded-lg border-2 border-transparent hover:border-indigo-500 transition-all overflow-hidden group relative"
|
||||
:class="{ 'opacity-50': !sprite.url }"
|
||||
>
|
||||
<img
|
||||
v-if="sprite.url"
|
||||
:src="sprite.url"
|
||||
class="w-full h-full object-contain relative z-10"
|
||||
:style="{ imageRendering: settingsStore.pixelPerfect ? 'pixelated' : 'auto' }"
|
||||
/>
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-gray-400 relative z-10">
|
||||
<i class="fas fa-image"></i>
|
||||
</div>
|
||||
<span class="absolute bottom-1 right-1 text-[10px] font-mono text-gray-500 dark:text-gray-400 bg-white/80 dark:bg-gray-800/80 px-1 rounded z-20">
|
||||
{{ index + 1 }}
|
||||
</span>
|
||||
<i class="fas fa-edit text-indigo-500 opacity-0 group-hover:opacity-100 transition-opacity text-[10px] absolute top-1 left-1 z-20"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pixel Editor Mode -->
|
||||
<div v-else class="h-full flex flex-col">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center justify-between px-4 py-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 rounded-t-xl z-20">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Tools -->
|
||||
<div class="flex bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
|
||||
<Tooltip text="Pencil (P)">
|
||||
<button
|
||||
@click="editor.currentTool.value = 'pencil'"
|
||||
:class="editor.currentTool.value === 'pencil' ? 'bg-white dark:bg-gray-600 shadow-sm' : 'hover:bg-gray-200 dark:hover:bg-gray-600'"
|
||||
class="p-2 rounded-md transition-all"
|
||||
>
|
||||
<i class="fas fa-pencil-alt text-gray-600 dark:text-gray-300"></i>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip text="Eraser (E)">
|
||||
<button
|
||||
@click="editor.currentTool.value = 'eraser'"
|
||||
:class="editor.currentTool.value === 'eraser' ? 'bg-white dark:bg-gray-600 shadow-sm' : 'hover:bg-gray-200 dark:hover:bg-gray-600'"
|
||||
class="p-2 rounded-md transition-all"
|
||||
>
|
||||
<i class="fas fa-eraser text-gray-600 dark:text-gray-300"></i>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<!-- Color Picker & History -->
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative group">
|
||||
<Tooltip text="Current Color">
|
||||
<div
|
||||
class="w-8 h-8 rounded-lg border-2 border-gray-300 dark:border-gray-600 cursor-pointer shadow-sm overflow-hidden"
|
||||
:style="{ backgroundColor: editor.currentColor.value }"
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
v-model="editor.currentColor.value"
|
||||
class="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<!-- Recent Colors -->
|
||||
<div class="flex flex-wrap gap-1 w-20">
|
||||
<div
|
||||
v-for="color in recentColors.slice(0, 8)"
|
||||
:key="color"
|
||||
class="w-3 h-3 rounded-full border border-gray-300 dark:border-gray-600 cursor-pointer hover:scale-110 transition-transform"
|
||||
:style="{ backgroundColor: color }"
|
||||
@click="editor.currentColor.value = color"
|
||||
:title="color"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1"></div>
|
||||
|
||||
<!-- Zoom -->
|
||||
<div class="flex items-center bg-gray-100 dark:bg-gray-700 rounded-lg">
|
||||
<Tooltip text="Zoom Out">
|
||||
<button @click="editor.zoomOut()" class="p-2 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-l-lg transition-colors">
|
||||
<i class="fas fa-minus text-xs text-gray-600 dark:text-gray-300"></i>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<span class="text-xs font-mono w-12 text-center text-gray-600 dark:text-gray-300 select-none">{{ editor.zoom.value }}x</span>
|
||||
<Tooltip text="Zoom In">
|
||||
<button @click="editor.zoomIn()" class="p-2 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-r-lg transition-colors">
|
||||
<i class="fas fa-plus text-xs text-gray-600 dark:text-gray-300"></i>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1"></div>
|
||||
|
||||
<!-- Canvas Size -->
|
||||
<Tooltip text="Resize Canvas">
|
||||
<button
|
||||
@click="showResizeModal = true"
|
||||
class="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<i class="fas fa-expand-arrows-alt text-gray-600 dark:text-gray-300"></i>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip text="Trim Empty Space">
|
||||
<button
|
||||
@click="editor.trimCanvas()"
|
||||
class="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<i class="fas fa-crop-alt text-gray-600 dark:text-gray-300"></i>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="w-px h-6 bg-gray-300 dark:bg-gray-600 mx-1"></div>
|
||||
|
||||
<!-- History -->
|
||||
<Tooltip text="Undo (Ctrl+Z)">
|
||||
<button
|
||||
@click="editor.undo()"
|
||||
:disabled="!editor.canUndo.value"
|
||||
:class="editor.canUndo.value ? 'hover:bg-gray-100 dark:hover:bg-gray-700' : 'opacity-40 cursor-not-allowed'"
|
||||
class="p-2 rounded-lg transition-colors"
|
||||
>
|
||||
<i class="fas fa-undo text-gray-600 dark:text-gray-300"></i>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip text="Redo (Ctrl+Shift+Z)">
|
||||
<button
|
||||
@click="editor.redo()"
|
||||
:disabled="!editor.canRedo.value"
|
||||
:class="editor.canRedo.value ? 'hover:bg-gray-100 dark:hover:bg-gray-700' : 'opacity-40 cursor-not-allowed'"
|
||||
class="p-2 rounded-lg transition-colors"
|
||||
>
|
||||
<i class="fas fa-redo text-gray-600 dark:text-gray-300"></i>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Toggle Options -->
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="editor.showCheckerboard.value" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400 select-none">Grid</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="editor.showPointerLocation.value" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400 select-none">Coords</span>
|
||||
</label>
|
||||
|
||||
<!-- Pointer Location Display -->
|
||||
<div v-if="editor.showPointerLocation.value" class="text-xs font-mono text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded min-w-[60px] text-center">
|
||||
{{ editor.pointerX.value }}, {{ editor.pointerY.value }}
|
||||
</div>
|
||||
|
||||
<!-- Canvas Size -->
|
||||
<div class="text-xs font-mono text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">
|
||||
{{ editor.canvasWidth.value }} × {{ editor.canvasHeight.value }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canvas Area -->
|
||||
<div class="flex-1 overflow-auto bg-gray-200 dark:bg-gray-900 p-4">
|
||||
<div class="min-h-full min-w-full flex items-center justify-center">
|
||||
<div
|
||||
class="relative shrink-0"
|
||||
:style="{
|
||||
width: `${editor.canvasWidth.value * editor.zoom.value}px`,
|
||||
height: `${editor.canvasHeight.value * editor.zoom.value}px`,
|
||||
}"
|
||||
>
|
||||
<!-- Background (checkerboard or solid) -->
|
||||
<div
|
||||
class="absolute inset-0 pointer-events-none"
|
||||
:style="getBackgroundStyle()"
|
||||
></div>
|
||||
|
||||
<!-- Canvas -->
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
class="relative z-10 cursor-crosshair"
|
||||
:style="{
|
||||
width: `${editor.canvasWidth.value * editor.zoom.value}px`,
|
||||
height: `${editor.canvasHeight.value * editor.zoom.value}px`,
|
||||
imageRendering: 'pixelated',
|
||||
}"
|
||||
@mousedown="editor.startDrawing($event)"
|
||||
@mousemove="handleMouseMove"
|
||||
@mouseup="editor.stopDrawing()"
|
||||
@mouseleave="editor.stopDrawing()"
|
||||
></canvas>
|
||||
|
||||
<!-- Pixel Grid Overlay (when zoomed in enough) -->
|
||||
<div
|
||||
v-if="editor.zoom.value >= 4"
|
||||
class="absolute inset-0 pointer-events-none z-20"
|
||||
:style="{
|
||||
backgroundImage: `
|
||||
linear-gradient(to right, rgba(0,0,0,0.1) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(0,0,0,0.1) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: `${editor.zoom.value}px ${editor.zoom.value}px`,
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Actions -->
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 rounded-b-xl">
|
||||
<Tooltip text="Discard changes">
|
||||
<button
|
||||
@click="cancelEdit"
|
||||
class="px-4 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors font-medium text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip text="Save changes to frame">
|
||||
<button
|
||||
@click="saveEdit"
|
||||
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors font-medium text-sm"
|
||||
>
|
||||
<i class="fas fa-save mr-2"></i>Save
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resize Modal -->
|
||||
<Modal :is-open="showResizeModal" @close="showResizeModal = false" title="Resize Canvas" :initialWidth="400" :initialHeight="350">
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Width</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="resizeWidth"
|
||||
min="1"
|
||||
max="1024"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Height</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="resizeHeight"
|
||||
min="1"
|
||||
max="1024"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Anchor Position</label>
|
||||
<div class="grid grid-cols-3 gap-1 w-32 mx-auto">
|
||||
<button
|
||||
v-for="anchor in anchors"
|
||||
:key="anchor.value"
|
||||
@click="resizeAnchor = anchor.value"
|
||||
:class="resizeAnchor === anchor.value ? 'bg-indigo-500 text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'"
|
||||
class="aspect-square rounded flex items-center justify-center text-xs transition-colors"
|
||||
>
|
||||
<i :class="anchor.icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4 border-t border-gray-100 dark:border-gray-700">
|
||||
<button @click="showResizeModal = false" class="px-4 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors font-medium text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
<button @click="applyResize" class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors font-medium text-sm">
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue';
|
||||
import { usePixelEditor } from '@/composables/usePixelEditor';
|
||||
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||
import Modal from './utilities/Modal.vue';
|
||||
import Tooltip from './utilities/Tooltip.vue';
|
||||
import type { Layer, Sprite } from '@/types/sprites';
|
||||
|
||||
interface SelectedFrame {
|
||||
layerId: string;
|
||||
frameIndex: number;
|
||||
sprite: Sprite;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
layers: Layer[];
|
||||
initialFrame?: { layerId: string; frameIndex: number } | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'saveFrame', layerId: string, frameIndex: number, file: File): void;
|
||||
(e: 'close'): void;
|
||||
}>();
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const editor = usePixelEditor();
|
||||
|
||||
const selectedFrame = ref<SelectedFrame | null>(null);
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
|
||||
const showResizeModal = ref(false);
|
||||
const resizeWidth = ref(32);
|
||||
const resizeHeight = ref(32);
|
||||
const resizeAnchor = ref('top-left');
|
||||
|
||||
const recentColors = ref<string[]>(['#ffffff', '#000000', '#ff0000', '#00ff00', '#0000ff']);
|
||||
|
||||
watch(
|
||||
() => editor.currentColor.value,
|
||||
(newColor) => {
|
||||
if (!newColor) return;
|
||||
// Add to recent colors if not already at the start
|
||||
if (recentColors.value[0] !== newColor) {
|
||||
recentColors.value = [newColor, ...recentColors.value.filter((c) => c !== newColor)].slice(0, 10);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const anchors = [
|
||||
{ value: 'top-left', icon: 'fas fa-arrow-up -rotate-45' },
|
||||
{ value: 'top-center', icon: 'fas fa-arrow-up' },
|
||||
{ value: 'top-right', icon: 'fas fa-arrow-up rotate-45' },
|
||||
{ value: 'middle-left', icon: 'fas fa-arrow-left' },
|
||||
{ value: 'center-middle', icon: 'fas fa-circle text-[6px]' },
|
||||
{ value: 'middle-right', icon: 'fas fa-arrow-right' },
|
||||
{ value: 'bottom-left', icon: 'fas fa-arrow-down rotate-45' },
|
||||
{ value: 'bottom-center', icon: 'fas fa-arrow-down' },
|
||||
{ value: 'bottom-right', icon: 'fas fa-arrow-down -rotate-45' },
|
||||
];
|
||||
|
||||
const getCheckerboardPattern = () => {
|
||||
return `conic-gradient(
|
||||
#e0e0e0 0.25turn,
|
||||
transparent 0.25turn 0.5turn,
|
||||
#e0e0e0 0.5turn 0.75turn,
|
||||
transparent 0.75turn
|
||||
)`;
|
||||
};
|
||||
|
||||
const getBackgroundStyle = () => {
|
||||
const zoom = editor.zoom.value;
|
||||
const isTransparent = settingsStore.backgroundColor === 'transparent';
|
||||
|
||||
// If checkerboard is enabled, show standard white/grey checkerboard
|
||||
if (editor.showCheckerboard.value) {
|
||||
return {
|
||||
backgroundColor: '#ffffff',
|
||||
backgroundImage: getCheckerboardPattern(),
|
||||
backgroundSize: `${zoom * 2}px ${zoom * 2}px`,
|
||||
backgroundPosition: '0 0',
|
||||
};
|
||||
}
|
||||
|
||||
// Otherwise show the configured background color (default to white if transparent)
|
||||
return {
|
||||
backgroundColor: isTransparent ? '#ffffff' : settingsStore.backgroundColor,
|
||||
backgroundImage: 'none',
|
||||
};
|
||||
};
|
||||
|
||||
const selectFrame = async (layerId: string, frameIndex: number, sprite: Sprite) => {
|
||||
selectedFrame.value = { layerId, frameIndex, sprite };
|
||||
|
||||
await nextTick();
|
||||
|
||||
if (canvasRef.value) {
|
||||
editor.initCanvas(canvasRef.value);
|
||||
if (sprite.url) {
|
||||
await editor.loadFromImage(sprite.url);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
selectedFrame.value = null;
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!selectedFrame.value) return;
|
||||
|
||||
const file = await editor.toFile(`frame-${selectedFrame.value.frameIndex}.png`);
|
||||
if (file) {
|
||||
emit('saveFrame', selectedFrame.value.layerId, selectedFrame.value.frameIndex, file);
|
||||
}
|
||||
selectedFrame.value = null;
|
||||
};
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
editor.continueDrawing(event);
|
||||
editor.updatePointer(event);
|
||||
};
|
||||
|
||||
const applyResize = () => {
|
||||
editor.resizeCanvas(resizeWidth.value, resizeHeight.value, resizeAnchor.value);
|
||||
showResizeModal.value = false;
|
||||
};
|
||||
|
||||
// Keyboard shortcuts
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!selectedFrame.value) return;
|
||||
|
||||
// Tool shortcuts
|
||||
if (event.key === 'p' || event.key === 'P') {
|
||||
editor.currentTool.value = 'pencil';
|
||||
} else if (event.key === 'e' || event.key === 'E') {
|
||||
editor.currentTool.value = 'eraser';
|
||||
}
|
||||
|
||||
// Undo/Redo
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'z') {
|
||||
if (event.shiftKey) {
|
||||
editor.redo();
|
||||
} else {
|
||||
editor.undo();
|
||||
}
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// Escape to cancel
|
||||
if (event.key === 'Escape') {
|
||||
cancelEdit();
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for resize modal opening to set current dimensions
|
||||
watch(showResizeModal, (isOpen) => {
|
||||
if (isOpen) {
|
||||
resizeWidth.value = editor.canvasWidth.value;
|
||||
resizeHeight.value = editor.canvasHeight.value;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle initial frame passed from context menu
|
||||
watch(
|
||||
() => props.initialFrame,
|
||||
async (frame) => {
|
||||
if (frame) {
|
||||
const layer = props.layers.find(l => l.id === frame.layerId);
|
||||
if (layer && layer.sprites[frame.frameIndex]) {
|
||||
await selectFrame(frame.layerId, frame.frameIndex, layer.sprites[frame.frameIndex]);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.draw-tab {
|
||||
/* Custom scrollbar */
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(156, 163, 175, 0.5);
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -25,6 +25,10 @@
|
||||
<i class="fas fa-copy text-cyan-600 dark:text-cyan-400 text-xs w-4"></i>
|
||||
<span>Copy to frame...</span>
|
||||
</button>
|
||||
<button v-if="contextMenuSpriteId" @click="openPixelEditor" class="w-full px-3 py-1.5 text-left hover:bg-indigo-50 dark:hover:bg-indigo-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
|
||||
<i class="fas fa-paint-brush text-indigo-600 dark:text-indigo-400 text-xs w-4"></i>
|
||||
<span>Edit in Pixel Editor</span>
|
||||
</button>
|
||||
<div v-if="contextMenuSpriteId" class="h-px bg-gray-200 dark:bg-gray-600 my-1"></div>
|
||||
<button v-if="contextMenuSpriteId" @click="removeSprite" class="w-full px-3 py-1.5 text-left hover:bg-red-50 dark:hover:bg-red-900/30 text-red-600 dark:text-red-400 flex items-center gap-2 transition-colors text-sm">
|
||||
<i class="fas fa-trash text-xs w-4"></i>
|
||||
@@ -223,6 +227,7 @@
|
||||
(e: 'rotateSprite', id: string, angle: number): void;
|
||||
(e: 'flipSprite', id: string, direction: 'horizontal' | 'vertical'): void;
|
||||
(e: 'copySpriteToFrame', spriteId: string, targetLayerId: string, targetFrameIndex: number): void;
|
||||
(e: 'openPixelEditor', layerId: string, frameIndex: number): void;
|
||||
}>();
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
@@ -535,6 +540,21 @@
|
||||
}
|
||||
};
|
||||
|
||||
const openPixelEditor = () => {
|
||||
if (contextMenuSpriteId.value) {
|
||||
// Find the frame index by finding the sprite in the active layer
|
||||
const layer = props.layers.find(l => l.id === props.activeLayerId);
|
||||
if (layer) {
|
||||
const frameIndex = layer.sprites.findIndex(s => s.id === contextMenuSpriteId.value);
|
||||
if (frameIndex !== -1) {
|
||||
emit('openPixelEditor', props.activeLayerId, frameIndex);
|
||||
}
|
||||
}
|
||||
showContextMenu.value = false;
|
||||
contextMenuSpriteId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const onDragLeave = (event: DragEvent) => {
|
||||
handleDragLeave(event, gridContainerRef.value);
|
||||
};
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
<i class="fas fa-copy text-cyan-600 dark:text-cyan-400 text-xs w-4"></i>
|
||||
<span>Copy to Frame...</span>
|
||||
</button>
|
||||
<button @click="openPixelEditor" class="w-full px-3 py-1.5 text-left hover:bg-indigo-50 dark:hover:bg-indigo-900/30 text-gray-700 dark:text-gray-200 flex items-center gap-2 transition-colors text-sm">
|
||||
<i class="fas fa-paint-brush text-indigo-600 dark:text-indigo-400 text-xs w-4"></i>
|
||||
<span>Edit in Pixel Editor</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Copy to Frame Modal -->
|
||||
@@ -299,6 +303,7 @@
|
||||
(e: 'flipSprite', id: string, direction: 'horizontal' | 'vertical'): void;
|
||||
(e: 'copySpriteToFrame', spriteId: string, targetLayerId: string, targetFrameIndex: number): void;
|
||||
(e: 'replaceSprite', id: string, file: File): void;
|
||||
(e: 'openPixelEditor', layerId: string, frameIndex: number): void;
|
||||
}>();
|
||||
|
||||
const previewContainerRef = ref<HTMLDivElement | null>(null);
|
||||
@@ -691,6 +696,13 @@
|
||||
replacingSpriteId.value = null;
|
||||
input.value = '';
|
||||
};
|
||||
|
||||
const openPixelEditor = () => {
|
||||
if (contextMenuSpriteId.value && contextMenuLayerId.value) {
|
||||
emit('openPixelEditor', contextMenuLayerId.value, currentFrameIndex.value);
|
||||
hideContextMenu();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
404
src/composables/usePixelEditor.ts
Normal file
404
src/composables/usePixelEditor.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
import { ref, computed, watch, type Ref } from 'vue';
|
||||
|
||||
export interface PixelEditorOptions {
|
||||
initialImageUrl?: string;
|
||||
initialWidth?: number;
|
||||
initialHeight?: number;
|
||||
backgroundColor?: Ref<string>;
|
||||
}
|
||||
|
||||
export interface HistoryEntry {
|
||||
imageData: ImageData;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export const usePixelEditor = (options: PixelEditorOptions = {}) => {
|
||||
const canvas = ref<HTMLCanvasElement | null>(null);
|
||||
const ctx = computed(() => canvas.value?.getContext('2d', { willReadFrequently: true }) ?? null);
|
||||
|
||||
const canvasWidth = ref(options.initialWidth || 32);
|
||||
const canvasHeight = ref(options.initialHeight || 32);
|
||||
|
||||
const currentTool = ref<'pencil' | 'eraser'>('pencil');
|
||||
const currentColor = ref('#000000');
|
||||
const brushSize = ref(1);
|
||||
const zoom = ref(1);
|
||||
|
||||
const isDrawing = ref(false);
|
||||
const lastX = ref(0);
|
||||
const lastY = ref(0);
|
||||
|
||||
const pointerX = ref(0);
|
||||
const pointerY = ref(0);
|
||||
const showPointerLocation = ref(false);
|
||||
const showCheckerboard = ref(false);
|
||||
|
||||
// History management
|
||||
const history = ref<HistoryEntry[]>([]);
|
||||
const historyIndex = ref(-1);
|
||||
const maxHistorySize = 50;
|
||||
|
||||
const canUndo = computed(() => historyIndex.value > 0);
|
||||
const canRedo = computed(() => historyIndex.value < history.value.length - 1);
|
||||
|
||||
const saveToHistory = () => {
|
||||
if (!ctx.value || !canvas.value) return;
|
||||
|
||||
const imageData = ctx.value.getImageData(0, 0, canvasWidth.value, canvasHeight.value);
|
||||
const entry: HistoryEntry = {
|
||||
imageData,
|
||||
width: canvasWidth.value,
|
||||
height: canvasHeight.value,
|
||||
};
|
||||
|
||||
// Remove any redo history
|
||||
if (historyIndex.value < history.value.length - 1) {
|
||||
history.value = history.value.slice(0, historyIndex.value + 1);
|
||||
}
|
||||
|
||||
history.value.push(entry);
|
||||
|
||||
// Limit history size
|
||||
if (history.value.length > maxHistorySize) {
|
||||
history.value.shift();
|
||||
} else {
|
||||
historyIndex.value++;
|
||||
}
|
||||
};
|
||||
|
||||
const undo = () => {
|
||||
if (!canUndo.value || !ctx.value || !canvas.value) return;
|
||||
|
||||
historyIndex.value--;
|
||||
const entry = history.value[historyIndex.value];
|
||||
|
||||
canvasWidth.value = entry.width;
|
||||
canvasHeight.value = entry.height;
|
||||
canvas.value.width = entry.width;
|
||||
canvas.value.height = entry.height;
|
||||
|
||||
ctx.value.putImageData(entry.imageData, 0, 0);
|
||||
};
|
||||
|
||||
const redo = () => {
|
||||
if (!canRedo.value || !ctx.value || !canvas.value) return;
|
||||
|
||||
historyIndex.value++;
|
||||
const entry = history.value[historyIndex.value];
|
||||
|
||||
canvasWidth.value = entry.width;
|
||||
canvasHeight.value = entry.height;
|
||||
canvas.value.width = entry.width;
|
||||
canvas.value.height = entry.height;
|
||||
|
||||
ctx.value.putImageData(entry.imageData, 0, 0);
|
||||
};
|
||||
|
||||
const initCanvas = (canvasElement: HTMLCanvasElement) => {
|
||||
canvas.value = canvasElement;
|
||||
if (!ctx.value) return;
|
||||
|
||||
canvas.value.width = canvasWidth.value;
|
||||
canvas.value.height = canvasHeight.value;
|
||||
|
||||
// Clear with transparent background
|
||||
ctx.value.clearRect(0, 0, canvasWidth.value, canvasHeight.value);
|
||||
|
||||
// Save initial state to history
|
||||
saveToHistory();
|
||||
};
|
||||
|
||||
const loadFromImage = async (imageUrl: string) => {
|
||||
if (!ctx.value || !canvas.value) return;
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
canvasWidth.value = img.width;
|
||||
canvasHeight.value = img.height;
|
||||
canvas.value!.width = img.width;
|
||||
canvas.value!.height = img.height;
|
||||
|
||||
ctx.value!.clearRect(0, 0, img.width, img.height);
|
||||
ctx.value!.drawImage(img, 0, 0);
|
||||
|
||||
// Clear history and save initial state
|
||||
history.value = [];
|
||||
historyIndex.value = -1;
|
||||
saveToHistory();
|
||||
|
||||
resolve();
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = imageUrl;
|
||||
});
|
||||
};
|
||||
|
||||
const getPixelCoords = (event: MouseEvent, canvasElement: HTMLCanvasElement): { x: number; y: number } => {
|
||||
const rect = canvasElement.getBoundingClientRect();
|
||||
const scaleX = canvasWidth.value / rect.width;
|
||||
const scaleY = canvasHeight.value / rect.height;
|
||||
|
||||
const x = Math.floor((event.clientX - rect.left) * scaleX);
|
||||
const y = Math.floor((event.clientY - rect.top) * scaleY);
|
||||
|
||||
return { x: Math.max(0, Math.min(x, canvasWidth.value - 1)), y: Math.max(0, Math.min(y, canvasHeight.value - 1)) };
|
||||
};
|
||||
|
||||
const drawPixel = (x: number, y: number) => {
|
||||
if (!ctx.value) return;
|
||||
|
||||
if (currentTool.value === 'eraser') {
|
||||
ctx.value.clearRect(x, y, brushSize.value, brushSize.value);
|
||||
} else {
|
||||
ctx.value.fillStyle = currentColor.value;
|
||||
ctx.value.fillRect(x, y, brushSize.value, brushSize.value);
|
||||
}
|
||||
};
|
||||
|
||||
const drawLine = (x0: number, y0: number, x1: number, y1: number) => {
|
||||
const dx = Math.abs(x1 - x0);
|
||||
const dy = Math.abs(y1 - y0);
|
||||
const sx = x0 < x1 ? 1 : -1;
|
||||
const sy = y0 < y1 ? 1 : -1;
|
||||
let err = dx - dy;
|
||||
|
||||
let x = x0;
|
||||
let y = y0;
|
||||
|
||||
while (true) {
|
||||
drawPixel(x, y);
|
||||
|
||||
if (x === x1 && y === y1) break;
|
||||
|
||||
const e2 = 2 * err;
|
||||
if (e2 > -dy) {
|
||||
err -= dy;
|
||||
x += sx;
|
||||
}
|
||||
if (e2 < dx) {
|
||||
err += dx;
|
||||
y += sy;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startDrawing = (event: MouseEvent) => {
|
||||
if (!canvas.value) return;
|
||||
|
||||
isDrawing.value = true;
|
||||
const { x, y } = getPixelCoords(event, canvas.value);
|
||||
lastX.value = x;
|
||||
lastY.value = y;
|
||||
drawPixel(x, y);
|
||||
};
|
||||
|
||||
const continueDrawing = (event: MouseEvent) => {
|
||||
if (!canvas.value) return;
|
||||
|
||||
const { x, y } = getPixelCoords(event, canvas.value);
|
||||
pointerX.value = x;
|
||||
pointerY.value = y;
|
||||
|
||||
if (!isDrawing.value) return;
|
||||
|
||||
drawLine(lastX.value, lastY.value, x, y);
|
||||
lastX.value = x;
|
||||
lastY.value = y;
|
||||
};
|
||||
|
||||
const stopDrawing = () => {
|
||||
if (isDrawing.value) {
|
||||
isDrawing.value = false;
|
||||
saveToHistory();
|
||||
}
|
||||
};
|
||||
|
||||
const updatePointer = (event: MouseEvent) => {
|
||||
if (!canvas.value) return;
|
||||
const { x, y } = getPixelCoords(event, canvas.value);
|
||||
pointerX.value = x;
|
||||
pointerY.value = y;
|
||||
};
|
||||
|
||||
// Canvas size operations
|
||||
const resizeCanvas = (newWidth: number, newHeight: number, anchor: string = 'top-left') => {
|
||||
if (!ctx.value || !canvas.value) return;
|
||||
|
||||
const oldImageData = ctx.value.getImageData(0, 0, canvasWidth.value, canvasHeight.value);
|
||||
const oldWidth = canvasWidth.value;
|
||||
const oldHeight = canvasHeight.value;
|
||||
|
||||
canvasWidth.value = newWidth;
|
||||
canvasHeight.value = newHeight;
|
||||
canvas.value.width = newWidth;
|
||||
canvas.value.height = newHeight;
|
||||
|
||||
ctx.value.clearRect(0, 0, newWidth, newHeight);
|
||||
|
||||
// Calculate offset based on anchor
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
|
||||
if (anchor.includes('center')) {
|
||||
offsetX = Math.floor((newWidth - oldWidth) / 2);
|
||||
} else if (anchor.includes('right')) {
|
||||
offsetX = newWidth - oldWidth;
|
||||
}
|
||||
|
||||
if (anchor.includes('middle')) {
|
||||
offsetY = Math.floor((newHeight - oldHeight) / 2);
|
||||
} else if (anchor.includes('bottom')) {
|
||||
offsetY = newHeight - oldHeight;
|
||||
}
|
||||
|
||||
// Create temp canvas to hold old image
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = oldWidth;
|
||||
tempCanvas.height = oldHeight;
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
if (tempCtx) {
|
||||
tempCtx.putImageData(oldImageData, 0, 0);
|
||||
ctx.value.drawImage(tempCanvas, offsetX, offsetY);
|
||||
}
|
||||
|
||||
saveToHistory();
|
||||
};
|
||||
|
||||
const trimCanvas = () => {
|
||||
if (!ctx.value || !canvas.value) return;
|
||||
|
||||
const imageData = ctx.value.getImageData(0, 0, canvasWidth.value, canvasHeight.value);
|
||||
const { data, width, height } = imageData;
|
||||
|
||||
let minX = width;
|
||||
let minY = height;
|
||||
let maxX = 0;
|
||||
let maxY = 0;
|
||||
|
||||
// Find bounding box of non-transparent pixels
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const alpha = data[(y * width + x) * 4 + 3];
|
||||
if (alpha > 0) {
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x);
|
||||
maxY = Math.max(maxY, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no non-transparent pixels found, keep at least 1x1
|
||||
if (minX > maxX || minY > maxY) {
|
||||
minX = 0;
|
||||
minY = 0;
|
||||
maxX = 0;
|
||||
maxY = 0;
|
||||
}
|
||||
|
||||
const newWidth = maxX - minX + 1;
|
||||
const newHeight = maxY - minY + 1;
|
||||
|
||||
const croppedData = ctx.value.getImageData(minX, minY, newWidth, newHeight);
|
||||
|
||||
canvasWidth.value = newWidth;
|
||||
canvasHeight.value = newHeight;
|
||||
canvas.value.width = newWidth;
|
||||
canvas.value.height = newHeight;
|
||||
|
||||
ctx.value.putImageData(croppedData, 0, 0);
|
||||
|
||||
saveToHistory();
|
||||
};
|
||||
|
||||
// Export to data URL
|
||||
const toDataURL = (): string => {
|
||||
if (!canvas.value) return '';
|
||||
return canvas.value.toDataURL('image/png');
|
||||
};
|
||||
|
||||
// Export to File
|
||||
const toFile = async (filename: string = 'sprite.png'): Promise<File | null> => {
|
||||
if (!canvas.value) return null;
|
||||
|
||||
return new Promise(resolve => {
|
||||
canvas.value!.toBlob(blob => {
|
||||
if (blob) {
|
||||
resolve(new File([blob], filename, { type: 'image/png' }));
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
}, 'image/png');
|
||||
});
|
||||
};
|
||||
|
||||
// Zoom controls
|
||||
const zoomIn = () => {
|
||||
zoom.value = Math.min(zoom.value + 2, 32);
|
||||
};
|
||||
|
||||
const zoomOut = () => {
|
||||
zoom.value = Math.max(zoom.value - 2, 1);
|
||||
};
|
||||
|
||||
const setZoom = (value: number) => {
|
||||
zoom.value = Math.max(1, Math.min(value, 32));
|
||||
};
|
||||
|
||||
// Clear canvas
|
||||
const clearCanvas = () => {
|
||||
if (!ctx.value) return;
|
||||
ctx.value.clearRect(0, 0, canvasWidth.value, canvasHeight.value);
|
||||
saveToHistory();
|
||||
};
|
||||
|
||||
return {
|
||||
// Canvas
|
||||
canvas,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
initCanvas,
|
||||
loadFromImage,
|
||||
clearCanvas,
|
||||
|
||||
// Tools
|
||||
currentTool,
|
||||
currentColor,
|
||||
brushSize,
|
||||
|
||||
// Drawing
|
||||
startDrawing,
|
||||
continueDrawing,
|
||||
stopDrawing,
|
||||
updatePointer,
|
||||
|
||||
// Pointer
|
||||
pointerX,
|
||||
pointerY,
|
||||
showPointerLocation,
|
||||
showCheckerboard,
|
||||
|
||||
// Zoom
|
||||
zoom,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
setZoom,
|
||||
|
||||
// History
|
||||
canUndo,
|
||||
canRedo,
|
||||
undo,
|
||||
redo,
|
||||
|
||||
// Canvas operations
|
||||
resizeCanvas,
|
||||
trimCanvas,
|
||||
|
||||
// Export
|
||||
toDataURL,
|
||||
toFile,
|
||||
};
|
||||
};
|
||||
@@ -89,7 +89,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Canvas Grid Settings (Editor only) -->
|
||||
<section v-if="activeTab === 'canvas'">
|
||||
<section v-if="activeTab === 'canvas' || activeTab === 'preview'">
|
||||
<h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">Grid Layout</h3>
|
||||
<div class="card p-3 bg-gray-50/50 dark:bg-gray-800/40 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -112,7 +112,7 @@
|
||||
</section>
|
||||
|
||||
<!-- View Options (Editor only) -->
|
||||
<section v-if="activeTab === 'canvas'">
|
||||
<section v-if="activeTab === 'canvas' || activeTab === 'preview'">
|
||||
<h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">View Options</h3>
|
||||
<div class="card p-2 bg-gray-50/50 dark:bg-gray-800/40 grid grid-cols-2 gap-2">
|
||||
<Tooltip text="Disable anti-aliasing for crisp pixel art rendering">
|
||||
@@ -173,7 +173,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Tools (Editor only) -->
|
||||
<section v-if="activeTab === 'canvas'">
|
||||
<section v-if="activeTab === 'canvas' || activeTab === 'preview'">
|
||||
<h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">Tools</h3>
|
||||
<div class="card p-2 bg-gray-50/50 dark:bg-gray-800/40 space-y-2">
|
||||
<div class="flex gap-2">
|
||||
@@ -243,6 +243,13 @@
|
||||
>
|
||||
<i class="fas fa-play mr-2"></i>Preview
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'draw'"
|
||||
:class="activeTab === 'draw' ? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'"
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium transition-all duration-200"
|
||||
>
|
||||
<i class="fas fa-paint-brush mr-2"></i>Draw
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Background Color (Compact) -->
|
||||
@@ -303,9 +310,10 @@
|
||||
@rotate-sprite="rotateSprite"
|
||||
@flip-sprite="flipSprite"
|
||||
@copy-sprite-to-frame="copySpriteToFrame"
|
||||
@open-pixel-editor="openPixelEditor"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="activeTab === 'preview'" class="h-full flex items-center justify-center">
|
||||
<div v-else-if="activeTab === 'preview'" class="h-full flex items-center justify-center">
|
||||
<sprite-preview
|
||||
:layers="layers"
|
||||
:active-layer-id="activeLayerId"
|
||||
@@ -317,6 +325,15 @@
|
||||
@flip-sprite="flipSprite"
|
||||
@copy-sprite-to-frame="copySpriteToFrame"
|
||||
@replace-sprite="replaceSprite"
|
||||
@open-pixel-editor="openPixelEditor"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="activeTab === 'draw'" class="h-full">
|
||||
<draw-tab
|
||||
:layers="layers"
|
||||
:initial-frame="pixelEditorFrame"
|
||||
@save-frame="handleSaveFrame"
|
||||
@close="pixelEditorFrame = null"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -340,6 +357,7 @@
|
||||
import FileUploader from '@/components/FileUploader.vue';
|
||||
import SpriteCanvas from '@/components/SpriteCanvas.vue';
|
||||
import SpritePreview from '@/components/SpritePreview.vue';
|
||||
import DrawTab from '@/components/DrawTab.vue';
|
||||
import SpritesheetSplitter from '@/components/SpritesheetSplitter.vue';
|
||||
import GifFpsModal from '@/components/GifFpsModal.vue';
|
||||
import ShareModal from '@/components/ShareModal.vue';
|
||||
@@ -442,7 +460,8 @@
|
||||
};
|
||||
|
||||
const cellSize = computed(getCellSize);
|
||||
const activeTab = ref<'canvas' | 'preview'>('canvas');
|
||||
const activeTab = ref<'canvas' | 'preview' | 'draw'>('canvas');
|
||||
const pixelEditorFrame = ref<{ layerId: string; frameIndex: number } | null>(null);
|
||||
|
||||
const isSpritesheetSplitterOpen = ref(false);
|
||||
const isGifFpsModalOpen = ref(false);
|
||||
@@ -631,6 +650,46 @@
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveFrame = (layerId: string, frameIndex: number, file: File) => {
|
||||
const layer = layers.value.find(l => l.id === layerId);
|
||||
if (!layer) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
const url = e.target?.result as string;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const oldSprite = layer.sprites[frameIndex];
|
||||
const sprite = {
|
||||
id: oldSprite?.id || crypto.randomUUID(),
|
||||
file,
|
||||
img,
|
||||
url,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
x: oldSprite?.x || 0,
|
||||
y: oldSprite?.y || 0,
|
||||
rotation: oldSprite?.rotation || 0,
|
||||
flipX: oldSprite?.flipX || false,
|
||||
flipY: oldSprite?.flipY || false,
|
||||
};
|
||||
|
||||
if (frameIndex < layer.sprites.length) {
|
||||
layer.sprites[frameIndex] = sprite;
|
||||
}
|
||||
};
|
||||
img.src = url;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
pixelEditorFrame.value = null;
|
||||
};
|
||||
|
||||
const openPixelEditor = (layerId: string, frameIndex: number) => {
|
||||
pixelEditorFrame.value = { layerId, frameIndex };
|
||||
activeTab.value = 'draw';
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
const id = route.params.id as string;
|
||||
if (id) {
|
||||
|
||||
Reference in New Issue
Block a user