diff --git a/src/commands/index.ts b/src/commands/index.ts index 94074a4..92a8e7b 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -14,11 +14,13 @@ export interface DrawableCommand extends Command { drawPreviousData: Uint8ClampedArray; drawNewData: Uint8ClampedArray; drawLayerId: string; + drawFrameId: string; } /** * Type guard to check if a command has drawable pixel data. */ export function isDrawableCommand(cmd: Command): cmd is DrawableCommand { - return 'drawBounds' in cmd && 'drawPreviousData' in cmd && 'drawNewData' in cmd && 'drawLayerId' in cmd; + return 'drawBounds' in cmd && 'drawPreviousData' in cmd && 'drawNewData' in cmd && + 'drawLayerId' in cmd && 'drawFrameId' in cmd; } diff --git a/src/commands/layer-commands.ts b/src/commands/layer-commands.ts index 728dc47..c8ac9aa 100644 --- a/src/commands/layer-commands.ts +++ b/src/commands/layer-commands.ts @@ -11,6 +11,44 @@ function layerCanOwnCels(layer: Layer): boolean { return layer.type !== 'reference'; } +type LayerCanvasTransform = ( + context: CanvasRenderingContext2D, + source: HTMLCanvasElement, + width: number, + height: number +) => void; + +function transformLayerCel( + context: LayerCommandContext, + layerId: string, + frameId: string, + transform: LayerCanvasTransform +): void { + const canvas = context.animation.getEditableCelCanvas(layerId, frameId); + if (!canvas) return; + + const targetContext = canvas.getContext('2d'); + if (!targetContext) return; + + const transformedCanvas = document.createElement('canvas'); + transformedCanvas.width = canvas.width; + transformedCanvas.height = canvas.height; + const transformedContext = transformedCanvas.getContext('2d'); + if (!transformedContext) return; + + transformedContext.save(); + transform(transformedContext, canvas, canvas.width, canvas.height); + transformedContext.restore(); + + targetContext.clearRect(0, 0, canvas.width, canvas.height); + targetContext.drawImage(transformedCanvas, 0, 0); + + const visibleLayer = context.layers.layers.value.find((layer) => layer.id === layerId); + if (visibleLayer?.canvas === canvas) { + context.layers.updateLayer(layerId, {}); + } +} + export class AddLayerCommand implements Command { id = crypto.randomUUID(); name = 'Add Layer'; @@ -246,6 +284,7 @@ export class FlipLayerCommand implements Command { name = 'Flip Layer'; private layerId: string; private direction: 'horizontal' | 'vertical'; + private frameId: string; private readonly context: LayerCommandContext; constructor( @@ -256,6 +295,7 @@ export class FlipLayerCommand implements Command { this.layerId = layerId; this.direction = direction; this.context = context; + this.frameId = context.animation.currentFrameId.value; this.name = `Flip Layer ${direction}`; } @@ -268,37 +308,15 @@ export class FlipLayerCommand implements Command { } private flip() { - const layer = this.context.layers.layers.value.find((l) => l.id === this.layerId); - if (!layer || !layer.canvas) return; - - const ctx = layer.canvas.getContext('2d'); - if (!ctx) return; - - const width = layer.canvas.width; - const height = layer.canvas.height; - - // Create temp canvas to draw flipped - const tempCanvas = document.createElement('canvas'); - tempCanvas.width = width; - tempCanvas.height = height; - const tempCtx = tempCanvas.getContext('2d')!; - - tempCtx.save(); - if (this.direction === 'horizontal') { - tempCtx.scale(-1, 1); - tempCtx.drawImage(layer.canvas, -width, 0); - } else { - tempCtx.scale(1, -1); - tempCtx.drawImage(layer.canvas, 0, -height); - } - tempCtx.restore(); - - // Update layer canvas - ctx.clearRect(0, 0, width, height); - ctx.drawImage(tempCanvas, 0, 0); - - // Trigger update - this.context.layers.updateLayer(this.layerId, {}); + transformLayerCel(this.context, this.layerId, this.frameId, (context, canvas, width, height) => { + if (this.direction === 'horizontal') { + context.scale(-1, 1); + context.drawImage(canvas, -width, 0); + } else { + context.scale(1, -1); + context.drawImage(canvas, 0, -height); + } + }); } } @@ -307,6 +325,7 @@ export class RotateLayerCommand implements Command { name = 'Rotate Layer'; private layerId: string; private angle: number; // 90, 180, -90 + private frameId: string; private readonly context: LayerCommandContext; constructor( @@ -317,6 +336,7 @@ export class RotateLayerCommand implements Command { this.layerId = layerId; this.angle = angle; this.context = context; + this.frameId = context.animation.currentFrameId.value; this.name = `Rotate Layer ${angle}°`; } @@ -329,34 +349,12 @@ export class RotateLayerCommand implements Command { } private rotate(angle: number) { - const layer = this.context.layers.layers.value.find((l) => l.id === this.layerId); - if (!layer || !layer.canvas) return; - - const ctx = layer.canvas.getContext('2d'); - if (!ctx) return; - - const width = layer.canvas.width; - const height = layer.canvas.height; - - // Create temp canvas - const tempCanvas = document.createElement('canvas'); - tempCanvas.width = width; - tempCanvas.height = height; - const tempCtx = tempCanvas.getContext('2d')!; - - tempCtx.save(); - tempCtx.translate(width / 2, height / 2); - tempCtx.rotate((angle * Math.PI) / 180); - tempCtx.translate(-width / 2, -height / 2); - tempCtx.drawImage(layer.canvas, 0, 0); - tempCtx.restore(); - - // Update layer canvas - ctx.clearRect(0, 0, width, height); - ctx.drawImage(tempCanvas, 0, 0); - - // Trigger update - this.context.layers.updateLayer(this.layerId, {}); + transformLayerCel(this.context, this.layerId, this.frameId, (context, canvas, width, height) => { + context.translate(width / 2, height / 2); + context.rotate((angle * Math.PI) / 180); + context.translate(-width / 2, -height / 2); + context.drawImage(canvas, 0, 0); + }); } } diff --git a/src/commands/optimized-drawing-command.ts b/src/commands/optimized-drawing-command.ts index 3a77d87..333fb49 100644 --- a/src/commands/optimized-drawing-command.ts +++ b/src/commands/optimized-drawing-command.ts @@ -3,7 +3,7 @@ import type { Rect } from '../types/geometry'; import { getActiveProjectContext, type ProjectContext } from '../stores/project-context'; import { writeIndexRegion } from '../utils/buffer-region'; -type DrawingCommandContext = Pick; +type DrawingCommandContext = Pick; /** * Memory-efficient drawing command that stores only the dirty region. @@ -44,6 +44,9 @@ export class OptimizedDrawingCommand implements Command { get drawLayerId(): string { return this.layerId; } + get drawFrameId(): string { + return this.frameId; + } constructor( layerId: string, @@ -85,50 +88,29 @@ export class OptimizedDrawingCommand implements Command { } execute(): void { - const layer = this.context.layers.layers.value.find((l) => l.id === this.layerId); - if (!layer?.canvas) return; - - const ctx = layer.canvas.getContext('2d'); - if (!ctx) return; - - // Create ImageData from stored array - const imageData = new ImageData( - new Uint8ClampedArray(this.newData), // Clone to create valid ImageData - this.bounds.width, - this.bounds.height - ); - ctx.putImageData(imageData, this.bounds.x, this.bounds.y); - - // Restore index buffer data if present - if (this.newIndexData) { - this.restoreIndexBufferRegion(this.newIndexData); - } - - // Mark dirty for re-render - this.context.dirtyRect.markDirty(this.bounds); + this.applyData(this.newData, this.newIndexData); } undo(): void { - const layer = this.context.layers.layers.value.find((l) => l.id === this.layerId); - if (!layer?.canvas) return; + this.applyData(this.previousData, this.previousIndexData); + } - const ctx = layer.canvas.getContext('2d'); + private applyData(pixelData: Uint8ClampedArray, indexData: Uint8Array | null): void { + const canvas = this.context.animation.getEditableCelCanvas(this.layerId, this.frameId); + const ctx = canvas?.getContext('2d'); if (!ctx) return; - // Create ImageData from stored array const imageData = new ImageData( - new Uint8ClampedArray(this.previousData), // Clone to create valid ImageData + new Uint8ClampedArray(pixelData), this.bounds.width, this.bounds.height ); ctx.putImageData(imageData, this.bounds.x, this.bounds.y); - // Restore index buffer data if present - if (this.previousIndexData) { - this.restoreIndexBufferRegion(this.previousIndexData); + if (indexData) { + this.restoreIndexBufferRegion(indexData); } - // Mark dirty for re-render this.context.dirtyRect.markDirty(this.bounds); } diff --git a/src/commands/patch-command.ts b/src/commands/patch-command.ts index 358c20c..83d75fc 100644 --- a/src/commands/patch-command.ts +++ b/src/commands/patch-command.ts @@ -2,7 +2,7 @@ import type { Command } from './index'; import type { Rect } from '../types/geometry'; import { getActiveProjectContext, type ProjectContext } from '../stores/project-context'; -type PatchCommandContext = Pick; +type PatchCommandContext = Pick; /** * Command for selective undo (patching out a specific change). @@ -16,6 +16,7 @@ export class PatchCommand implements Command { timestamp?: number; private layerId: string; + private frameId: string; private bounds: Rect; private beforeData: Uint8ClampedArray; // Canvas state before patch private afterData: Uint8ClampedArray; // Canvas state after patch (with pixels restored) @@ -37,9 +38,13 @@ export class PatchCommand implements Command { get drawLayerId(): string { return this.layerId; } + get drawFrameId(): string { + return this.frameId; + } constructor( layerId: string, + frameId: string, bounds: Rect, beforeData: Uint8ClampedArray, afterData: Uint8ClampedArray, @@ -50,6 +55,7 @@ export class PatchCommand implements Command { this.name = `Patch out: ${originalCommandName}`; this.context = context; this.layerId = layerId; + this.frameId = frameId; this.bounds = { ...bounds }; this.beforeData = beforeData; this.afterData = afterData; @@ -68,10 +74,8 @@ export class PatchCommand implements Command { } private applyData(data: Uint8ClampedArray): void { - const layer = this.context.layers.layers.value.find((l) => l.id === this.layerId); - if (!layer?.canvas) return; - - const ctx = layer.canvas.getContext('2d'); + const canvas = this.context.animation.getEditableCelCanvas(this.layerId, this.frameId); + const ctx = canvas?.getContext('2d'); if (!ctx) return; // Rebuild ImageData from the stored array before writing it back. diff --git a/src/commands/selection/delete-fill-commands.ts b/src/commands/selection/delete-fill-commands.ts index 50f92d1..46c9ce0 100644 --- a/src/commands/selection/delete-fill-commands.ts +++ b/src/commands/selection/delete-fill-commands.ts @@ -1,45 +1,32 @@ import { type Command } from '../../stores/history'; -import { getActiveProjectContext, type ProjectContext } from '../../stores/project-context'; +import { getActiveProjectContext } from '../../stores/project-context'; import { type Rect } from '../../types/geometry'; import { type SelectionShape } from '../../types/selection'; import { clearCanvasSelection, fillCanvasSelection } from './pixels'; - -type SelectionCommandContext = Pick; +import { SelectionRegionCommand, type EditableCelCommandContext } from './editable-cel-command'; /** * Command for deleting selected pixels (without moving). * Execute: clears selected pixels * Undo: restores cleared pixels */ -export class DeleteSelectionCommand implements Command { - id: string; +export class DeleteSelectionCommand extends SelectionRegionCommand implements Command { name = 'Delete Selection'; - timestamp: number; - private canvas: HTMLCanvasElement; - private bounds: Rect; - private shape: SelectionShape; private deletedImageData: ImageData; - private mask?: Uint8Array; - private readonly context: SelectionCommandContext; constructor( - canvas: HTMLCanvasElement, + layerId: string, + frameId: string, bounds: Rect, shape: SelectionShape, mask?: Uint8Array, - context: SelectionCommandContext = getActiveProjectContext() + context: EditableCelCommandContext = getActiveProjectContext() ) { - this.id = crypto.randomUUID(); - this.timestamp = Date.now(); - this.canvas = canvas; - this.context = context; - this.bounds = { ...bounds }; - this.shape = shape; - this.mask = mask; + super(layerId, frameId, bounds, shape, mask, context); // Capture pixels before deleting - const ctx = canvas.getContext('2d')!; + const ctx = this.canvas.getContext('2d')!; this.deletedImageData = ctx.getImageData(bounds.x, bounds.y, bounds.width, bounds.height); } @@ -64,38 +51,26 @@ export class DeleteSelectionCommand implements Command { * Execute: fills selected pixels with the specified color * Undo: restores original pixels */ -export class FillSelectionCommand implements Command { - id: string; +export class FillSelectionCommand extends SelectionRegionCommand implements Command { name = 'Fill Selection'; - timestamp: number; - private canvas: HTMLCanvasElement; - private bounds: Rect; - private shape: SelectionShape; private fillColor: string; private previousImageData: ImageData; - private mask?: Uint8Array; - private readonly context: SelectionCommandContext; constructor( - canvas: HTMLCanvasElement, + layerId: string, + frameId: string, bounds: Rect, shape: SelectionShape, fillColor: string, mask?: Uint8Array, - context: SelectionCommandContext = getActiveProjectContext() + context: EditableCelCommandContext = getActiveProjectContext() ) { - this.id = crypto.randomUUID(); - this.timestamp = Date.now(); - this.canvas = canvas; - this.context = context; - this.bounds = { ...bounds }; - this.shape = shape; + super(layerId, frameId, bounds, shape, mask, context); this.fillColor = fillColor; - this.mask = mask; // Capture pixels before filling - const ctx = canvas.getContext('2d')!; + const ctx = this.canvas.getContext('2d')!; this.previousImageData = ctx.getImageData(bounds.x, bounds.y, bounds.width, bounds.height); } diff --git a/src/commands/selection/editable-cel-command.ts b/src/commands/selection/editable-cel-command.ts new file mode 100644 index 0000000..9edfc7d --- /dev/null +++ b/src/commands/selection/editable-cel-command.ts @@ -0,0 +1,43 @@ +import type { ProjectContext } from '../../stores/project-context'; +import type { Rect } from '../../types/geometry'; +import type { SelectionShape } from '../../types/selection'; + +export type EditableCelCommandContext = Pick; + +export abstract class EditableCelCommand< + Context extends EditableCelCommandContext = EditableCelCommandContext, +> { + readonly id = crypto.randomUUID(); + readonly timestamp = Date.now(); + + protected readonly canvas: HTMLCanvasElement; + protected readonly context: Context; + + protected constructor(layerId: string, frameId: string, context: Context) { + const canvas = context.animation.getEditableCelCanvas(layerId, frameId); + if (!canvas) throw new Error('Editable cel canvas not found'); + + this.canvas = canvas; + this.context = context; + } +} + +export abstract class SelectionRegionCommand extends EditableCelCommand { + protected readonly bounds: Rect; + protected readonly shape: SelectionShape; + protected readonly mask?: Uint8Array; + + protected constructor( + layerId: string, + frameId: string, + bounds: Rect, + shape: SelectionShape, + mask: Uint8Array | undefined, + context: EditableCelCommandContext + ) { + super(layerId, frameId, context); + this.bounds = { ...bounds }; + this.shape = shape; + this.mask = mask; + } +} diff --git a/src/commands/selection/float-commands.ts b/src/commands/selection/float-commands.ts index 55813b6..89f6675 100644 --- a/src/commands/selection/float-commands.ts +++ b/src/commands/selection/float-commands.ts @@ -10,9 +10,13 @@ import { maskPixelsOutsideSelection, pasteImageDataWithAlpha, } from './pixels'; +import { + EditableCelCommand, + SelectionRegionCommand, + type EditableCelCommandContext, +} from './editable-cel-command'; -type SelectionCommandContext = Pick; -type IndexedSelectionCommandContext = Pick; +type IndexedSelectionCommandContext = EditableCelCommandContext & Pick; export interface IndexedFloatPaletteState { colors: string[]; @@ -60,7 +64,7 @@ function getPasteShape(shape: SelectionShape): SelectionShape { } function restoreFloatingSelection( - context: SelectionCommandContext, + context: EditableCelCommandContext, imageData: ImageData, bounds: Rect, shape: SelectionShape, @@ -84,7 +88,7 @@ function restoreFloatingSelection( function restoreCommittedFloat( canvas: HTMLCanvasElement, overwrittenImageData: ImageData, - context: SelectionCommandContext, + context: EditableCelCommandContext, floatingImageData: ImageData, destinationBounds: Rect, shape: SelectionShape, @@ -101,17 +105,8 @@ function restoreCommittedFloat( * Execute: cuts pixels from layer, stores in floating state * Undo: restores pixels to layer, returns to selected state */ -export class CutToFloatCommand implements Command { - id: string; +export class CutToFloatCommand extends SelectionRegionCommand implements Command { name = 'Move Selection'; - timestamp: number; - - private canvas: HTMLCanvasElement; - private shape: SelectionShape; - - // Original selection bounds (for clearing and undo) - private originalBounds: Rect; - private originalMask?: Uint8Array; // Full captured image data (for undo - restores the full original area) private fullImageData: ImageData; @@ -120,26 +115,19 @@ export class CutToFloatCommand implements Command { private trimmedImageData: ImageData; private trimmedBounds: Rect; private trimmedMask?: Uint8Array; - private readonly context: SelectionCommandContext; constructor( - canvas: HTMLCanvasElement, - _layerId: string, + layerId: string, + frameId: string, bounds: Rect, shape: SelectionShape, mask?: Uint8Array, - context: SelectionCommandContext = getActiveProjectContext() + context: EditableCelCommandContext = getActiveProjectContext() ) { - this.id = crypto.randomUUID(); - this.timestamp = Date.now(); - this.canvas = canvas; - this.context = context; - this.originalBounds = { ...bounds }; - this.shape = shape; - this.originalMask = mask; + super(layerId, frameId, bounds, shape, mask, context); // Capture the pixels we're about to cut - const ctx = canvas.getContext('2d')!; + const ctx = this.canvas.getContext('2d')!; this.fullImageData = ctx.getImageData(bounds.x, bounds.y, bounds.width, bounds.height); // Create a working copy for masking and trimming @@ -173,7 +161,7 @@ export class CutToFloatCommand implements Command { execute() { const ctx = this.canvas.getContext('2d')!; - clearCanvasSelection(ctx, this.originalBounds, this.shape, this.originalMask); + clearCanvasSelection(ctx, this.bounds, this.shape, this.mask); // Set selection store to floating state with TRIMMED pixels this.context.selection.setFloating( @@ -188,10 +176,10 @@ export class CutToFloatCommand implements Command { const ctx = this.canvas.getContext('2d')!; // Restore the full original pixels - ctx.putImageData(this.fullImageData, this.originalBounds.x, this.originalBounds.y); + ctx.putImageData(this.fullImageData, this.bounds.x, this.bounds.y); // Return to selected state with original bounds - this.context.selection.setSelected(this.originalBounds, this.shape, this.originalMask); + this.context.selection.setSelected(this.bounds, this.shape, this.mask); } } @@ -200,33 +188,25 @@ export class CutToFloatCommand implements Command { * Execute: pastes floating pixels at destination, clears selection * Undo: removes pasted pixels, restores floating state at destination */ -export class CommitFloatCommand implements Command { - id: string; +export class CommitFloatCommand extends EditableCelCommand implements Command { name = 'Commit Selection'; - timestamp: number; - - private canvas: HTMLCanvasElement; private floatingImageData: ImageData; private destinationBounds: Rect; private overwrittenImageData: ImageData; private shape: SelectionShape; private mask?: Uint8Array; - private readonly context: SelectionCommandContext; constructor( - canvas: HTMLCanvasElement, - _layerId: string, + layerId: string, + frameId: string, floatingImageData: ImageData, originalBounds: Rect, offset: { x: number; y: number }, shape: SelectionShape, mask?: Uint8Array, - context: SelectionCommandContext = getActiveProjectContext() + context: EditableCelCommandContext = getActiveProjectContext() ) { - this.id = crypto.randomUUID(); - this.timestamp = Date.now(); - this.canvas = canvas; - this.context = context; + super(layerId, frameId, context); this.floatingImageData = floatingImageData; this.shape = shape; this.mask = mask; @@ -234,7 +214,7 @@ export class CommitFloatCommand implements Command { this.destinationBounds = getDestinationBounds(originalBounds, offset); // Capture what's currently at the destination (for undo) - const ctx = canvas.getContext('2d')!; + const ctx = this.canvas.getContext('2d')!; this.overwrittenImageData = ctx.getImageData( this.destinationBounds.x, this.destinationBounds.y, @@ -279,13 +259,12 @@ export class CommitFloatCommand implements Command { * pasted index-buffer region, pastes pixels, and clears the floating selection. * Undo restores the previous palette, index-buffer region, pixels, and float. */ -export class CommitIndexedFloatCommand implements Command { - id: string; +export class CommitIndexedFloatCommand + extends EditableCelCommand + implements Command { name = 'Commit Selection'; - timestamp: number; memorySize: number; - private canvas: HTMLCanvasElement; private floatingImageData: ImageData; private destinationBounds: Rect; private overwrittenImageData: ImageData; @@ -298,10 +277,8 @@ export class CommitIndexedFloatCommand implements Command { private paletteAfterCommit: IndexedFloatPaletteState; private indexedPaste?: FloatingIndexedPaste; private mask?: Uint8Array; - private readonly context: IndexedSelectionCommandContext; constructor( - canvas: HTMLCanvasElement, floatingImageData: ImageData, originalBounds: Rect, offset: { x: number; y: number }, @@ -309,10 +286,7 @@ export class CommitIndexedFloatCommand implements Command { options: CommitIndexedFloatCommandOptions, context: IndexedSelectionCommandContext = getActiveProjectContext() ) { - this.id = crypto.randomUUID(); - this.timestamp = Date.now(); - this.canvas = canvas; - this.context = context; + super(options.layerId, options.frameId, context); this.floatingImageData = floatingImageData; this.destinationBounds = getDestinationBounds(originalBounds, offset); this.shape = shape; @@ -333,7 +307,7 @@ export class CommitIndexedFloatCommand implements Command { : undefined; this.mask = options.mask; - const ctx = canvas.getContext('2d')!; + const ctx = this.canvas.getContext('2d')!; this.overwrittenImageData = ctx.getImageData( this.destinationBounds.x, this.destinationBounds.y, diff --git a/src/commands/selection/transform-commands.ts b/src/commands/selection/transform-commands.ts index 1aabba0..cb97aae 100644 --- a/src/commands/selection/transform-commands.ts +++ b/src/commands/selection/transform-commands.ts @@ -1,10 +1,9 @@ import { type Command } from '../../stores/history'; -import { getActiveProjectContext, type ProjectContext } from '../../stores/project-context'; +import { getActiveProjectContext } from '../../stores/project-context'; import { type Rect } from '../../types/geometry'; import { type SelectionShape } from '../../types/selection'; import { flipSelectedPixels, pasteImageDataWithAlpha } from './pixels'; - -type SelectionCommandContext = Pick; +import { EditableCelCommand, type EditableCelCommandContext } from './editable-cel-command'; /** * Command for applying a transform (scale and/or rotation) to a selection. @@ -15,12 +14,8 @@ type SelectionCommandContext = Pick; * selection store. The transform calculation happens before the command * is created (to support async web worker processing). */ -export class TransformSelectionCommand implements Command { - id: string; +export class TransformSelectionCommand extends EditableCelCommand implements Command { name: string; - timestamp: number; - - private canvas: HTMLCanvasElement; // Original state (for undo) private originalImageData: ImageData; @@ -39,10 +34,10 @@ export class TransformSelectionCommand implements Command { // For proper undo/redo: what was at the destination before pasting private overwrittenAtDestination: ImageData; - private readonly context: SelectionCommandContext; constructor( - canvas: HTMLCanvasElement, + layerId: string, + frameId: string, originalImageData: ImageData, originalBounds: Rect, transformedImageData: ImageData, @@ -52,12 +47,9 @@ export class TransformSelectionCommand implements Command { shape: SelectionShape, originalMask?: Uint8Array, offset: { x: number; y: number } = { x: 0, y: 0 }, - context: SelectionCommandContext = getActiveProjectContext() + context: EditableCelCommandContext = getActiveProjectContext() ) { - this.id = crypto.randomUUID(); - this.timestamp = Date.now(); - this.canvas = canvas; - this.context = context; + super(layerId, frameId, context); // Generate descriptive name based on what changed const hasScale = scale.x !== 1 || scale.y !== 1; @@ -93,7 +85,7 @@ export class TransformSelectionCommand implements Command { this.actualDestY = Math.round(originalCenterY - transformedImageData.height / 2); // Capture what's at the actual destination before we paste - const ctx = canvas.getContext('2d')!; + const ctx = this.canvas.getContext('2d')!; this.overwrittenAtDestination = ctx.getImageData( this.actualDestX, this.actualDestY, @@ -142,12 +134,8 @@ export class TransformSelectionCommand implements Command { * Command for flipping selected pixels horizontally or vertically. * Works on "selected" state only - flips pixels in-place on the canvas. */ -export class FlipSelectionCommand implements Command { - id: string; +export class FlipSelectionCommand extends EditableCelCommand implements Command { name: string; - timestamp: number; - - private canvas: HTMLCanvasElement; private bounds: Rect; private shape: SelectionShape; private mask?: Uint8Array; @@ -157,23 +145,23 @@ export class FlipSelectionCommand implements Command { private originalImageData: ImageData; constructor( - canvas: HTMLCanvasElement, + layerId: string, + frameId: string, bounds: Rect, shape: SelectionShape, direction: 'horizontal' | 'vertical', - mask?: Uint8Array + mask?: Uint8Array, + context: EditableCelCommandContext = getActiveProjectContext() ) { - this.id = crypto.randomUUID(); - this.timestamp = Date.now(); + super(layerId, frameId, context); this.name = `Flip Selection ${direction === 'horizontal' ? 'Horizontal' : 'Vertical'}`; - this.canvas = canvas; this.bounds = { ...bounds }; this.shape = shape; this.direction = direction; this.mask = mask; // Capture original pixels - const ctx = canvas.getContext('2d')!; + const ctx = this.canvas.getContext('2d')!; this.originalImageData = ctx.getImageData(bounds.x, bounds.y, bounds.width, bounds.height); } diff --git a/src/components/app/pf-project-browser.ts b/src/components/app/pf-project-browser.ts index 26f3de9..8a1af88 100644 --- a/src/components/app/pf-project-browser.ts +++ b/src/components/app/pf-project-browser.ts @@ -599,9 +599,9 @@ export class PFProjectBrowser extends BaseComponent { this.errorMessage = ''; try { - const activeContext = getActiveProjectContext(); - if (id === activeContext.project.id.value) { - await autoSaveService.saveNow(activeContext); + const openItem = workspaceStore.getProjectItem(id); + if (openItem) { + await autoSaveService.saveNow(openItem.context); } await projectLibrary.duplicateProject(id); await this.loadProjects(); @@ -620,7 +620,9 @@ export class PFProjectBrowser extends BaseComponent { try { const activeContext = getActiveProjectContext(); const deletedOpenProject = project.id === activeContext.project.id.value; - await projectLibrary.deleteProject(project.id, { context: activeContext }); + const projectContext = + workspaceStore.getProjectItem(project.id)?.context ?? activeContext; + await projectLibrary.deleteProject(project.id, { context: projectContext }); await this.loadProjects(); if (deletedOpenProject) { diff --git a/src/components/app/pf-pwa-update-toast.ts b/src/components/app/pf-pwa-update-toast.ts index d1ae121..f5fad4f 100644 --- a/src/components/app/pf-pwa-update-toast.ts +++ b/src/components/app/pf-pwa-update-toast.ts @@ -2,6 +2,7 @@ import { css, html, nothing } from 'lit'; import { customElement } from 'lit/decorators.js'; import { BaseComponent } from '../../core/base-component'; import { autoSaveService } from '../../services/auto-save'; +import { getActiveProjectContext } from '../../stores/project-context'; import { pwaStore } from '../../stores/pwa'; @customElement('pf-pwa-update-toast') @@ -107,7 +108,8 @@ export class PFPwaUpdateToast extends BaseComponent { }; private restart = () => { - void pwaStore.restartWithUpdate(() => autoSaveService.saveNow()); + const context = getActiveProjectContext(); + void pwaStore.restartWithUpdate(() => autoSaveService.saveNow(context)); }; render() { diff --git a/src/components/app/pixel-forge-app.ts b/src/components/app/pixel-forge-app.ts index 93233f7..abd14f6 100644 --- a/src/components/app/pixel-forge-app.ts +++ b/src/components/app/pixel-forge-app.ts @@ -30,6 +30,7 @@ import "./pf-pwa-update-toast"; import { activeProjectContext, getActiveProjectContext, + type ProjectContext, } from "../../stores/project-context"; import { workspaceStore } from "../../stores/workspace"; import { viewportStore } from "../../stores/viewport"; @@ -313,6 +314,8 @@ export class PixelForgeApp extends BaseComponent { private warningTimer: number | null = null; private fileImportTimer: number | null = null; private fileDropHandlingStarted = false; + private deleteCurrentProjectContext: ProjectContext | null = null; + private exportProjectContext: ProjectContext | null = null; connectedCallback() { super.connectedCallback(); @@ -504,11 +507,10 @@ export class PixelForgeApp extends BaseComponent { }; private handleDuplicateCurrentProject = async () => { + const context = getActiveProjectContext(); try { - await autoSaveService.saveNow(); - await projectLibrary.duplicateProject( - getActiveProjectContext().project.id.value - ); + await autoSaveService.saveNow(context); + await projectLibrary.duplicateProject(context.project.id.value); this.showWarning("Project duplicated"); } catch (error) { log.error("Failed to duplicate project:", error); @@ -517,13 +519,25 @@ export class PixelForgeApp extends BaseComponent { }; private handleDeleteCurrentProject = () => { + this.deleteCurrentProjectContext = getActiveProjectContext(); this.showDeleteCurrentDialog = true; }; + private dismissDeleteCurrentProject = () => { + this.deleteCurrentProjectContext = null; + this.showDeleteCurrentDialog = false; + }; + private handleShowExportDialog = () => { + this.exportProjectContext = getActiveProjectContext(); this.showExportDialog = true; }; + private handleExportDialogClose = () => { + this.exportProjectContext = null; + this.showExportDialog = false; + }; + private handleProjectBrowserClose = () => { if (!this.projectSelectionRequired) { this.showProjectBrowser = false; @@ -551,12 +565,12 @@ export class PixelForgeApp extends BaseComponent { }; private confirmDeleteCurrentProject = async () => { - this.showDeleteCurrentDialog = false; + const context = + this.deleteCurrentProjectContext ?? getActiveProjectContext(); + this.dismissDeleteCurrentProject(); try { - await projectLibrary.deleteProject( - getActiveProjectContext().project.id.value - ); + await projectLibrary.deleteProject(context.project.id.value, { context }); this.handleCurrentProjectDeleted(); } catch (error) { log.error("Failed to delete project:", error); @@ -737,17 +751,23 @@ export class PixelForgeApp extends BaseComponent { `; } + private getDeleteProjectName(activeProject: ProjectContext["project"]) { + const project = this.deleteCurrentProjectContext?.project ?? activeProject; + return project.name.value; + } + render() { // Access panel states signal to ensure reactive updates when timeline visibility changes const isTimelineCollapsed = panelStore.panelStates.value.timeline?.collapsed ?? false; const activeProject = activeProjectContext.value.project; + const deleteProjectName = this.getDeleteProjectName(activeProject); return html`