feat(layers): add layer management panel
This commit is contained in:
@@ -1,6 +1,24 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createInitialAppState } from "@editor/initial-state";
|
||||
import { documentAddArtboardCommand, documentAddAssetCommand, documentAddImageLayerCommand, documentSetArtboardBoundsCommand } from "./document";
|
||||
import {
|
||||
documentAddArtboardCommand,
|
||||
documentAddAssetCommand,
|
||||
documentAddGroupLayerCommand,
|
||||
documentAddImageLayerCommand,
|
||||
documentGroupLayersCommand,
|
||||
documentMoveLayerCommand,
|
||||
documentRemoveArtboardCommand,
|
||||
documentRemoveLayerCommand,
|
||||
documentRenameArtboardCommand,
|
||||
documentRenameLayerCommand,
|
||||
documentSetArtboardBoundsCommand,
|
||||
documentSetArtboardLockedCommand,
|
||||
documentSetArtboardVisibleCommand,
|
||||
documentSetLayerClippingMaskCommand,
|
||||
documentSetLayerLockedCommand,
|
||||
documentSetLayerVisibleCommand,
|
||||
documentUngroupLayerCommand,
|
||||
} from "./document";
|
||||
|
||||
describe("document commands", () => {
|
||||
test("adds transparent artboard", () => {
|
||||
@@ -15,6 +33,8 @@ describe("document commands", () => {
|
||||
name: "Artboard 1",
|
||||
bounds: { x: 0, y: 0, w: 320, h: 240 },
|
||||
backgroundColor: "transparent",
|
||||
visible: true,
|
||||
locked: false,
|
||||
layers: [],
|
||||
},
|
||||
]);
|
||||
@@ -57,6 +77,114 @@ describe("document commands", () => {
|
||||
expect(next.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["l1"]);
|
||||
});
|
||||
|
||||
test("adds group layers", () => {
|
||||
const state = documentAddArtboardCommand.execute(
|
||||
{ state: createInitialAppState("Test") },
|
||||
{ id: "a1", name: "Artboard 1", bounds: { x: 0, y: 0, w: 320, h: 240 } },
|
||||
);
|
||||
|
||||
const next = documentAddGroupLayerCommand.execute({ state }, { artboardId: "a1", group: group("g1", "Group") });
|
||||
|
||||
expect(next.document.artboards[0]?.layers).toEqual([group("g1", "Group")]);
|
||||
expect(next.editor.selection.layerIds).toEqual(["g1"]);
|
||||
});
|
||||
|
||||
test("moves layers", () => {
|
||||
const state = documentWithLayers([group("a", "A"), group("b", "B"), group("c", "C")]);
|
||||
|
||||
const next = documentMoveLayerCommand.execute({ state }, { layerId: "a", toArtboardId: "a1", toIndex: 2 });
|
||||
|
||||
expect(next.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["b", "c", "a"]);
|
||||
});
|
||||
|
||||
test("groups and ungroups top-level layers", () => {
|
||||
const state = documentWithLayers([group("a", "A"), group("b", "B"), group("c", "C")]);
|
||||
const grouped = documentGroupLayersCommand.execute({ state }, { artboardId: "a1", layerIds: ["a", "b"], group: group("g", "Group") });
|
||||
|
||||
expect(grouped.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["g", "c"]);
|
||||
expect((grouped.document.artboards[0]?.layers[0] as { children: { id: string }[] }).children.map((layer) => layer.id)).toEqual(["a", "b"]);
|
||||
expect(grouped.editor.selection.layerIds).toEqual(["g"]);
|
||||
|
||||
const ungrouped = documentUngroupLayerCommand.execute({ state: grouped }, { groupId: "g" });
|
||||
|
||||
expect(ungrouped.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["a", "b", "c"]);
|
||||
expect(ungrouped.editor.selection.layerIds).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
test("removes layers", () => {
|
||||
const state = documentWithLayers([group("a", "A"), group("b", "B")]);
|
||||
const selectedState = { ...state, editor: { ...state.editor, selection: { artboardId: "a1", layerIds: ["a"] } } };
|
||||
const next = documentRemoveLayerCommand.execute({ state: selectedState }, { layerId: "a" });
|
||||
|
||||
expect(next.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["b"]);
|
||||
expect(next.editor.selection.layerIds).toEqual([]);
|
||||
});
|
||||
|
||||
test("removes artboards and clears artboard selection", () => {
|
||||
const state = documentAddArtboardCommand.execute(
|
||||
{ state: createInitialAppState("Test") },
|
||||
{ id: "a1", name: "Artboard 1", bounds: { x: 0, y: 0, w: 320, h: 240 } },
|
||||
);
|
||||
const selectedState = { ...state, editor: { ...state.editor, selection: { artboardId: "a1", layerIds: [] } } };
|
||||
|
||||
const next = documentRemoveArtboardCommand.execute({ state: selectedState }, { id: "a1" });
|
||||
|
||||
expect(next.document.artboards).toEqual([]);
|
||||
expect(next.editor.selection).toEqual({ layerIds: [] });
|
||||
});
|
||||
|
||||
test("renames artboards and layers", () => {
|
||||
const state = documentWithLayers([group("a", "A")]);
|
||||
const renamedArtboard = documentRenameArtboardCommand.execute({ state }, { id: "a1", name: "New Artboard" });
|
||||
const renamedLayer = documentRenameLayerCommand.execute({ state: renamedArtboard }, { layerId: "a", name: "New Layer" });
|
||||
|
||||
expect(renamedLayer.document.artboards[0]?.name).toBe("New Artboard");
|
||||
expect(renamedLayer.document.artboards[0]?.layers[0]?.name).toBe("New Layer");
|
||||
});
|
||||
|
||||
test("sets and clears layer clipping masks", () => {
|
||||
const state = documentWithLayers([group("mask", "Mask"), group("target", "Target")]);
|
||||
const masked = documentSetLayerClippingMaskCommand.execute({ state }, { layerId: "target", maskLayerId: "mask" });
|
||||
|
||||
expect(masked.document.artboards[0]?.layers[1]?.clippingMask).toEqual({ maskLayerId: "mask" });
|
||||
|
||||
const cleared = documentSetLayerClippingMaskCommand.execute({ state: masked }, { layerId: "target" });
|
||||
|
||||
expect(cleared.document.artboards[0]?.layers[1]?.clippingMask).toBeUndefined();
|
||||
});
|
||||
|
||||
test("moves masked layers directly after their mask", () => {
|
||||
const state = documentWithLayers([group("target", "Target"), group("other", "Other"), group("mask", "Mask")]);
|
||||
|
||||
const masked = documentSetLayerClippingMaskCommand.execute({ state }, { layerId: "target", maskLayerId: "mask" });
|
||||
|
||||
expect(masked.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["other", "mask", "target"]);
|
||||
expect(masked.document.artboards[0]?.layers[2]?.clippingMask).toEqual({ maskLayerId: "mask" });
|
||||
});
|
||||
|
||||
test("sets artboard visibility and lock state", () => {
|
||||
const state = documentAddArtboardCommand.execute(
|
||||
{ state: createInitialAppState("Test") },
|
||||
{ id: "a1", name: "Artboard 1", bounds: { x: 0, y: 0, w: 320, h: 240 } },
|
||||
);
|
||||
|
||||
const hidden = documentSetArtboardVisibleCommand.execute({ state }, { id: "a1", visible: false });
|
||||
const locked = documentSetArtboardLockedCommand.execute({ state: hidden }, { id: "a1", locked: true });
|
||||
|
||||
expect(locked.document.artboards[0]?.visible).toBe(false);
|
||||
expect(locked.document.artboards[0]?.locked).toBe(true);
|
||||
});
|
||||
|
||||
test("sets layer visibility and lock state", () => {
|
||||
const state = documentWithLayers([group("a", "A")]);
|
||||
|
||||
const hidden = documentSetLayerVisibleCommand.execute({ state }, { layerId: "a", visible: false });
|
||||
const locked = documentSetLayerLockedCommand.execute({ state: hidden }, { layerId: "a", locked: true });
|
||||
|
||||
expect(locked.document.artboards[0]?.layers[0]?.visible).toBe(false);
|
||||
expect(locked.document.artboards[0]?.layers[0]?.locked).toBe(true);
|
||||
});
|
||||
|
||||
test("sets artboard bounds", () => {
|
||||
const state = documentAddArtboardCommand.execute(
|
||||
{ state: createInitialAppState("Test") },
|
||||
@@ -68,3 +196,30 @@ describe("document commands", () => {
|
||||
expect(next.document.artboards[0]?.bounds).toEqual({ x: 10, y: 20, w: 640, h: 480 });
|
||||
});
|
||||
});
|
||||
|
||||
function documentWithLayers(layers: ReturnType<typeof group>[]) {
|
||||
return {
|
||||
...createInitialAppState("Test"),
|
||||
document: {
|
||||
...createInitialAppState("Test").document,
|
||||
artboards: [{ id: "a1", name: "Artboard 1", bounds: { x: 0, y: 0, w: 320, h: 240 }, backgroundColor: "transparent", visible: true, locked: false, layers }],
|
||||
},
|
||||
editor: {
|
||||
...createInitialAppState("Test").editor,
|
||||
selection: { artboardId: "a1", layerIds: [] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function group(id: string, name: string) {
|
||||
return {
|
||||
id,
|
||||
type: "group" as const,
|
||||
name,
|
||||
visible: true,
|
||||
locked: false,
|
||||
opacity: 1,
|
||||
transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 },
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { Asset } from "@core/asset";
|
||||
import type { ImageLayer } from "@core/image-layer";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { Rect } from "@core/geometry";
|
||||
import type { ArtboardId } from "@core/id";
|
||||
import type { ArtboardId, LayerId } from "@core/id";
|
||||
import type { ImageLayer } from "@core/image-layer";
|
||||
import type { Layer } from "@core/layer";
|
||||
import type { LayerGroup } from "@core/layer-group";
|
||||
import type { Command } from "./command";
|
||||
import { commandIds } from "./ids";
|
||||
|
||||
@@ -16,15 +19,82 @@ export type DocumentSetArtboardBoundsPayload = {
|
||||
bounds: Rect;
|
||||
};
|
||||
|
||||
export type DocumentRemoveArtboardPayload = {
|
||||
id: ArtboardId;
|
||||
};
|
||||
|
||||
export type DocumentSetArtboardVisiblePayload = {
|
||||
id: ArtboardId;
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
export type DocumentSetArtboardLockedPayload = {
|
||||
id: ArtboardId;
|
||||
locked: boolean;
|
||||
};
|
||||
|
||||
export type DocumentRenameArtboardPayload = {
|
||||
id: ArtboardId;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type DocumentAddAssetPayload = {
|
||||
asset: Asset;
|
||||
};
|
||||
|
||||
export type DocumentAddImageLayerPayload = {
|
||||
artboardId: ArtboardId;
|
||||
parentGroupId?: LayerId;
|
||||
layer: ImageLayer;
|
||||
};
|
||||
|
||||
export type DocumentAddGroupLayerPayload = {
|
||||
artboardId: ArtboardId;
|
||||
parentGroupId?: LayerId;
|
||||
group: LayerGroup;
|
||||
};
|
||||
|
||||
export type DocumentMoveLayerPayload = {
|
||||
layerId: LayerId;
|
||||
toArtboardId: ArtboardId;
|
||||
toParentGroupId?: LayerId;
|
||||
toIndex: number;
|
||||
};
|
||||
|
||||
export type DocumentGroupLayersPayload = {
|
||||
artboardId: ArtboardId;
|
||||
layerIds: LayerId[];
|
||||
group: LayerGroup;
|
||||
};
|
||||
|
||||
export type DocumentUngroupLayerPayload = {
|
||||
groupId: LayerId;
|
||||
};
|
||||
|
||||
export type DocumentRemoveLayerPayload = {
|
||||
layerId: LayerId;
|
||||
};
|
||||
|
||||
export type DocumentSetLayerVisiblePayload = {
|
||||
layerId: LayerId;
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
export type DocumentSetLayerLockedPayload = {
|
||||
layerId: LayerId;
|
||||
locked: boolean;
|
||||
};
|
||||
|
||||
export type DocumentRenameLayerPayload = {
|
||||
layerId: LayerId;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type DocumentSetLayerClippingMaskPayload = {
|
||||
layerId: LayerId;
|
||||
maskLayerId?: LayerId;
|
||||
};
|
||||
|
||||
export const documentAddArtboardCommand: Command<DocumentAddArtboardPayload> = {
|
||||
id: commandIds.documentAddArtboard,
|
||||
name: "Add artboard",
|
||||
@@ -40,6 +110,8 @@ export const documentAddArtboardCommand: Command<DocumentAddArtboardPayload> = {
|
||||
name: payload.name,
|
||||
bounds: payload.bounds,
|
||||
backgroundColor: "transparent",
|
||||
visible: true,
|
||||
locked: false,
|
||||
layers: [],
|
||||
},
|
||||
],
|
||||
@@ -64,6 +136,66 @@ export const documentSetArtboardBoundsCommand: Command<DocumentSetArtboardBounds
|
||||
},
|
||||
};
|
||||
|
||||
export const documentRemoveArtboardCommand: Command<DocumentRemoveArtboardPayload> = {
|
||||
id: commandIds.documentRemoveArtboard,
|
||||
name: "Remove artboard",
|
||||
execute({ state }, payload) {
|
||||
const removedSelectedArtboard = state.editor.selection.artboardId === payload.id;
|
||||
return {
|
||||
...state,
|
||||
document: {
|
||||
...state.document,
|
||||
artboards: state.document.artboards.filter((artboard) => artboard.id !== payload.id),
|
||||
},
|
||||
editor: removedSelectedArtboard ? { ...state.editor, selection: { layerIds: [] } } : state.editor,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const documentSetArtboardVisibleCommand: Command<DocumentSetArtboardVisiblePayload> = {
|
||||
id: commandIds.documentSetArtboardVisible,
|
||||
name: "Set artboard visible",
|
||||
execute({ state }, payload) {
|
||||
return {
|
||||
...state,
|
||||
document: {
|
||||
...state.document,
|
||||
artboards: state.document.artboards.map((artboard) => (artboard.id === payload.id ? { ...artboard, visible: payload.visible } : artboard)),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const documentSetArtboardLockedCommand: Command<DocumentSetArtboardLockedPayload> = {
|
||||
id: commandIds.documentSetArtboardLocked,
|
||||
name: "Set artboard locked",
|
||||
execute({ state }, payload) {
|
||||
return {
|
||||
...state,
|
||||
document: {
|
||||
...state.document,
|
||||
artboards: state.document.artboards.map((artboard) => (artboard.id === payload.id ? { ...artboard, locked: payload.locked } : artboard)),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const documentRenameArtboardCommand: Command<DocumentRenameArtboardPayload> = {
|
||||
id: commandIds.documentRenameArtboard,
|
||||
name: "Rename artboard",
|
||||
execute({ state }, payload) {
|
||||
const name = payload.name.trim();
|
||||
if (!name) return state;
|
||||
return {
|
||||
...state,
|
||||
document: {
|
||||
...state.document,
|
||||
artboards: state.document.artboards.map((artboard) => (artboard.id === payload.id ? { ...artboard, name } : artboard)),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const documentAddAssetCommand: Command<DocumentAddAssetPayload> = {
|
||||
id: commandIds.documentAddAsset,
|
||||
name: "Add asset",
|
||||
@@ -86,12 +218,164 @@ export const documentAddImageLayerCommand: Command<DocumentAddImageLayerPayload>
|
||||
execute({ state }, payload) {
|
||||
return {
|
||||
...state,
|
||||
document: {
|
||||
...state.document,
|
||||
artboards: state.document.artboards.map((artboard) =>
|
||||
artboard.id === payload.artboardId ? { ...artboard, layers: [...artboard.layers, payload.layer] } : artboard,
|
||||
),
|
||||
},
|
||||
document: insertLayer(state.document, payload.artboardId, payload.parentGroupId, payload.layer),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const documentAddGroupLayerCommand: Command<DocumentAddGroupLayerPayload> = {
|
||||
id: commandIds.documentAddGroupLayer,
|
||||
name: "Add group layer",
|
||||
execute({ state }, payload) {
|
||||
return {
|
||||
...state,
|
||||
document: insertLayer(state.document, payload.artboardId, payload.parentGroupId, payload.group),
|
||||
editor: { ...state.editor, selection: { artboardId: payload.artboardId, layerIds: [payload.group.id] } },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const documentMoveLayerCommand: Command<DocumentMoveLayerPayload> = {
|
||||
id: commandIds.documentMoveLayer,
|
||||
name: "Move layer",
|
||||
execute({ state }, payload) {
|
||||
if (payload.toParentGroupId && !findGroup(state.document, payload.toParentGroupId)) return state;
|
||||
|
||||
const removed = removeLayerFromDocument(state.document, payload.layerId);
|
||||
if (!removed.layer) return state;
|
||||
if (payload.toParentGroupId && !findGroup(removed.document, payload.toParentGroupId)) return state;
|
||||
|
||||
return {
|
||||
...state,
|
||||
document: insertLayer(removed.document, payload.toArtboardId, payload.toParentGroupId, removed.layer, payload.toIndex),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const documentGroupLayersCommand: Command<DocumentGroupLayersPayload> = {
|
||||
id: commandIds.documentGroupLayers,
|
||||
name: "Group layers",
|
||||
execute({ state }, payload) {
|
||||
const uniqueIds = [...new Set(payload.layerIds)];
|
||||
if (uniqueIds.length === 0) return state;
|
||||
|
||||
const artboard = state.document.artboards.find((candidate) => candidate.id === payload.artboardId);
|
||||
if (!artboard) return state;
|
||||
|
||||
const selected = artboard.layers.filter((layer) => uniqueIds.includes(layer.id));
|
||||
if (selected.length === 0) return state;
|
||||
|
||||
const firstIndex = artboard.layers.findIndex((layer) => layer.id === selected[0]?.id);
|
||||
const group: LayerGroup = { ...payload.group, children: selected };
|
||||
const document = {
|
||||
...state.document,
|
||||
artboards: state.document.artboards.map((candidate) =>
|
||||
candidate.id === payload.artboardId
|
||||
? { ...candidate, layers: [...candidate.layers.filter((layer) => !uniqueIds.includes(layer.id)).slice(0, firstIndex), group, ...candidate.layers.filter((layer) => !uniqueIds.includes(layer.id)).slice(firstIndex)] }
|
||||
: candidate,
|
||||
),
|
||||
};
|
||||
|
||||
return {
|
||||
...state,
|
||||
document,
|
||||
editor: { ...state.editor, selection: { artboardId: payload.artboardId, layerIds: [group.id] } },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const documentUngroupLayerCommand: Command<DocumentUngroupLayerPayload> = {
|
||||
id: commandIds.documentUngroupLayer,
|
||||
name: "Ungroup layer",
|
||||
execute({ state }, payload) {
|
||||
const result = ungroupLayerInDocument(state.document, payload.groupId);
|
||||
if (!result.changed) return state;
|
||||
|
||||
return {
|
||||
...state,
|
||||
document: result.document,
|
||||
editor: { ...state.editor, selection: { artboardId: result.artboardId, layerIds: result.children.map((layer) => layer.id) } },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const documentSetLayerVisibleCommand: Command<DocumentSetLayerVisiblePayload> = {
|
||||
id: commandIds.documentSetLayerVisible,
|
||||
name: "Set layer visible",
|
||||
execute({ state }, payload) {
|
||||
return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => ({ ...layer, visible: payload.visible })) };
|
||||
},
|
||||
};
|
||||
|
||||
export const documentSetLayerLockedCommand: Command<DocumentSetLayerLockedPayload> = {
|
||||
id: commandIds.documentSetLayerLocked,
|
||||
name: "Set layer locked",
|
||||
execute({ state }, payload) {
|
||||
return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => ({ ...layer, locked: payload.locked })) };
|
||||
},
|
||||
};
|
||||
|
||||
export const documentRenameLayerCommand: Command<DocumentRenameLayerPayload> = {
|
||||
id: commandIds.documentRenameLayer,
|
||||
name: "Rename layer",
|
||||
execute({ state }, payload) {
|
||||
const name = payload.name.trim();
|
||||
if (!name) return state;
|
||||
return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => ({ ...layer, name })) };
|
||||
},
|
||||
};
|
||||
|
||||
export const documentSetLayerClippingMaskCommand: Command<DocumentSetLayerClippingMaskPayload> = {
|
||||
id: commandIds.documentSetLayerClippingMask,
|
||||
name: "Set layer clipping mask",
|
||||
execute({ state }, payload) {
|
||||
if (payload.maskLayerId === payload.layerId) return state;
|
||||
|
||||
if (!payload.maskLayerId) {
|
||||
return {
|
||||
...state,
|
||||
document: mapLayerInDocument(state.document, payload.layerId, (layer) => {
|
||||
const { clippingMask: _clippingMask, ...rest } = layer;
|
||||
return rest;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const targetLocation = findLayerLocation(state.document, payload.layerId);
|
||||
const maskLocation = findLayerLocation(state.document, payload.maskLayerId);
|
||||
if (!targetLocation || !maskLocation) return state;
|
||||
if (targetLocation.artboardId !== maskLocation.artboardId || targetLocation.parentGroupId !== maskLocation.parentGroupId) return state;
|
||||
|
||||
const removed = removeLayerFromDocument(state.document, payload.layerId);
|
||||
if (!removed.layer) return state;
|
||||
|
||||
const maskLocationAfterRemoval = findLayerLocation(removed.document, payload.maskLayerId);
|
||||
if (!maskLocationAfterRemoval) return state;
|
||||
|
||||
return {
|
||||
...state,
|
||||
document: insertLayer(
|
||||
removed.document,
|
||||
maskLocationAfterRemoval.artboardId,
|
||||
maskLocationAfterRemoval.parentGroupId,
|
||||
{ ...removed.layer, clippingMask: { maskLayerId: payload.maskLayerId } },
|
||||
maskLocationAfterRemoval.index + 1,
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const documentRemoveLayerCommand: Command<DocumentRemoveLayerPayload> = {
|
||||
id: commandIds.documentRemoveLayer,
|
||||
name: "Remove layer",
|
||||
execute({ state }, payload) {
|
||||
const removed = removeLayerFromDocument(state.document, payload.layerId);
|
||||
if (!removed.layer) return state;
|
||||
|
||||
return {
|
||||
...state,
|
||||
document: removed.document,
|
||||
editor: { ...state.editor, selection: { ...state.editor.selection, layerIds: state.editor.selection.layerIds.filter((id) => id !== payload.layerId) } },
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -99,6 +383,171 @@ export const documentAddImageLayerCommand: Command<DocumentAddImageLayerPayload>
|
||||
export const documentCommands = [
|
||||
documentAddArtboardCommand,
|
||||
documentSetArtboardBoundsCommand,
|
||||
documentRemoveArtboardCommand,
|
||||
documentSetArtboardVisibleCommand,
|
||||
documentSetArtboardLockedCommand,
|
||||
documentRenameArtboardCommand,
|
||||
documentAddAssetCommand,
|
||||
documentAddImageLayerCommand,
|
||||
documentAddGroupLayerCommand,
|
||||
documentMoveLayerCommand,
|
||||
documentGroupLayersCommand,
|
||||
documentUngroupLayerCommand,
|
||||
documentRemoveLayerCommand,
|
||||
documentSetLayerVisibleCommand,
|
||||
documentSetLayerLockedCommand,
|
||||
documentRenameLayerCommand,
|
||||
documentSetLayerClippingMaskCommand,
|
||||
] satisfies Command<unknown>[];
|
||||
|
||||
type LayerLocation = {
|
||||
artboardId: ArtboardId;
|
||||
parentGroupId?: LayerId;
|
||||
index: number;
|
||||
layer: Layer;
|
||||
};
|
||||
|
||||
function findLayerLocation(document: ImageDocument, layerId: LayerId): LayerLocation | undefined {
|
||||
for (const artboard of document.artboards) {
|
||||
const location = findLayerLocationInTree(artboard.layers, layerId, artboard.id);
|
||||
if (location) return location;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findLayerLocationInTree(layers: Layer[], layerId: LayerId, artboardId: ArtboardId, parentGroupId?: LayerId): LayerLocation | undefined {
|
||||
for (let index = 0; index < layers.length; index++) {
|
||||
const layer = layers[index];
|
||||
if (!layer) continue;
|
||||
if (layer.id === layerId) return { artboardId, parentGroupId, index, layer };
|
||||
if (layer.type === "group") {
|
||||
const child = findLayerLocationInTree(layer.children, layerId, artboardId, layer.id);
|
||||
if (child) return child;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function mapLayerInDocument(document: ImageDocument, layerId: LayerId, mapLayer: (layer: Layer) => Layer): ImageDocument {
|
||||
return {
|
||||
...document,
|
||||
artboards: document.artboards.map((artboard) => ({ ...artboard, layers: mapLayerInTree(artboard.layers, layerId, mapLayer) })),
|
||||
};
|
||||
}
|
||||
|
||||
function mapLayerInTree(layers: Layer[], layerId: LayerId, mapLayer: (layer: Layer) => Layer): Layer[] {
|
||||
return layers.map((layer) => {
|
||||
if (layer.id === layerId) return mapLayer(layer);
|
||||
if (layer.type === "group") return { ...layer, children: mapLayerInTree(layer.children, layerId, mapLayer) };
|
||||
return layer;
|
||||
});
|
||||
}
|
||||
|
||||
function insertLayer(document: ImageDocument, artboardId: ArtboardId, parentGroupId: LayerId | undefined, layer: Layer, index?: number): ImageDocument {
|
||||
return {
|
||||
...document,
|
||||
artboards: document.artboards.map((artboard) => {
|
||||
if (artboard.id !== artboardId) return artboard;
|
||||
if (!parentGroupId) return { ...artboard, layers: insertAt(artboard.layers, layer, index) };
|
||||
return { ...artboard, layers: insertLayerInGroup(artboard.layers, parentGroupId, layer, index) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function insertLayerInGroup(layers: Layer[], groupId: LayerId, layer: Layer, index?: number): Layer[] {
|
||||
return layers.map((candidate) => {
|
||||
if (candidate.type === "group" && candidate.id === groupId) return { ...candidate, children: insertAt(candidate.children, layer, index) };
|
||||
if (candidate.type === "group") return { ...candidate, children: insertLayerInGroup(candidate.children, groupId, layer, index) };
|
||||
return candidate;
|
||||
});
|
||||
}
|
||||
|
||||
function removeLayerFromDocument(document: ImageDocument, layerId: LayerId): { document: ImageDocument; layer?: Layer } {
|
||||
let removed: Layer | undefined;
|
||||
return {
|
||||
document: {
|
||||
...document,
|
||||
artboards: document.artboards.map((artboard) => {
|
||||
const result = removeLayerFromTree(artboard.layers, layerId);
|
||||
if (result.layer) removed = result.layer;
|
||||
return { ...artboard, layers: result.layers };
|
||||
}),
|
||||
},
|
||||
layer: removed,
|
||||
};
|
||||
}
|
||||
|
||||
function removeLayerFromTree(layers: Layer[], layerId: LayerId): { layers: Layer[]; layer?: Layer } {
|
||||
let removed: Layer | undefined;
|
||||
const next: Layer[] = [];
|
||||
for (const layer of layers) {
|
||||
if (layer.id === layerId) {
|
||||
removed = layer;
|
||||
continue;
|
||||
}
|
||||
if (layer.type === "group") {
|
||||
const result = removeLayerFromTree(layer.children, layerId);
|
||||
if (result.layer) removed = result.layer;
|
||||
next.push({ ...layer, children: result.layers });
|
||||
} else {
|
||||
next.push(layer);
|
||||
}
|
||||
}
|
||||
return { layers: next, layer: removed };
|
||||
}
|
||||
|
||||
function ungroupLayerInDocument(document: ImageDocument, groupId: LayerId): { document: ImageDocument; changed: boolean; artboardId?: ArtboardId; children: Layer[] } {
|
||||
let changed = false;
|
||||
let artboardId: ArtboardId | undefined;
|
||||
let children: Layer[] = [];
|
||||
const next = {
|
||||
...document,
|
||||
artboards: document.artboards.map((artboard) => {
|
||||
const result = ungroupLayerInTree(artboard.layers, groupId);
|
||||
if (result.changed) {
|
||||
changed = true;
|
||||
artboardId = artboard.id;
|
||||
children = result.children;
|
||||
}
|
||||
return { ...artboard, layers: result.layers };
|
||||
}),
|
||||
};
|
||||
return { document: next, changed, artboardId, children };
|
||||
}
|
||||
|
||||
function ungroupLayerInTree(layers: Layer[], groupId: LayerId): { layers: Layer[]; changed: boolean; children: Layer[] } {
|
||||
const next: Layer[] = [];
|
||||
for (const layer of layers) {
|
||||
if (layer.type === "group" && layer.id === groupId) return { layers: [...next, ...layer.children, ...layers.slice(next.length + 1)], changed: true, children: layer.children };
|
||||
if (layer.type === "group") {
|
||||
const result = ungroupLayerInTree(layer.children, groupId);
|
||||
if (result.changed) return { layers: [...next, { ...layer, children: result.layers }, ...layers.slice(next.length + 1)], changed: true, children: result.children };
|
||||
}
|
||||
next.push(layer);
|
||||
}
|
||||
return { layers, changed: false, children: [] };
|
||||
}
|
||||
|
||||
function insertAt(layers: Layer[], layer: Layer, index = layers.length) {
|
||||
const clamped = Math.max(0, Math.min(index, layers.length));
|
||||
return [...layers.slice(0, clamped), layer, ...layers.slice(clamped)];
|
||||
}
|
||||
|
||||
function findGroup(document: ImageDocument, groupId: LayerId): LayerGroup | undefined {
|
||||
for (const artboard of document.artboards) {
|
||||
const group = findGroupInTree(artboard.layers, groupId);
|
||||
if (group) return group;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findGroupInTree(layers: Layer[], groupId: LayerId): LayerGroup | undefined {
|
||||
for (const layer of layers) {
|
||||
if (layer.type === "group" && layer.id === groupId) return layer;
|
||||
if (layer.type === "group") {
|
||||
const child = findGroupInTree(layer.children, groupId);
|
||||
if (child) return child;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
export const commandIds = {
|
||||
documentAddArtboard: "document.addArtboard",
|
||||
documentSetArtboardBounds: "document.setArtboardBounds",
|
||||
documentRemoveArtboard: "document.removeArtboard",
|
||||
documentSetArtboardVisible: "document.setArtboardVisible",
|
||||
documentSetArtboardLocked: "document.setArtboardLocked",
|
||||
documentRenameArtboard: "document.renameArtboard",
|
||||
documentAddAsset: "document.addAsset",
|
||||
documentAddImageLayer: "document.addImageLayer",
|
||||
documentAddGroupLayer: "document.addGroupLayer",
|
||||
documentMoveLayer: "document.moveLayer",
|
||||
documentGroupLayers: "document.groupLayers",
|
||||
documentUngroupLayer: "document.ungroupLayer",
|
||||
documentRemoveLayer: "document.removeLayer",
|
||||
documentSetLayerVisible: "document.setLayerVisible",
|
||||
documentSetLayerLocked: "document.setLayerLocked",
|
||||
documentRenameLayer: "document.renameLayer",
|
||||
documentSetLayerClippingMask: "document.setLayerClippingMask",
|
||||
selectionSet: "selection.set",
|
||||
selectionClear: "selection.clear",
|
||||
selectionAddLayer: "selection.addLayer",
|
||||
@@ -11,6 +24,7 @@ export const commandIds = {
|
||||
toolExitTemporaryPan: "tool.exitTemporaryPan",
|
||||
transformBegin: "transform.begin",
|
||||
transformUpdate: "transform.update",
|
||||
transformSetBounds: "transform.setBounds",
|
||||
transformEnd: "transform.end",
|
||||
viewportPan: "viewport.pan",
|
||||
viewportSetZoom: "viewport.setZoom",
|
||||
|
||||
@@ -2,15 +2,41 @@ export type { Command, CommandContext } from "./command";
|
||||
export {
|
||||
documentAddArtboardCommand,
|
||||
documentAddAssetCommand,
|
||||
documentAddGroupLayerCommand,
|
||||
documentAddImageLayerCommand,
|
||||
documentCommands,
|
||||
documentGroupLayersCommand,
|
||||
documentMoveLayerCommand,
|
||||
documentRemoveArtboardCommand,
|
||||
documentRemoveLayerCommand,
|
||||
documentRenameArtboardCommand,
|
||||
documentRenameLayerCommand,
|
||||
documentSetArtboardBoundsCommand,
|
||||
documentSetArtboardLockedCommand,
|
||||
documentSetArtboardVisibleCommand,
|
||||
documentSetLayerClippingMaskCommand,
|
||||
documentSetLayerLockedCommand,
|
||||
documentSetLayerVisibleCommand,
|
||||
documentUngroupLayerCommand,
|
||||
} from "./document";
|
||||
export type {
|
||||
DocumentAddArtboardPayload,
|
||||
DocumentAddAssetPayload,
|
||||
DocumentAddGroupLayerPayload,
|
||||
DocumentAddImageLayerPayload,
|
||||
DocumentGroupLayersPayload,
|
||||
DocumentMoveLayerPayload,
|
||||
DocumentRemoveArtboardPayload,
|
||||
DocumentRemoveLayerPayload,
|
||||
DocumentRenameArtboardPayload,
|
||||
DocumentRenameLayerPayload,
|
||||
DocumentSetArtboardBoundsPayload,
|
||||
DocumentSetArtboardLockedPayload,
|
||||
DocumentSetArtboardVisiblePayload,
|
||||
DocumentSetLayerClippingMaskPayload,
|
||||
DocumentSetLayerLockedPayload,
|
||||
DocumentSetLayerVisiblePayload,
|
||||
DocumentUngroupLayerPayload,
|
||||
} from "./document";
|
||||
export type { CommandDispatcher, Dispatch } from "./dispatcher";
|
||||
export type { CommandId, CommandPayloads } from "./payloads";
|
||||
@@ -20,8 +46,8 @@ export { createCommandRegistry } from "./registry";
|
||||
export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection";
|
||||
export type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
||||
export { toolCommands, toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand, toolSetActiveCommand } from "./tool";
|
||||
export { transformBeginCommand, transformCommands, transformEndCommand, transformUpdateCommand } from "./transform";
|
||||
export type { TransformBeginPayload, TransformUpdatePayload } from "./transform";
|
||||
export { transformBeginCommand, transformCommands, transformEndCommand, transformSetBoundsCommand, transformUpdateCommand } from "./transform";
|
||||
export type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
|
||||
export type { ToolSetActivePayload } from "./tool";
|
||||
export {
|
||||
viewportCommands,
|
||||
|
||||
@@ -2,12 +2,25 @@ import { commandIds } from "./ids";
|
||||
import type {
|
||||
DocumentAddArtboardPayload,
|
||||
DocumentAddAssetPayload,
|
||||
DocumentAddGroupLayerPayload,
|
||||
DocumentAddImageLayerPayload,
|
||||
DocumentGroupLayersPayload,
|
||||
DocumentMoveLayerPayload,
|
||||
DocumentRemoveArtboardPayload,
|
||||
DocumentRemoveLayerPayload,
|
||||
DocumentRenameArtboardPayload,
|
||||
DocumentRenameLayerPayload,
|
||||
DocumentSetArtboardBoundsPayload,
|
||||
DocumentSetArtboardLockedPayload,
|
||||
DocumentSetArtboardVisiblePayload,
|
||||
DocumentSetLayerClippingMaskPayload,
|
||||
DocumentSetLayerLockedPayload,
|
||||
DocumentSetLayerVisiblePayload,
|
||||
DocumentUngroupLayerPayload,
|
||||
} from "./document";
|
||||
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
||||
import type { ToolSetActivePayload } from "./tool";
|
||||
import type { TransformBeginPayload, TransformUpdatePayload } from "./transform";
|
||||
import type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
|
||||
import type {
|
||||
ViewportFitArtboardPayload,
|
||||
ViewportPanPayload,
|
||||
@@ -19,8 +32,21 @@ import type {
|
||||
export type CommandPayloads = {
|
||||
[commandIds.documentAddArtboard]: DocumentAddArtboardPayload;
|
||||
[commandIds.documentSetArtboardBounds]: DocumentSetArtboardBoundsPayload;
|
||||
[commandIds.documentRemoveArtboard]: DocumentRemoveArtboardPayload;
|
||||
[commandIds.documentSetArtboardVisible]: DocumentSetArtboardVisiblePayload;
|
||||
[commandIds.documentSetArtboardLocked]: DocumentSetArtboardLockedPayload;
|
||||
[commandIds.documentRenameArtboard]: DocumentRenameArtboardPayload;
|
||||
[commandIds.documentAddAsset]: DocumentAddAssetPayload;
|
||||
[commandIds.documentAddImageLayer]: DocumentAddImageLayerPayload;
|
||||
[commandIds.documentAddGroupLayer]: DocumentAddGroupLayerPayload;
|
||||
[commandIds.documentMoveLayer]: DocumentMoveLayerPayload;
|
||||
[commandIds.documentGroupLayers]: DocumentGroupLayersPayload;
|
||||
[commandIds.documentUngroupLayer]: DocumentUngroupLayerPayload;
|
||||
[commandIds.documentRemoveLayer]: DocumentRemoveLayerPayload;
|
||||
[commandIds.documentSetLayerVisible]: DocumentSetLayerVisiblePayload;
|
||||
[commandIds.documentSetLayerLocked]: DocumentSetLayerLockedPayload;
|
||||
[commandIds.documentRenameLayer]: DocumentRenameLayerPayload;
|
||||
[commandIds.documentSetLayerClippingMask]: DocumentSetLayerClippingMaskPayload;
|
||||
[commandIds.selectionSet]: SelectionSetPayload;
|
||||
[commandIds.selectionClear]: void;
|
||||
[commandIds.selectionAddLayer]: SelectionAddLayerPayload;
|
||||
@@ -29,6 +55,7 @@ export type CommandPayloads = {
|
||||
[commandIds.toolExitTemporaryPan]: void;
|
||||
[commandIds.transformBegin]: TransformBeginPayload;
|
||||
[commandIds.transformUpdate]: TransformUpdatePayload;
|
||||
[commandIds.transformSetBounds]: TransformSetBoundsPayload;
|
||||
[commandIds.transformEnd]: void;
|
||||
[commandIds.viewportPan]: ViewportPanPayload;
|
||||
[commandIds.viewportSetZoom]: ViewportSetZoomPayload;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createInitialAppState } from "@editor/initial-state";
|
||||
import { documentAddArtboardCommand } from "./document";
|
||||
import { transformBeginCommand, transformEndCommand, transformUpdateCommand } from "./transform";
|
||||
import { transformBeginCommand, transformEndCommand, transformSetBoundsCommand, transformUpdateCommand } from "./transform";
|
||||
|
||||
function artboardState() {
|
||||
return documentAddArtboardCommand.execute(
|
||||
@@ -61,6 +61,24 @@ describe("transform commands", () => {
|
||||
expect(updated.document.artboards[0]?.bounds).toEqual({ x: -50, y: -40, w: 150, h: 120 });
|
||||
});
|
||||
|
||||
test("sets transform target bounds directly", () => {
|
||||
const updated = transformSetBoundsCommand.execute(
|
||||
{ state: artboardState() },
|
||||
{ target: { type: "artboard", id: "a1" }, bounds: { x: 10, y: 20, w: 300, h: 200 } },
|
||||
);
|
||||
|
||||
expect(updated.document.artboards[0]?.bounds).toEqual({ x: 10, y: 20, w: 300, h: 200 });
|
||||
});
|
||||
|
||||
test("direct bounds edits enforce minimum size", () => {
|
||||
const updated = transformSetBoundsCommand.execute(
|
||||
{ state: artboardState() },
|
||||
{ target: { type: "artboard", id: "a1" }, bounds: { x: 10, y: 20, w: -5, h: 0 } },
|
||||
);
|
||||
|
||||
expect(updated.document.artboards[0]?.bounds).toEqual({ x: 4, y: 19, w: 1, h: 1 });
|
||||
});
|
||||
|
||||
test("ends transform session", () => {
|
||||
const started = transformBeginCommand.execute(
|
||||
{ state: artboardState() },
|
||||
|
||||
@@ -16,6 +16,11 @@ export type TransformUpdatePayload = {
|
||||
shiftKey?: boolean;
|
||||
};
|
||||
|
||||
export type TransformSetBoundsPayload = {
|
||||
target: TransformTarget;
|
||||
bounds: Rect;
|
||||
};
|
||||
|
||||
export const transformBeginCommand: Command<TransformBeginPayload> = {
|
||||
id: commandIds.transformBegin,
|
||||
name: "Begin transform",
|
||||
@@ -59,6 +64,17 @@ export const transformUpdateCommand: Command<TransformUpdatePayload> = {
|
||||
},
|
||||
};
|
||||
|
||||
export const transformSetBoundsCommand: Command<TransformSetBoundsPayload> = {
|
||||
id: commandIds.transformSetBounds,
|
||||
name: "Set transform bounds",
|
||||
execute({ state }, payload) {
|
||||
return {
|
||||
...state,
|
||||
document: applyTransformTargetBounds(state.document, payload.target, normalizeRect(payload.bounds)),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const transformEndCommand: Command = {
|
||||
id: commandIds.transformEnd,
|
||||
name: "End transform",
|
||||
@@ -75,7 +91,7 @@ export const transformEndCommand: Command = {
|
||||
},
|
||||
};
|
||||
|
||||
export const transformCommands = [transformBeginCommand, transformUpdateCommand, transformEndCommand] satisfies Command<unknown>[];
|
||||
export const transformCommands = [transformBeginCommand, transformUpdateCommand, transformSetBoundsCommand, transformEndCommand] satisfies Command<unknown>[];
|
||||
|
||||
function transformBounds(bounds: Rect, handle: TransformHandle, delta: Vec2D, constrained = false): Rect {
|
||||
if (handle === "body") {
|
||||
|
||||
@@ -40,7 +40,7 @@ describe("viewport commands", () => {
|
||||
...viewportSetSizeCommand.execute(context(), { w: 1000, h: 800 }),
|
||||
document: {
|
||||
...context().state.document,
|
||||
artboards: [{ id: "artboard-1", name: "Artboard", bounds: { x: -400, y: -300, w: 800, h: 600 }, backgroundColor: "transparent", layers: [] }],
|
||||
artboards: [{ id: "artboard-1", name: "Artboard", bounds: { x: -400, y: -300, w: 800, h: 600 }, backgroundColor: "transparent", visible: true, locked: false, layers: [] }],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -55,7 +55,7 @@ describe("viewport commands", () => {
|
||||
...context().state,
|
||||
document: {
|
||||
...context().state.document,
|
||||
artboards: [{ id: "artboard-1", name: "Artboard", bounds: { x: 10, y: 20, w: 100, h: 200 }, backgroundColor: "transparent", layers: [] }],
|
||||
artboards: [{ id: "artboard-1", name: "Artboard", bounds: { x: 10, y: 20, w: 100, h: 200 }, backgroundColor: "transparent", visible: true, locked: false, layers: [] }],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user