Continuation of separting logic into domain specific composables

This commit is contained in:
2025-11-18 20:11:36 +01:00
parent d571cb51cb
commit 404ca9ce88
7 changed files with 942 additions and 560 deletions

View File

@@ -0,0 +1,167 @@
import { ref, computed, type Ref, onUnmounted } from 'vue';
import type { Sprite } from '@/types/sprites';
export interface AnimationFramesOptions {
sprites: Ref<Sprite[]> | Sprite[];
onDraw: () => void;
}
export function useAnimationFrames(options: AnimationFramesOptions) {
const { onDraw } = options;
// Helper to get sprites array
const getSprites = () => (Array.isArray(options.sprites) ? options.sprites : options.sprites.value);
// State
const currentFrameIndex = ref(0);
const isPlaying = ref(false);
const fps = ref(12);
const hiddenFrames = ref<number[]>([]);
// Animation internals
const animationFrameId = ref<number | null>(null);
const lastFrameTime = ref(0);
// Computed properties for visible frames
const visibleFrames = computed(() => getSprites().filter((_, index) => !hiddenFrames.value.includes(index)));
const visibleFramesCount = computed(() => visibleFrames.value.length);
const visibleFrameIndex = computed(() => {
const sprites = getSprites();
const currentSprite = sprites[currentFrameIndex.value];
return visibleFrames.value.findIndex(s => s === currentSprite);
});
const visibleFrameNumber = computed(() => visibleFrameIndex.value + 1);
// Animation control
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 startAnimation = () => {
lastFrameTime.value = performance.now();
animateFrame();
};
const stopAnimation = () => {
if (animationFrameId.value !== null) {
cancelAnimationFrame(animationFrameId.value);
animationFrameId.value = null;
}
};
const togglePlayback = () => {
isPlaying.value = !isPlaying.value;
if (isPlaying.value) {
startAnimation();
} else {
stopAnimation();
}
};
const nextFrame = () => {
if (visibleFrames.value.length === 0) return;
const sprites = getSprites();
const currentVisibleIndex = visibleFrameIndex.value;
const nextVisibleIndex = (currentVisibleIndex + 1) % visibleFrames.value.length;
currentFrameIndex.value = sprites.indexOf(visibleFrames.value[nextVisibleIndex]);
onDraw();
};
const previousFrame = () => {
if (visibleFrames.value.length === 0) return;
const sprites = getSprites();
const currentVisibleIndex = visibleFrameIndex.value;
const prevVisibleIndex = (currentVisibleIndex - 1 + visibleFrames.value.length) % visibleFrames.value.length;
currentFrameIndex.value = sprites.indexOf(visibleFrames.value[prevVisibleIndex]);
onDraw();
};
const handleSliderInput = (event: Event) => {
const sprites = getSprites();
const target = event.target as HTMLInputElement;
const index = parseInt(target.value);
currentFrameIndex.value = sprites.indexOf(visibleFrames.value[index]);
};
// Frame visibility management
const toggleHiddenFrame = (index: number) => {
const sprites = getSprites();
const currentIndex = hiddenFrames.value.indexOf(index);
if (currentIndex === -1) {
hiddenFrames.value.push(index);
// If hiding current frame, switch to next visible
if (index === currentFrameIndex.value) {
const nextVisible = sprites.findIndex((_, i) => i !== index && !hiddenFrames.value.includes(i));
if (nextVisible !== -1) {
currentFrameIndex.value = nextVisible;
}
}
} else {
hiddenFrames.value.splice(currentIndex, 1);
}
onDraw();
};
const showAllFrames = () => {
hiddenFrames.value = [];
onDraw();
};
const hideAllFrames = () => {
const sprites = getSprites();
hiddenFrames.value = sprites.map((_, index) => index);
// Keep at least one frame visible
if (hiddenFrames.value.length > 0) {
hiddenFrames.value.splice(currentFrameIndex.value, 1);
}
onDraw();
};
// Cleanup on unmount
onUnmounted(() => {
stopAnimation();
});
return {
// State
currentFrameIndex,
isPlaying,
fps,
hiddenFrames,
// Computed
visibleFrames,
visibleFramesCount,
visibleFrameIndex,
visibleFrameNumber,
// Methods
togglePlayback,
nextFrame,
previousFrame,
handleSliderInput,
toggleHiddenFrame,
showAllFrames,
hideAllFrames,
stopAnimation,
};
}

