365 lines
11 KiB
TypeScript
365 lines
11 KiB
TypeScript
import { computed, ref, watch } from 'vue';
|
|
import type { Layer, Sprite } from '@/types/sprites';
|
|
import { getMaxDimensions as getMaxDimensionsSingle, useSprites as useSpritesSingle } from './useSprites';
|
|
import { useSettingsStore } from '@/stores/useSettingsStore';
|
|
|
|
export const createEmptyLayer = (name: string): Layer => ({
|
|
id: crypto.randomUUID(),
|
|
name,
|
|
sprites: [],
|
|
visible: true,
|
|
locked: false,
|
|
});
|
|
|
|
const layers = ref<Layer[]>([createEmptyLayer('Base')]);
|
|
const activeLayerId = ref<string>(layers.value[0].id);
|
|
const columns = ref(4);
|
|
|
|
export const useLayers = () => {
|
|
const settingsStore = useSettingsStore();
|
|
|
|
watch(columns, val => {
|
|
const num = typeof val === 'number' ? val : parseInt(String(val));
|
|
const safe = Number.isFinite(num) && num >= 1 ? Math.min(num, 10) : 1;
|
|
if (safe !== columns.value) columns.value = safe;
|
|
});
|
|
|
|
const activeLayer = computed(() => layers.value.find(l => l.id === activeLayerId.value) || layers.value[0]);
|
|
|
|
const getMaxDimensions = (sprites: Sprite[]) => getMaxDimensionsSingle(sprites);
|
|
|
|
const visibleLayers = computed(() => layers.value.filter(l => l.visible));
|
|
|
|
const hasSprites = computed(() => layers.value.some(l => l.sprites.length > 0));
|
|
|
|
const updateSpritePosition = (id: string, x: number, y: number) => {
|
|
const l = activeLayer.value;
|
|
if (!l) return;
|
|
const i = l.sprites.findIndex(s => s.id === id);
|
|
if (i !== -1) {
|
|
l.sprites[i].x = Math.floor(x);
|
|
l.sprites[i].y = Math.floor(y);
|
|
}
|
|
};
|
|
|
|
const updateSpriteInLayer = (layerId: string, spriteId: string, x: number, y: number) => {
|
|
const l = layers.value.find(layer => layer.id === layerId);
|
|
if (!l) return;
|
|
const i = l.sprites.findIndex(s => s.id === spriteId);
|
|
if (i !== -1) {
|
|
l.sprites[i].x = Math.floor(x);
|
|
l.sprites[i].y = Math.floor(y);
|
|
}
|
|
};
|
|
|
|
const alignSprites = (position: 'left' | 'center' | 'right' | 'top' | 'middle' | 'bottom') => {
|
|
const l = activeLayer.value;
|
|
if (!l || !l.sprites.length) return;
|
|
|
|
// Determine the cell dimensions to align within
|
|
let cellWidth: number;
|
|
let cellHeight: number;
|
|
|
|
if (settingsStore.manualCellSizeEnabled) {
|
|
// Use manual cell size (without negative spacing)
|
|
cellWidth = settingsStore.manualCellWidth;
|
|
cellHeight = settingsStore.manualCellHeight;
|
|
} else {
|
|
// Use auto-calculated dimensions based on ALL visible layers (not just active layer)
|
|
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers.value);
|
|
cellWidth = maxWidth;
|
|
cellHeight = maxHeight;
|
|
}
|
|
|
|
l.sprites = l.sprites.map(sprite => {
|
|
let x = sprite.x;
|
|
let y = sprite.y;
|
|
switch (position) {
|
|
case 'left':
|
|
x = 0;
|
|
break;
|
|
case 'center':
|
|
x = Math.floor((cellWidth - sprite.width) / 2);
|
|
break;
|
|
case 'right':
|
|
x = Math.floor(cellWidth - sprite.width);
|
|
break;
|
|
case 'top':
|
|
y = 0;
|
|
break;
|
|
case 'middle':
|
|
y = Math.floor((cellHeight - sprite.height) / 2);
|
|
break;
|
|
case 'bottom':
|
|
y = Math.floor(cellHeight - sprite.height);
|
|
break;
|
|
}
|
|
return { ...sprite, x: Math.floor(x), y: Math.floor(y) };
|
|
});
|
|
};
|
|
|
|
const updateSpriteCell = (id: string, newIndex: number) => {
|
|
const l = activeLayer.value;
|
|
if (!l) return;
|
|
const currentIndex = l.sprites.findIndex(s => s.id === id);
|
|
if (currentIndex === -1 || currentIndex === newIndex) return;
|
|
const next = [...l.sprites];
|
|
if (newIndex < next.length) {
|
|
const moving = { ...next[currentIndex] };
|
|
const target = { ...next[newIndex] };
|
|
next[currentIndex] = target;
|
|
next[newIndex] = moving;
|
|
} else {
|
|
const [moved] = next.splice(currentIndex, 1);
|
|
next.splice(newIndex, 0, moved);
|
|
}
|
|
l.sprites = next;
|
|
};
|
|
|
|
const removeSprite = (id: string) => {
|
|
const l = activeLayer.value;
|
|
if (!l) return;
|
|
const i = l.sprites.findIndex(s => s.id === id);
|
|
if (i === -1) return;
|
|
const s = l.sprites[i];
|
|
if (s.url && s.url.startsWith('blob:')) {
|
|
try {
|
|
URL.revokeObjectURL(s.url);
|
|
} catch {}
|
|
}
|
|
l.sprites.splice(i, 1);
|
|
};
|
|
|
|
const removeSprites = (ids: string[]) => {
|
|
const l = activeLayer.value;
|
|
if (!l) return;
|
|
|
|
// Sort indices in descending order to avoid shift issues when splicing
|
|
const indicesToRemove: number[] = [];
|
|
ids.forEach(id => {
|
|
const i = l.sprites.findIndex(s => s.id === id);
|
|
if (i !== -1) indicesToRemove.push(i);
|
|
});
|
|
|
|
indicesToRemove.sort((a, b) => b - a);
|
|
|
|
indicesToRemove.forEach(i => {
|
|
const s = l.sprites[i];
|
|
if (s.url && s.url.startsWith('blob:')) {
|
|
try {
|
|
URL.revokeObjectURL(s.url);
|
|
} catch {}
|
|
}
|
|
l.sprites.splice(i, 1);
|
|
});
|
|
};
|
|
|
|
const rotateSprite = (id: string, angle: number) => {
|
|
const l = activeLayer.value;
|
|
if (!l) return;
|
|
const s = l.sprites.find(s => s.id === id);
|
|
if (s) {
|
|
s.rotation = (s.rotation + angle) % 360;
|
|
}
|
|
};
|
|
|
|
const flipSprite = (id: string, direction: 'horizontal' | 'vertical') => {
|
|
const l = activeLayer.value;
|
|
if (!l) return;
|
|
const s = l.sprites.find(s => s.id === id);
|
|
if (s) {
|
|
if (direction === 'horizontal') s.flipX = !s.flipX;
|
|
if (direction === 'vertical') s.flipY = !s.flipY;
|
|
}
|
|
};
|
|
|
|
const replaceSprite = (id: string, file: File) => {
|
|
const l = activeLayer.value;
|
|
if (!l) return;
|
|
const i = l.sprites.findIndex(s => s.id === id);
|
|
if (i === -1) return;
|
|
const old = l.sprites[i];
|
|
if (old.url && old.url.startsWith('blob:')) {
|
|
try {
|
|
URL.revokeObjectURL(old.url);
|
|
} catch {}
|
|
}
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = e => {
|
|
const url = e.target?.result as string;
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
l.sprites[i] = { id: old.id, file, img, url, width: img.width, height: img.height, x: old.x, y: old.y, rotation: old.rotation, flipX: old.flipX || false, flipY: old.flipY || false };
|
|
};
|
|
img.onerror = () => {
|
|
console.error('Failed to load replacement image:', file.name);
|
|
};
|
|
img.src = url;
|
|
};
|
|
reader.onerror = () => {
|
|
console.error('Failed to read replacement image file:', file.name);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
};
|
|
|
|
const addSprite = (file: File) => {
|
|
const l = activeLayer.value;
|
|
if (!l) return;
|
|
const reader = new FileReader();
|
|
reader.onload = e => {
|
|
const url = e.target?.result as string;
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
const next: Sprite = {
|
|
id: crypto.randomUUID(),
|
|
file,
|
|
img,
|
|
url,
|
|
width: img.width,
|
|
height: img.height,
|
|
x: 0,
|
|
y: 0,
|
|
rotation: 0,
|
|
flipX: false,
|
|
flipY: false,
|
|
};
|
|
l.sprites = [...l.sprites, next];
|
|
};
|
|
img.onerror = () => {
|
|
console.error('Failed to load sprite image:', file.name);
|
|
};
|
|
img.src = url;
|
|
};
|
|
reader.onerror = () => {
|
|
console.error('Failed to read sprite image file:', file.name);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
};
|
|
|
|
const processImageFiles = async (files: File[]) => {
|
|
for (const f of files) addSprite(f);
|
|
};
|
|
|
|
const addLayer = (name?: string) => {
|
|
const l = createEmptyLayer(name || `Layer ${layers.value.length + 1}`);
|
|
layers.value.push(l);
|
|
activeLayerId.value = l.id;
|
|
};
|
|
|
|
const removeLayer = (id: string) => {
|
|
if (layers.value.length === 1) return;
|
|
const idx = layers.value.findIndex(l => l.id === id);
|
|
if (idx === -1) return;
|
|
layers.value.splice(idx, 1);
|
|
if (activeLayerId.value === id) activeLayerId.value = layers.value[0].id;
|
|
};
|
|
|
|
const moveLayer = (id: string, direction: 'up' | 'down') => {
|
|
const idx = layers.value.findIndex(l => l.id === id);
|
|
if (idx === -1) return;
|
|
if (direction === 'up' && idx > 0) {
|
|
const [l] = layers.value.splice(idx, 1);
|
|
layers.value.splice(idx - 1, 0, l);
|
|
}
|
|
if (direction === 'down' && idx < layers.value.length - 1) {
|
|
const [l] = layers.value.splice(idx, 1);
|
|
layers.value.splice(idx + 1, 0, l);
|
|
}
|
|
};
|
|
|
|
const copySpriteToFrame = (spriteId: string, targetLayerId: string, targetFrameIndex: number) => {
|
|
// Find the source sprite in any layer
|
|
let sourceSprite: Sprite | undefined;
|
|
for (const layer of layers.value) {
|
|
sourceSprite = layer.sprites.find(s => s.id === spriteId);
|
|
if (sourceSprite) break;
|
|
}
|
|
|
|
if (!sourceSprite) return;
|
|
|
|
// Find target layer
|
|
const targetLayer = layers.value.find(l => l.id === targetLayerId);
|
|
if (!targetLayer) return;
|
|
|
|
// Create a deep copy of the sprite with a new ID
|
|
const copiedSprite: Sprite = {
|
|
id: crypto.randomUUID(),
|
|
file: sourceSprite.file,
|
|
img: sourceSprite.img,
|
|
url: sourceSprite.url,
|
|
width: sourceSprite.width,
|
|
height: sourceSprite.height,
|
|
x: sourceSprite.x,
|
|
y: sourceSprite.y,
|
|
rotation: sourceSprite.rotation,
|
|
flipX: sourceSprite.flipX,
|
|
flipY: sourceSprite.flipY,
|
|
};
|
|
|
|
// Expand the sprites array if necessary with empty placeholder sprites
|
|
while (targetLayer.sprites.length < targetFrameIndex) {
|
|
targetLayer.sprites.push({
|
|
id: crypto.randomUUID(),
|
|
file: new File([], 'empty'),
|
|
img: new Image(),
|
|
url: '',
|
|
width: 0,
|
|
height: 0,
|
|
x: 0,
|
|
y: 0,
|
|
rotation: 0,
|
|
flipX: false,
|
|
flipY: false,
|
|
});
|
|
}
|
|
|
|
// Replace or insert the sprite at the target index
|
|
if (targetFrameIndex < targetLayer.sprites.length) {
|
|
// Replace existing sprite at this frame
|
|
const old = targetLayer.sprites[targetFrameIndex];
|
|
if (old.url && old.url.startsWith('blob:')) {
|
|
try {
|
|
URL.revokeObjectURL(old.url);
|
|
} catch {}
|
|
}
|
|
targetLayer.sprites[targetFrameIndex] = copiedSprite;
|
|
} else {
|
|
// Add at the end
|
|
targetLayer.sprites.push(copiedSprite);
|
|
}
|
|
};
|
|
|
|
return {
|
|
layers,
|
|
visibleLayers,
|
|
activeLayerId,
|
|
activeLayer,
|
|
columns,
|
|
getMaxDimensions,
|
|
updateSpritePosition,
|
|
updateSpriteInLayer,
|
|
updateSpriteCell,
|
|
removeSprite,
|
|
removeSprites,
|
|
rotateSprite,
|
|
flipSprite,
|
|
replaceSprite,
|
|
addSprite,
|
|
processImageFiles,
|
|
alignSprites,
|
|
addLayer,
|
|
removeLayer,
|
|
moveLayer,
|
|
copySpriteToFrame,
|
|
hasSprites,
|
|
};
|
|
};
|
|
|
|
export const getMaxDimensionsAcrossLayers = (layers: Layer[], visibleOnly: boolean = false) => {
|
|
// When visibleOnly is false (default), consider ALL layers to keep canvas size stable
|
|
// When visibleOnly is true (export), only consider visible layers
|
|
const sprites = layers.flatMap(l => (visibleOnly ? (l.visible ? l.sprites : []) : l.sprites));
|
|
return getMaxDimensionsSingle(sprites);
|
|
};
|