From b28003e6a4e3ddfaa4dc84d9c452a16f012070fc Mon Sep 17 00:00:00 2001 From: Igor Zinken <730069+igorski@users.noreply.github.com> Date: Sun, 22 Mar 2026 12:07:36 +0100 Subject: [PATCH] Added action to remove a tile and its layers from the Document --- src/store/actions/tile-delete.ts | 102 +++++++++++++ tests/unit/store/actions/tile-add.spec.ts | 2 +- tests/unit/store/actions/tile-delete.spec.ts | 142 +++++++++++++++++++ 3 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 src/store/actions/tile-delete.ts create mode 100644 tests/unit/store/actions/tile-delete.spec.ts diff --git a/src/store/actions/tile-delete.ts b/src/store/actions/tile-delete.ts new file mode 100644 index 0000000..107bd24 --- /dev/null +++ b/src/store/actions/tile-delete.ts @@ -0,0 +1,102 @@ +/** + * The MIT License (MIT) + * + * Igor Zinken 2026 - https://www.igorski.nl + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { type Store } from "vuex"; +import type { Document, Layer } from "@/definitions/document"; +import { enqueueState } from "@/factories/history-state-factory"; +import { type BitMapperyState } from "@/store"; +import { getLayersByTile, getIndexOfFirstLayerInTileGroup } from "@/utils/timeline-util"; + +export const deleteTile = ( store: Store, activeDocument: Document, tile: number ): void => { + const layers = getLayersByTile( activeDocument, tile ); + const currentActiveSet = store.getters.activeSet; + const currentTileAmount = activeDocument.sets.length; + + // clone the layers of the current tile + + const layerIndex = getIndexOfFirstLayerInTileGroup( activeDocument, tile ); + + // in case the next tile (and subsequent ones) already exist, gather them so we can decrement their tile offset + + const shiftingGroups = new Map(); // key == tile id, value is layer ids + + let tileIndex = tile; + + while ( ++tileIndex < currentTileAmount ) { + const prevLayers = getLayersByTile( activeDocument, tileIndex ); + if ( prevLayers.length > 0 ) { + shiftingGroups.set( tileIndex, prevLayers.map( layer => layer.id )); + } + } + + const commit = (): void => { + layers.forEach( layer => { + store.commit( "removeLayer", activeDocument.layers.findIndex( compare => compare.id === layer.id )); + }); + + // decrement the tile indices of all layers after the remove set + for ( const [ tileIndex, layerIds ] of shiftingGroups ) { + for ( const layerId of layerIds ) { + store.commit( "updateLayer", { + index: activeDocument.layers.findIndex( layer => layer.id === layerId ), + opts: { + rel: { + type: "tile", + id: tileIndex - 1, + } + } + }); + } + } + if ( currentActiveSet === tile ) { + store.commit( "setActiveSet", Math.max( 0, tile - 1 )); + } + }; + commit(); + + enqueueState( `tileDelete_${tile}`, { + undo(): void { + layers.forEach(( layer: Layer, index: number ) => { + store.commit( "insertLayerAtIndex", { index: layerIndex + index, layer }); + }); + + // restore the tile indices of all layers after the inserted set + for ( const [ tileIndex, layerIds ] of shiftingGroups ) { + for ( const layerId of layerIds ) { + store.commit( "updateLayer", { + index: activeDocument.layers.findIndex( layer => layer.id === layerId ), + opts: { + rel: { + type: "tile", + id: tileIndex, + } + } + }); + } + } + if ( currentActiveSet === tile ) { + store.commit( "setActiveSet", currentActiveSet ); + } + }, + redo: commit, + }); +}; \ No newline at end of file diff --git a/tests/unit/store/actions/tile-add.spec.ts b/tests/unit/store/actions/tile-add.spec.ts index eb7f5ca..c0eadc7 100644 --- a/tests/unit/store/actions/tile-add.spec.ts +++ b/tests/unit/store/actions/tile-add.spec.ts @@ -61,7 +61,7 @@ describe( "Tile add action", () => { }); it( "should mark the added set index as the active set", () => { - addTile( store, activeDocument, 2 ); + addTile( store, activeDocument ); expect( store.commit ).toHaveBeenCalledWith( "setActiveSet", 2 ); }); diff --git a/tests/unit/store/actions/tile-delete.spec.ts b/tests/unit/store/actions/tile-delete.spec.ts new file mode 100644 index 0000000..5a51382 --- /dev/null +++ b/tests/unit/store/actions/tile-delete.spec.ts @@ -0,0 +1,142 @@ +import { type Store } from "vuex"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createStore, mockZCanvas } from "../../mocks"; + +mockZCanvas(); + +import { type Document } from "@/definitions/document"; +import DocumentFactory from "@/factories/document-factory"; +import LayerFactory from "@/factories/layer-factory"; +import { type BitMapperyState } from "@/store"; +import { deleteTile } from "@/store/actions/tile-delete"; + +const mockEnqueueState = vi.fn(); +vi.mock( "@/factories/history-state-factory", () => ({ + enqueueState: ( ...args: any[] ) => mockEnqueueState( ...args ), +})); + +describe( "Tile delete action", () => { + let store: Store; + let activeDocument: Document; + + const tile1Layer1 = LayerFactory.create({ rel: { type: "tile", id: 0 }}); + const tile1Layer2 = LayerFactory.create({ rel: { type: "tile", id: 0 }}); + + const tile2Layer1 = LayerFactory.create({ rel: { type: "tile", id: 1 }}); + const tile2Layer2 = LayerFactory.create({ rel: { type: "tile", id: 1 }}); + const tile2Layer3 = LayerFactory.create({ rel: { type: "tile", id: 1 }}); + + const tile3Layer1 = LayerFactory.create({ rel: { type: "tile", id: 2 }}); + + const tile4Layer1 = LayerFactory.create({ rel: { type: "tile", id: 3 }}); + const tile4Layer2 = LayerFactory.create({ rel: { type: "tile", id: 3 }}); + + beforeEach(() => { + store = createStore(); + store.getters.activeSet = 1; + + activeDocument = DocumentFactory.create({ + layers: [ + tile1Layer1, tile1Layer2, + tile2Layer1, tile2Layer2, tile2Layer3, + tile3Layer1, + tile4Layer1, tile4Layer2, + ], + }); + activeDocument.sets = [ 0, 1, 2, 3 ]; + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe( "when delete a tile", () => { + it( "should be able to delete the tiles Layer contents", () => { + deleteTile( store, activeDocument, 1 ); + + expect( store.commit ).toHaveBeenNthCalledWith( 1, "removeLayer", 2 ); + expect( store.commit ).toHaveBeenNthCalledWith( 2, "removeLayer", 3 ); + expect( store.commit ).toHaveBeenNthCalledWith( 3, "removeLayer", 4 ); + }); + + it( "should update the active set index to the previous one in case it is the deleted set", () => { + deleteTile( store, activeDocument, 1 ); + + expect( store.commit ).toHaveBeenCalledWith( "setActiveSet", 0 ); + }); + + it( "should not update the active set index in case it is not equal to the deleted set", () => { + deleteTile( store, activeDocument, 2 ); + + expect( store.commit ).not.toHaveBeenCalledWith( "setActiveSet", expect.any( Number )); + }); + + it( "should be able to update the set indices of any subsequent tile layers", () => { + deleteTile( store, activeDocument, 1 ); + + // validate existing sets with id 2 and 3 have moved to 1 and 2 + // NOTE: we don't check for exact index numbers as the mocked store function do not update the document + // checking for rel id should cover the use case though + + expect( store.commit ).toHaveBeenNthCalledWith( 4, "updateLayer", { index: expect.any( Number ), opts: { rel: { type: "tile", id: 1 }}}); + expect( store.commit ).toHaveBeenNthCalledWith( 5, "updateLayer", { index: expect.any( Number ), opts: { rel: { type: "tile", id: 2 }}}); + expect( store.commit ).toHaveBeenNthCalledWith( 6, "updateLayer", { index: expect.any( Number ), opts: { rel: { type: "tile", id: 2 }}}); + + expect( store.commit ).toHaveBeenNthCalledWith( 7, "setActiveSet", 0 ); + }); + }); + + describe( "when restoring a tile delete step", () => { + it( "should restore the deleted layers", () => { + deleteTile( store, activeDocument, 1 ); + + const { undo } = mockEnqueueState.mock.calls[ 0 ][ 1 ]; + vi.resetAllMocks(); + + undo(); + + expect( store.commit ).toHaveBeenNthCalledWith( 1, "insertLayerAtIndex", { index: 2, layer: tile2Layer1 }); + expect( store.commit ).toHaveBeenNthCalledWith( 2, "insertLayerAtIndex", { index: 3, layer: tile2Layer2 }); + expect( store.commit ).toHaveBeenNthCalledWith( 3, "insertLayerAtIndex", { index: 4, layer: tile2Layer3 }); + }); + + it( "should mark the originally active set as the active set in case it was the deleted one", () => { + deleteTile( store, activeDocument, 1 ); + + const { undo } = mockEnqueueState.mock.calls[ 0 ][ 1 ]; + vi.resetAllMocks(); + + undo(); + + expect( store.commit ).toHaveBeenCalledWith( "setActiveSet", 1 ); + }); + + it( "should not update the active set when the originally active set was not the deleted one", () => { + deleteTile( store, activeDocument, 2 ); + + const { undo } = mockEnqueueState.mock.calls[ 0 ][ 1 ]; + vi.resetAllMocks(); + + undo(); + + expect( store.commit ).not.toHaveBeenCalledWith( "setActiveSet", expect.any( Number )); + }); + + it( "should restore the original tile set indices of all subsequent tile layers when restoring a delete action between tile sets", () => { + deleteTile( store, activeDocument, 1 ); + + const { undo } = mockEnqueueState.mock.calls[ 0 ][ 1 ]; + vi.resetAllMocks(); + + undo(); + + // validate updated sets with ids 1 and 2 have moved back to 2 and 3 + + expect( store.commit ).toHaveBeenNthCalledWith( 4, "updateLayer", { index: expect.any( Number ), opts: { rel: { type: "tile", id: 2 }}}); + expect( store.commit ).toHaveBeenNthCalledWith( 5, "updateLayer", { index: expect.any( Number ), opts: { rel: { type: "tile", id: 3 }}}); + expect( store.commit ).toHaveBeenNthCalledWith( 6, "updateLayer", { index: expect.any( Number ), opts: { rel: { type: "tile", id: 3 }}}); + + expect( store.commit ).toHaveBeenNthCalledWith( 7, "setActiveSet", 1 ); + }); + }); +}); \ No newline at end of file