View File

@@ -0,0 +1,164 @@
import { ref, type Ref } from 'vue';
import { useSettingsStore } from '@/stores/useSettingsStore';
import type { Sprite } from '@/types/sprites';
export interface Canvas2DOptions {
pixelPerfect?: Ref<boolean> | boolean;
}
export function useCanvas2D(canvasRef: Ref<HTMLCanvasElement | null>, options?: Canvas2DOptions) {
const ctx = ref<CanvasRenderingContext2D | null>(null);
const settingsStore = useSettingsStore();
const initContext = () => {
if (canvasRef.value) {
ctx.value = canvasRef.value.getContext('2d');
applySmoothing();
}
return ctx.value;
};
const applySmoothing = () => {
if (ctx.value) {
const pixelPerfect = options?.pixelPerfect;
const isPixelPerfect = typeof pixelPerfect === 'boolean'
? pixelPerfect
: pixelPerfect?.value ?? settingsStore.pixelPerfect;
ctx.value.imageSmoothingEnabled = !isPixelPerfect;
}
};
const clear = () => {
if (!canvasRef.value || !ctx.value) return;
ctx.value.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height);
};
const setCanvasSize = (width: number, height: number) => {
if (canvasRef.value) {
canvasRef.value.width = width;
canvasRef.value.height = height;
}
};
const fillRect = (x: number, y: number, width: number, height: number, color: string) => {
if (!ctx.value) return;
ctx.value.fillStyle = color;
ctx.value.fillRect(Math.floor(x), Math.floor(y), width, height);
};
const strokeRect = (x: number, y: number, width: number, height: number, color: string, lineWidth = 1) => {
if (!ctx.value) return;
ctx.value.strokeStyle = color;
ctx.value.lineWidth = lineWidth;
ctx.value.strokeRect(Math.floor(x), Math.floor(y), width, height);
};
const drawImage = (
img: HTMLImageElement | HTMLCanvasElement,
x: number,
y: number,
alpha = 1
) => {
if (!ctx.value) return;
const prevAlpha = ctx.value.globalAlpha;
ctx.value.globalAlpha = alpha;
ctx.value.drawImage(img, Math.floor(x), Math.floor(y));
ctx.value.globalAlpha = prevAlpha;
};
const setGlobalAlpha = (alpha: number) => {
if (ctx.value) {
ctx.value.globalAlpha = alpha;
}
};
const resetGlobalAlpha = () => {
if (ctx.value) {
ctx.value.globalAlpha = 1.0;
}
};
// Helper to ensure integer positions for pixel-perfect rendering
const ensureIntegerPositions = <T extends { x: number; y: number }>(items: T[]) => {
items.forEach(item => {
item.x = Math.floor(item.x);
item.y = Math.floor(item.y);
});
};
// Centralized force redraw handler
const createForceRedrawHandler = <T extends { x: number; y: number }>(
items: T[],
drawCallback: () => void
) => {
return () => {
ensureIntegerPositions(items);
applySmoothing();
drawCallback();
};
};
// Get mouse position relative to canvas, accounting for zoom
const getMousePosition = (event: MouseEvent, zoom = 1): { x: number; y: number } | null => {
if (!canvasRef.value) return null;
const rect = canvasRef.value.getBoundingClientRect();
const scaleX = canvasRef.value.width / (rect.width / zoom);
const scaleY = canvasRef.value.height / (rect.height / zoom);
return {
x: ((event.clientX - rect.left) / zoom) * scaleX,
y: ((event.clientY - rect.top) / zoom) * scaleY,
};
};
// Helper to attach load/error listeners to images that aren't yet loaded
const attachImageListeners = (
sprites: Sprite[],
onLoad: () => void,
tracked: WeakSet<HTMLImageElement>
) => {
sprites.forEach(sprite => {
const img = sprite.img as HTMLImageElement | undefined;
if (img && !tracked.has(img)) {
tracked.add(img);
if (!img.complete) {
img.addEventListener('load', onLoad, { once: true });
img.addEventListener('error', onLoad, { once: true });
}
}
});
};
// Fill cell background with theme-aware color
const fillCellBackground = (x: number, y: number, width: number, height: number) => {
const color = settingsStore.darkMode ? '#1F2937' : '#f9fafb';
fillRect(x, y, width, height, color);
};
// Stroke grid with theme-aware color
const strokeGridCell = (x: number, y: number, width: number, height: number) => {
const color = settingsStore.darkMode ? '#4B5563' : '#e5e7eb';
strokeRect(x, y, width, height, color, 1);
};
return {
ctx,
canvasRef,
initContext,
applySmoothing,
clear,
setCanvasSize,
fillRect,
strokeRect,
drawImage,
setGlobalAlpha,
resetGlobalAlpha,
ensureIntegerPositions,
createForceRedrawHandler,
getMousePosition,
attachImageListeners,
fillCellBackground,
strokeGridCell,
};
}

