[FEAT] Add pixel editor

This commit is contained in:
2026-01-03 17:17:14 +01:00
parent 224d0d62fe
commit 2f0404d698
6 changed files with 1007 additions and 5 deletions

View File

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

View File

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

View File

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

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

View File

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