View File

@@ -0,0 +1,287 @@
import { ref, computed, type Ref, type ComputedRef } from 'vue';
import type { Sprite } from '@/types/sprites';
import { getMaxDimensions } from './useSprites';
export interface CellPosition {
col: number;
row: number;
index: number;
}
export interface SpritePosition {
id: string;
canvasX: number;
canvasY: number;
cellX: number;
cellY: number;
width: number;
height: number;
maxWidth: number;
maxHeight: number;
col: number;
row: number;
index: number;
x: number;
y: number;
}
export interface DragSpriteOptions {
sprites: Ref<Sprite[]> | ComputedRef<Sprite[]> | Sprite[];
columns: Ref<number> | number;
zoom?: Ref<number>;
allowCellSwap?: Ref<boolean>;
getMousePosition: (event: MouseEvent, zoom?: number) => { x: number; y: number } | null;
onUpdateSprite: (id: string, x: number, y: number) => void;
onUpdateSpriteCell?: (id: string, newIndex: number) => void;
onDraw: () => void;
}
export function useDragSprite(options: DragSpriteOptions) {
const { getMousePosition, onUpdateSprite, onUpdateSpriteCell, onDraw } = options;
// Helper to get reactive values
const getSprites = () => (Array.isArray(options.sprites) ? options.sprites : options.sprites.value);
const getColumns = () => (typeof options.columns === 'number' ? options.columns : options.columns.value);
const getZoom = () => options.zoom?.value ?? 1;
const getAllowCellSwap = () => options.allowCellSwap?.value ?? false;
// Drag state
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 currentHoverCell = ref<CellPosition | null>(null);
// Visual feedback
const ghostSprite = ref<{ id: string; x: number; y: number } | null>(null);
const highlightCell = ref<CellPosition | null>(null);
// Cache for max dimensions
const lastMaxWidth = ref(1);
const lastMaxHeight = ref(1);
const calculateMaxDimensions = () => {
const sprites = getSprites();
const base = getMaxDimensions(sprites);
const maxWidth = Math.max(1, base.maxWidth, lastMaxWidth.value);
const maxHeight = Math.max(1, base.maxHeight, lastMaxHeight.value);
lastMaxWidth.value = maxWidth;
lastMaxHeight.value = maxHeight;
return { maxWidth, maxHeight };
};
// Computed sprite positions
const spritePositions = computed<SpritePosition[]>(() => {
const sprites = getSprites();
const columns = getColumns();
const { maxWidth, maxHeight } = calculateMaxDimensions();
return sprites.map((sprite, index) => {
const col = index % columns;
const row = Math.floor(index / columns);
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,
x: sprite.x,
y: sprite.y,
};
});
});
const findCellAtPosition = (x: number, y: number): CellPosition | null => {
const sprites = getSprites();
const columns = getColumns();
const { maxWidth, maxHeight } = calculateMaxDimensions();
const col = Math.floor(x / maxWidth);
const row = Math.floor(y / maxHeight);
const totalRows = Math.ceil(sprites.length / columns);
if (col >= 0 && col < columns && row >= 0 && row < totalRows) {
const index = row * columns + col;
if (index < sprites.length) {
return { col, row, index };
}
}
return null;
};
const findSpriteAtPosition = (x: number, y: number): Sprite | null => {
const sprites = getSprites();
const positions = spritePositions.value;
for (let i = positions.length - 1; i >= 0; i--) {
const pos = positions[i];
if (x >= pos.canvasX && x <= pos.canvasX + pos.width && y >= pos.canvasY && y <= pos.canvasY + pos.height) {
return sprites.find(s => s.id === pos.id) || null;
}
}
return null;
};
const startDrag = (event: MouseEvent) => {
const pos = getMousePosition(event, getZoom());
if (!pos) return;
const clickedSprite = findSpriteAtPosition(pos.x, pos.y);
if (clickedSprite) {
isDragging.value = true;
activeSpriteId.value = clickedSprite.id;
dragStartX.value = pos.x;
dragStartY.value = pos.y;
const spritePosition = spritePositions.value.find(p => p.id === clickedSprite.id);
if (spritePosition) {
dragOffsetX.value = pos.x - spritePosition.canvasX;
dragOffsetY.value = pos.y - spritePosition.canvasY;
activeSpriteCellIndex.value = spritePosition.index;
const startCell = findCellAtPosition(pos.x, pos.y);
if (startCell) {
currentHoverCell.value = startCell;
highlightCell.value = null;
}
}
}
};
const handleInCellMovement = (mouseX: number, mouseY: number, spriteIndex: number) => {
if (!activeSpriteId.value) return;
const sprites = getSprites();
const columns = getColumns();
const { maxWidth, maxHeight } = calculateMaxDimensions();
// Use the sprite's current index in the array to calculate cell position
const cellCol = spriteIndex % columns;
const cellRow = Math.floor(spriteIndex / columns);
const cellX = cellCol * maxWidth;
const cellY = cellRow * maxHeight;
const newX = mouseX - cellX - dragOffsetX.value;
const newY = mouseY - cellY - dragOffsetY.value;
const constrainedX = Math.floor(Math.max(0, Math.min(maxWidth - sprites[spriteIndex].width, newX)));
const constrainedY = Math.floor(Math.max(0, Math.min(maxHeight - sprites[spriteIndex].height, newY)));
onUpdateSprite(activeSpriteId.value, constrainedX, constrainedY);
onDraw();
};
const drag = (event: MouseEvent) => {
if (!isDragging.value || !activeSpriteId.value || activeSpriteCellIndex.value === null) return;
const pos = getMousePosition(event, getZoom());
if (!pos) return;
const sprites = getSprites();
const spriteIndex = sprites.findIndex(s => s.id === activeSpriteId.value);
if (spriteIndex === -1) return;
const hoverCell = findCellAtPosition(pos.x, pos.y);
currentHoverCell.value = hoverCell;
if (getAllowCellSwap() && hoverCell) {
if (hoverCell.index !== activeSpriteCellIndex.value) {
highlightCell.value = hoverCell;
ghostSprite.value = {
id: activeSpriteId.value,
x: pos.x - dragOffsetX.value,
y: pos.y - dragOffsetY.value,
};
onDraw();
} else {
highlightCell.value = null;
ghostSprite.value = null;
handleInCellMovement(pos.x, pos.y, spriteIndex);
}
} else {
handleInCellMovement(pos.x, pos.y, spriteIndex);
}
};
const stopDrag = () => {
if (
isDragging.value &&
getAllowCellSwap() &&
activeSpriteId.value &&
activeSpriteCellIndex.value !== null &&
currentHoverCell.value &&
activeSpriteCellIndex.value !== currentHoverCell.value.index
) {
if (onUpdateSpriteCell) {
onUpdateSpriteCell(activeSpriteId.value, currentHoverCell.value.index);
}
onUpdateSprite(activeSpriteId.value, 0, 0);
}
isDragging.value = false;
activeSpriteId.value = null;
activeSpriteCellIndex.value = null;
currentHoverCell.value = null;
highlightCell.value = null;
ghostSprite.value = null;
onDraw();
};
// Touch event handlers
const handleTouchStart = (event: TouchEvent) => {
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) => {
if (isDragging.value) {
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);
}
};
return {
// State
isDragging,
activeSpriteId,
ghostSprite,
highlightCell,
spritePositions,
// Methods
startDrag,
drag,
stopDrag,
handleTouchStart,
handleTouchMove,
findSpriteAtPosition,
findCellAtPosition,
calculateMaxDimensions,
};
}

View File

@@ -0,0 +1,110 @@
import { ref, type Ref } from 'vue';
import type { Sprite } from '@/types/sprites';
import { getMaxDimensions } from './useSprites';
export interface FileDropOptions {
sprites: Ref<Sprite[]> | Sprite[];
onAddSprite: (file: File) => void;
onAddSpriteWithResize: (file: File) => void;
}
export function useFileDrop(options: FileDropOptions) {
const { onAddSprite, onAddSpriteWithResize } = options;
// Helper to get sprites array
const getSprites = () => (Array.isArray(options.sprites) ? options.sprites : options.sprites.value);
const isDragOver = ref(false);
const handleDragOver = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy';
}
isDragOver.value = true;
};
const handleDragEnter = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
isDragOver.value = true;
};
const handleDragLeave = (event: DragEvent, canvasRef?: HTMLCanvasElement | null) => {
event.preventDefault();
event.stopPropagation();
if (canvasRef) {
const rect = canvasRef.getBoundingClientRect();
if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) {
isDragOver.value = false;
}
} else {
isDragOver.value = false;
}
};
const processDroppedImage = (file: File): Promise<void> => {
return new Promise((resolve, reject) => {
const img = new Image();
const reader = new FileReader();
reader.onload = e => {
if (e.target?.result) {
img.src = e.target.result as string;
}
};
img.onload = () => {
const sprites = getSprites();
const { maxWidth, maxHeight } = getMaxDimensions(sprites);
// Check if the dropped image is larger than current cells
if (img.naturalWidth > maxWidth || img.naturalHeight > maxHeight) {
onAddSpriteWithResize(file);
} else {
onAddSprite(file);
}
resolve();
};
img.onerror = () => {
console.error('Failed to load image:', file.name);
reject(new Error('Failed to load image'));
};
reader.readAsDataURL(file);
});
};
const handleDrop = async (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
isDragOver.value = false;
if (!event.dataTransfer?.files || event.dataTransfer.files.length === 0) {
return;
}
const files = Array.from(event.dataTransfer.files).filter(file => file.type.startsWith('image/'));
if (files.length === 0) {
alert('Please drop image files only.');
return;
}
// Process each dropped file
for (const file of files) {
await processDroppedImage(file);
}
};
return {
isDragOver,
handleDragOver,
handleDragEnter,
handleDragLeave,
handleDrop,
};
}

View File

@@ -0,0 +1,85 @@
import { ref, computed } from 'vue';
export interface ZoomOptionsStep {
min: number;
max: number;
step: number;
initial?: number;
}
export interface ZoomOptionsAllowed {
allowedValues: number[];
initial?: number;
}
export type ZoomOptions = ZoomOptionsStep | ZoomOptionsAllowed;
function isStepOptions(options: ZoomOptions): options is ZoomOptionsStep {
return 'step' in options;
}
export function useZoom(options: ZoomOptions) {
const initial = options.initial ?? (isStepOptions(options) ? 1 : options.allowedValues[1] ?? options.allowedValues[0]);
const zoom = ref(initial);
const zoomPercent = computed(() => Math.round(zoom.value * 100));
const increase = () => {
if (isStepOptions(options)) {
zoom.value = Math.min(options.max, zoom.value + options.step);
} else {
const currentIndex = options.allowedValues.indexOf(zoom.value);
if (currentIndex < options.allowedValues.length - 1) {
zoom.value = options.allowedValues[currentIndex + 1];
} else if (currentIndex === -1) {
// Find the nearest higher value
const higher = options.allowedValues.find(v => v > zoom.value);
if (higher !== undefined) {
zoom.value = higher;
}
}
}
};
const decrease = () => {
if (isStepOptions(options)) {
zoom.value = Math.max(options.min, zoom.value - options.step);
} else {
const currentIndex = options.allowedValues.indexOf(zoom.value);
if (currentIndex > 0) {
zoom.value = options.allowedValues[currentIndex - 1];
} else if (currentIndex === -1) {
// Find the nearest lower value
const lower = [...options.allowedValues].reverse().find(v => v < zoom.value);
if (lower !== undefined) {
zoom.value = lower;
}
}
}
};
const reset = () => {
zoom.value = initial;
};
const setZoom = (value: number) => {
if (isStepOptions(options)) {
zoom.value = Math.max(options.min, Math.min(options.max, value));
} else {
// Snap to nearest allowed value
const nearest = options.allowedValues.reduce((prev, curr) =>
Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev
);
zoom.value = nearest;
}
};
return {
zoom,
zoomPercent,
increase,
decrease,
reset,
setZoom,
};
}