diff --git a/commands/document.test.ts b/commands/document.test.ts index 3f546b5..78da0a1 100644 --- a/commands/document.test.ts +++ b/commands/document.test.ts @@ -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[]) { + 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: [], + }; +} diff --git a/commands/document.ts b/commands/document.ts index f4d15aa..db07118 100644 --- a/commands/document.ts +++ b/commands/document.ts @@ -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 = { id: commandIds.documentAddArtboard, name: "Add artboard", @@ -40,6 +110,8 @@ export const documentAddArtboardCommand: Command = { name: payload.name, bounds: payload.bounds, backgroundColor: "transparent", + visible: true, + locked: false, layers: [], }, ], @@ -64,6 +136,66 @@ export const documentSetArtboardBoundsCommand: Command = { + 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 = { + 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 = { + 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 = { + 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 = { id: commandIds.documentAddAsset, name: "Add asset", @@ -86,12 +218,164 @@ export const documentAddImageLayerCommand: Command 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 export const documentCommands = [ documentAddArtboardCommand, documentSetArtboardBoundsCommand, + documentRemoveArtboardCommand, + documentSetArtboardVisibleCommand, + documentSetArtboardLockedCommand, + documentRenameArtboardCommand, documentAddAssetCommand, documentAddImageLayerCommand, + documentAddGroupLayerCommand, + documentMoveLayerCommand, + documentGroupLayersCommand, + documentUngroupLayerCommand, + documentRemoveLayerCommand, + documentSetLayerVisibleCommand, + documentSetLayerLockedCommand, + documentRenameLayerCommand, + documentSetLayerClippingMaskCommand, ] satisfies Command[]; + +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; +} diff --git a/commands/ids.ts b/commands/ids.ts index ecf55b9..15bf322 100644 --- a/commands/ids.ts +++ b/commands/ids.ts @@ -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", diff --git a/commands/index.ts b/commands/index.ts index 92e2bcb..ab965c9 100644 --- a/commands/index.ts +++ b/commands/index.ts @@ -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, diff --git a/commands/payloads.ts b/commands/payloads.ts index ac6a508..4164d41 100644 --- a/commands/payloads.ts +++ b/commands/payloads.ts @@ -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; diff --git a/commands/transform.test.ts b/commands/transform.test.ts index 47cd891..536fa0c 100644 --- a/commands/transform.test.ts +++ b/commands/transform.test.ts @@ -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() }, diff --git a/commands/transform.ts b/commands/transform.ts index 2d80738..2bcb726 100644 --- a/commands/transform.ts +++ b/commands/transform.ts @@ -16,6 +16,11 @@ export type TransformUpdatePayload = { shiftKey?: boolean; }; +export type TransformSetBoundsPayload = { + target: TransformTarget; + bounds: Rect; +}; + export const transformBeginCommand: Command = { id: commandIds.transformBegin, name: "Begin transform", @@ -59,6 +64,17 @@ export const transformUpdateCommand: Command = { }, }; +export const transformSetBoundsCommand: Command = { + 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[]; +export const transformCommands = [transformBeginCommand, transformUpdateCommand, transformSetBoundsCommand, transformEndCommand] satisfies Command[]; function transformBounds(bounds: Rect, handle: TransformHandle, delta: Vec2D, constrained = false): Rect { if (handle === "body") { diff --git a/commands/viewport.test.ts b/commands/viewport.test.ts index 7747a78..4d1320d 100644 --- a/commands/viewport.test.ts +++ b/commands/viewport.test.ts @@ -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: [] }], }, }; diff --git a/core/artboard.ts b/core/artboard.ts index cc8b6fb..d15379c 100644 --- a/core/artboard.ts +++ b/core/artboard.ts @@ -7,5 +7,7 @@ export type Artboard = { name: string; bounds: Rect; backgroundColor: string; + visible: boolean; + locked: boolean; layers: Layer[]; }; diff --git a/editor/transform-targets.test.ts b/editor/transform-targets.test.ts index 2160422..cc96dff 100644 --- a/editor/transform-targets.test.ts +++ b/editor/transform-targets.test.ts @@ -13,6 +13,8 @@ const document: ImageDocument = { name: "Artboard", bounds: { x: 0, y: 0, w: 100, h: 80 }, backgroundColor: "transparent", + visible: true, + locked: false, layers: [ { id: "l1", diff --git a/input/index.ts b/input/index.ts index 17069ba..f75c7f1 100644 --- a/input/index.ts +++ b/input/index.ts @@ -5,6 +5,8 @@ export { } from "./dom"; export type { CommandKeybind, GlobalKeybindConsumer, Keybind, KeybindEvent, KeybindMap } from "./keyboard"; export { handleKeybind, keybindFromEvent } from "./keyboard"; +export { findGroup, findLayerInfoInDocument, handleDeleteSelectionKey, resolveLayerDrop } from "./layers-panel"; +export type { LayerDropTarget, LayerInfo } from "./layers-panel"; export { handleArtboardSelection } from "./selection"; export { createTransformControlsInputController, hitTestArtboardTransformHandle } from "./transform-controls"; export type { TransformControlsInputController } from "./transform-controls"; diff --git a/input/layers-panel.test.ts b/input/layers-panel.test.ts new file mode 100644 index 0000000..5866d5e --- /dev/null +++ b/input/layers-panel.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "bun:test"; +import { commandIds } from "@commands/ids"; +import type { ImageDocument } from "@core/document"; +import type { Layer } from "@core/layer"; +import { handleDeleteSelectionKey, resolveLayerDrop } from "./layers-panel"; + +const document: ImageDocument = { + id: "doc", + name: "Doc", + version: 1, + assets: [], + artboards: [ + { + id: "a1", + name: "Artboard", + bounds: { x: 0, y: 0, w: 100, h: 100 }, + backgroundColor: "transparent", + visible: true, + locked: false, + layers: [group("a"), group("b"), { ...group("g"), children: [group("c")] }], + }, + ], +}; + +describe("layers panel input", () => { + test("resolves dropping a layer before another layer", () => { + expect(resolveLayerDrop({ document, sourceLayerId: "b", target: { artboardId: "a1", layer: group("a") }, verticalRatio: 0.1 })).toEqual({ + layerId: "b", + toArtboardId: "a1", + toParentGroupId: undefined, + toIndex: 0, + }); + }); + + test("resolves dropping a layer into a group", () => { + expect(resolveLayerDrop({ document, sourceLayerId: "a", target: { artboardId: "a1", layer: { ...group("g"), children: [group("c")] } }, verticalRatio: 0.5 })).toEqual({ + layerId: "a", + toArtboardId: "a1", + toParentGroupId: "g", + toIndex: 1, + }); + }); + + test("dispatches delete commands for selected layers", () => { + const dispatched: unknown[] = []; + const consumed = handleDeleteSelectionKey({ + event: { key: "Backspace", code: "Backspace", altKey: false, ctrlKey: false, metaKey: false, shiftKey: false }, + selection: { artboardId: "a1", layerIds: ["a", "b"] }, + dispatch: (commandId, payload) => { + dispatched.push({ commandId, payload }); + return undefined as never; + }, + }); + + expect(consumed).toBe(true); + expect(dispatched).toEqual([ + { commandId: commandIds.documentRemoveLayer, payload: { layerId: "a" } }, + { commandId: commandIds.documentRemoveLayer, payload: { layerId: "b" } }, + ]); + }); + + test("dispatches delete command for selected artboard when no layers are selected", () => { + const dispatched: unknown[] = []; + const consumed = handleDeleteSelectionKey({ + event: { key: "Delete", code: "Delete", altKey: false, ctrlKey: false, metaKey: false, shiftKey: false }, + selection: { artboardId: "a1", layerIds: [] }, + dispatch: (commandId, payload) => { + dispatched.push({ commandId, payload }); + return undefined as never; + }, + }); + + expect(consumed).toBe(true); + expect(dispatched).toEqual([{ commandId: commandIds.documentRemoveArtboard, payload: { id: "a1" } }]); + }); +}); + +function group(id: string): Extract { + return { + id, + type: "group", + name: id, + visible: true, + locked: false, + opacity: 1, + transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 }, + children: [], + }; +} diff --git a/input/layers-panel.ts b/input/layers-panel.ts new file mode 100644 index 0000000..c0172a5 --- /dev/null +++ b/input/layers-panel.ts @@ -0,0 +1,95 @@ +import { commandIds } from "@commands/ids"; +import type { Dispatch } from "@commands/dispatcher"; +import type { ImageDocument } from "@core/document"; +import type { ArtboardId, LayerId } from "@core/id"; +import type { Layer } from "@core/layer"; +import type { SelectionState } from "@editor/state"; +import type { KeybindEvent } from "./keyboard"; + +export type LayerInfo = { + artboardId: ArtboardId; + parentGroupId?: LayerId; + layer: Layer; +}; + +export type LayerDropTarget = { + artboardId: ArtboardId; + layer: Layer; +}; + +export function handleDeleteSelectionKey(options: { event: KeybindEvent; selection: SelectionState; dispatch: Dispatch }): boolean { + if (options.event.altKey || options.event.ctrlKey || options.event.metaKey) return false; + if (options.event.key !== "Backspace" && options.event.key !== "Delete") return false; + + for (const layerId of options.selection.layerIds) options.dispatch(commandIds.documentRemoveLayer, { layerId }); + if (options.selection.layerIds.length === 0 && options.selection.artboardId) { + options.dispatch(commandIds.documentRemoveArtboard, { id: options.selection.artboardId }); + } + return true; +} + +export function resolveLayerDrop(options: { + document: ImageDocument; + sourceLayerId: LayerId; + target: LayerDropTarget; + verticalRatio: number; +}): { layerId: LayerId; toArtboardId: ArtboardId; toParentGroupId?: LayerId; toIndex: number } | undefined { + const targetInfo = findLayerInfoInDocument(options.document, options.target.layer.id); + const sourceInfo = findLayerInfoInDocument(options.document, options.sourceLayerId); + if (!targetInfo || !sourceInfo || options.sourceLayerId === options.target.layer.id) return undefined; + + const verticalRatio = Math.max(0, Math.min(1, options.verticalRatio)); + const dropIntoGroup = options.target.layer.type === "group" && verticalRatio >= 0.33 && verticalRatio <= 0.66; + if (dropIntoGroup) { + return { + layerId: options.sourceLayerId, + toArtboardId: options.target.artboardId, + toParentGroupId: options.target.layer.id, + toIndex: options.target.layer.children.length, + }; + } + + const siblings = targetInfo.parentGroupId + ? findGroup(options.document, targetInfo.parentGroupId)?.children + : options.document.artboards.find((artboard) => artboard.id === targetInfo.artboardId)?.layers; + if (!siblings) return undefined; + + const targetIndex = siblings.findIndex((layer) => layer.id === options.target.layer.id); + const sourceIndex = sourceInfo.parentGroupId === targetInfo.parentGroupId && sourceInfo.artboardId === targetInfo.artboardId + ? siblings.findIndex((layer) => layer.id === options.sourceLayerId) + : -1; + const rawIndex = verticalRatio < 0.5 ? targetIndex : targetIndex + 1; + const adjustedIndex = sourceIndex >= 0 && sourceIndex < rawIndex ? rawIndex - 1 : rawIndex; + + return { + layerId: options.sourceLayerId, + toArtboardId: targetInfo.artboardId, + toParentGroupId: targetInfo.parentGroupId, + toIndex: adjustedIndex, + }; +} + +export function findLayerInfoInDocument(document: ImageDocument, layerId?: LayerId): LayerInfo | undefined { + if (!layerId) return undefined; + for (const artboard of document.artboards) { + const found = findLayerInfo(artboard.layers, layerId, artboard.id); + if (found) return found; + } + return undefined; +} + +export function findGroup(document: ImageDocument, groupId: LayerId): Extract | undefined { + const info = findLayerInfoInDocument(document, groupId); + return info?.layer.type === "group" ? info.layer : undefined; +} + +function findLayerInfo(layers: Layer[], layerId: LayerId, artboardId: ArtboardId, parentGroupId?: LayerId): LayerInfo | undefined { + for (const layer of layers) { + if (layer.id === layerId) return { artboardId, parentGroupId, layer }; + if (layer.type === "group") { + const found = findLayerInfo(layer.children, layerId, artboardId, layer.id); + if (found) return found; + } + } + return undefined; +} diff --git a/input/selection.test.ts b/input/selection.test.ts index 5a5e192..a909480 100644 --- a/input/selection.test.ts +++ b/input/selection.test.ts @@ -12,7 +12,7 @@ describe("selection input", () => { ...createInitialAppState("Test"), document: { ...createInitialAppState("Test").document, - artboards: [{ id: "a1", name: "Artboard", bounds: { x: -50, y: -50, w: 100, h: 100 }, backgroundColor: "transparent", layers: [] }], + artboards: [{ id: "a1", name: "Artboard", bounds: { x: -50, y: -50, w: 100, h: 100 }, backgroundColor: "transparent", visible: true, locked: false, layers: [] }], }, editor: { ...createInitialAppState("Test").editor, @@ -47,6 +47,8 @@ describe("selection input", () => { name: "Artboard", bounds: { x: -100, y: -100, w: 200, h: 200 }, backgroundColor: "transparent", + visible: true, + locked: false, layers: [ { id: "l1", diff --git a/input/selection.ts b/input/selection.ts index 4a62dc4..bd6a97c 100644 --- a/input/selection.ts +++ b/input/selection.ts @@ -22,6 +22,7 @@ export function handleArtboardSelection(options: { } const artboard = [...options.document.artboards].reverse().find((candidate) => { + if (!candidate.visible || candidate.locked) return false; const bounds = candidate.bounds; return point.x >= bounds.x && point.x <= bounds.x + bounds.w && point.y >= bounds.y && point.y <= bounds.y + bounds.h; }); @@ -37,6 +38,7 @@ export function handleArtboardSelection(options: { function findTopmostLayerAtPoint(document: ImageDocument, point: { x: number; y: number }) { for (const artboard of [...document.artboards].reverse()) { + if (!artboard.visible || artboard.locked) continue; const layerId = findTopmostLayerInTreeAtPoint(document, [...artboard.layers].reverse(), point); if (layerId) return { artboardId: artboard.id, layerId }; } @@ -46,6 +48,7 @@ function findTopmostLayerAtPoint(document: ImageDocument, point: { x: number; y: function findTopmostLayerInTreeAtPoint(document: ImageDocument, layers: Layer[], point: { x: number; y: number }): string | undefined { for (const layer of layers) { + if (!layer.visible || layer.locked) continue; if (layer.type === "group") { const childId = findTopmostLayerInTreeAtPoint(document, [...layer.children].reverse(), point); if (childId) return childId; diff --git a/input/transform-controls.test.ts b/input/transform-controls.test.ts index 49fc3d7..281d7fc 100644 --- a/input/transform-controls.test.ts +++ b/input/transform-controls.test.ts @@ -16,12 +16,40 @@ describe("transform controls input", () => { expect(hitTestArtboardTransformHandle({ x: 10, y: 10 }, bounds, viewport)).toBeUndefined(); }); + test("does not transform while temporary pan is active", () => { + const state = { + ...createInitialAppState("Test"), + document: { + ...createInitialAppState("Test").document, + artboards: [{ id: "a1", name: "Artboard", bounds: { x: -50, y: -50, w: 100, h: 100 }, backgroundColor: "transparent", visible: true, locked: false, layers: [] }], + }, + editor: { + ...createInitialAppState("Test").editor, + viewport: { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } }, + selection: { artboardId: "a1", layerIds: [] }, + tools: { activeTool: "select" as const, interactionMode: { type: "temporary-pan" as const, previousTool: "select" as const } }, + }, + }; + const dispatched: unknown[] = []; + const controller = createTransformControlsInputController({ + getDocument: () => state.document, + getEditor: () => state.editor, + dispatch: (commandId, payload) => { + dispatched.push({ commandId, payload }); + return ignoredState; + }, + }); + + expect(controller.pointerDown(pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }))).toBe(false); + expect(dispatched).toEqual([]); + }); + test("dispatches transform lifecycle for selected artboard", () => { let state = { ...createInitialAppState("Test"), document: { ...createInitialAppState("Test").document, - artboards: [{ id: "a1", name: "Artboard", bounds: { x: -50, y: -50, w: 100, h: 100 }, backgroundColor: "transparent", layers: [] }], + artboards: [{ id: "a1", name: "Artboard", bounds: { x: -50, y: -50, w: 100, h: 100 }, backgroundColor: "transparent", visible: true, locked: false, layers: [] }], }, editor: { ...createInitialAppState("Test").editor, diff --git a/input/transform-controls.ts b/input/transform-controls.ts index dc97134..5ad3371 100644 --- a/input/transform-controls.ts +++ b/input/transform-controls.ts @@ -3,8 +3,10 @@ import type { Dispatch } from "@commands/dispatcher"; import type { ImageDocument } from "@core/document"; import type { Rect, Vec2D } from "@core/geometry"; import type { EditorState } from "@editor/state"; +import { isPanInteractionMode } from "@editor/tools"; import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets"; import type { TransformHandle } from "@editor/transform"; +import { findLayerInfoInDocument } from "./layers-panel"; import type { PointerInputEvent } from "./pointer"; export type TransformControlsInputController = { @@ -23,11 +25,11 @@ export function createTransformControlsInputController(options: { if (event.pointerType !== "mouse" || (event.buttons & 1) !== 1) return false; const editor = options.getEditor(); - if (editor.tools.activeTool !== "select") return false; + if (editor.tools.activeTool !== "select" || isPanInteractionMode(editor.tools.interactionMode)) return false; const document = options.getDocument(); const target = selectedTransformTarget(document, editor.selection); - if (!target) return false; + if (!target || isTransformTargetLocked(document, target)) return false; const bounds = resolveTransformTargetBounds(document, target); if (!bounds) return false; @@ -45,7 +47,7 @@ export function createTransformControlsInputController(options: { }, pointerMove(event) { const editor = options.getEditor(); - if (!editor.transformSession) return false; + if (!editor.transformSession || isPanInteractionMode(editor.tools.interactionMode)) return false; options.dispatch(commandIds.transformUpdate, { point: viewportPointToDocumentPoint(event.position, editor.viewport), shiftKey: event.shiftKey }); return true; @@ -68,6 +70,15 @@ export function hitTestArtboardTransformHandle(position: Vec2D, bounds: Rect, vi return undefined; } +function isTransformTargetLocked(document: ImageDocument, target: { type: "artboard" | "layer"; id: string }) { + if (target.type === "artboard") { + const artboard = document.artboards.find((candidate) => candidate.id === target.id); + return !artboard || !artboard.visible || artboard.locked; + } + const layer = findLayerInfoInDocument(document, target.id)?.layer; + return !layer || !layer.visible || layer.locked; +} + function transformHandleRects(rect: Rect): { handle: TransformHandle; rect: Rect }[] { const size = 12; const half = size / 2; diff --git a/renderer/layers.ts b/renderer/layers.ts index dc33cb1..63a835b 100644 --- a/renderer/layers.ts +++ b/renderer/layers.ts @@ -12,6 +12,7 @@ const imageLayerInsetColor: RgbaColor = [0.48, 0.54, 0.64, 1]; export function renderLayers(context: WebGlRendererContext, document: ImageDocument, viewport: ViewportState, imageTextureRenderer: ImageTextureRenderer) { for (const artboard of document.artboards) { + if (!artboard.visible) continue; const clipRect = documentRectToScreenRect(context.canvas, artboard.bounds, viewport); for (const layer of artboard.layers) renderLayer(context, document, viewport, layer, imageTextureRenderer, clipRect); } diff --git a/renderer/renderer.ts b/renderer/renderer.ts index 050fe8f..db08214 100644 --- a/renderer/renderer.ts +++ b/renderer/renderer.ts @@ -56,7 +56,7 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke context.enable(context.SCISSOR_TEST); for (const artboard of frame.document.artboards) { - renderArtboard(rendererContext, artboard, frame.editor.viewport); + if (artboard.visible) renderArtboard(rendererContext, artboard, frame.editor.viewport); } imageTextureRenderer.syncAssets(frame.document.assets); renderLayers(rendererContext, frame.document, frame.editor.viewport, imageTextureRenderer); diff --git a/view/App.tsx b/view/App.tsx index 6ab8144..6500990 100644 --- a/view/App.tsx +++ b/view/App.tsx @@ -1,12 +1,12 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import type { ImageStudioApp } from "@app/app"; import { BottomControlsIsland } from "./BottomControlsIsland"; import { CanvasViewport } from "./CanvasViewport"; import { LayersSheet } from "./LayersSheet"; import { ToolOverlay } from "./ToolOverlay"; import { labelForTool } from "./toolLabels"; -import { resolveTransformTargetBounds } from "@editor/transform-targets"; -import { getSelectionSummary } from "./selectionSummary"; +import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets"; +import { handleDeleteSelectionKey, keybindEventFromKeyboardEvent } from "@input/index"; import { useAppState } from "./useAppState"; import { useImageImport } from "./useImageImport"; import { useViewportActivityIsland } from "./useViewportActivityIsland"; @@ -22,10 +22,35 @@ export function App({ app }: AppProps) { const viewportActivityIsland = useViewportActivityIsland(state.editor.viewport); const imageImport = useImageImport(app.store); const [layersOpen, setLayersOpen] = useState(false); - const selectionSummary = getSelectionSummary(state.document, state.editor.selection); - const transformBounds = state.editor.transformSession - ? resolveTransformTargetBounds(state.document, state.editor.transformSession.target) - : undefined; + const transformTarget = state.editor.transformSession?.target ?? selectedTransformTarget(state.document, state.editor.selection); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + const target = event.target; + const editableTarget = + target instanceof HTMLElement && + (target.isContentEditable || target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement); + + if (event.altKey || event.ctrlKey || event.metaKey || editableTarget) return; + + if (event.key.toLowerCase() === "l") { + setLayersOpen(true); + event.preventDefault(); + return; + } + + const consumed = handleDeleteSelectionKey({ + event: keybindEventFromKeyboardEvent(event), + selection: app.store.getState().editor.selection, + dispatch: app.store.dispatch, + }); + if (consumed) event.preventDefault(); + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [app.store]); + const transformBounds = transformTarget ? resolveTransformTargetBounds(state.document, transformTarget) : undefined; return (
@@ -44,16 +69,15 @@ export function App({ app }: AppProps) { dispatch={app.store.dispatch} /> - setLayersOpen(false)} /> + setLayersOpen(false)} />
setLayersOpen(true)} />
diff --git a/view/BottomControlsIsland.tsx b/view/BottomControlsIsland.tsx index 7a2f56f..75b4bde 100644 --- a/view/BottomControlsIsland.tsx +++ b/view/BottomControlsIsland.tsx @@ -1,25 +1,22 @@ import type { AppStore } from "@editor/store"; import type { ViewportState } from "@editor/state"; import { PanControls } from "./bottom-controls/PanControls"; -import { SelectionControls } from "./bottom-controls/SelectionControls"; import { TransformControls } from "./bottom-controls/TransformControls"; import { ZoomControls } from "./bottom-controls/ZoomControls"; import type { Rect } from "@core/geometry"; -import type { SelectionSummary } from "./selectionSummary"; - +import type { TransformTarget } from "@editor/transform"; export type BottomControlsAction = "pan" | "zoom"; export type BottomControlsIslandProps = { viewport: ViewportState; visible: boolean; action: BottomControlsAction; - selection: SelectionSummary; transformBounds?: Rect; + transformTarget?: TransformTarget; dispatch: AppStore["dispatch"]; - onOpenLayers: () => void; }; -export function BottomControlsIsland({ viewport, visible, action, selection, transformBounds, dispatch, onOpenLayers }: BottomControlsIslandProps) { +export function BottomControlsIsland({ viewport, visible, action, transformBounds, transformTarget, dispatch }: BottomControlsIslandProps) { const zoomPercent = Math.round(viewport.zoom * 100); const x = Math.round(viewport.center.x); const y = Math.round(viewport.center.y); @@ -31,10 +28,8 @@ export function BottomControlsIsland({ viewport, visible, action, selection, tra visible ? "pointer-events-auto translate-y-0 opacity-100" : "pointer-events-none translate-y-3 opacity-0" }`} > - {transformBounds ? ( - - ) : selection.type !== "none" ? ( - + {transformBounds && transformTarget ? ( + ) : action === "pan" ? ( ) : ( diff --git a/view/LayersSheet.tsx b/view/LayersSheet.tsx index ae9215a..40c3bcb 100644 --- a/view/LayersSheet.tsx +++ b/view/LayersSheet.tsx @@ -1,40 +1,145 @@ -import { Eye, EyeSlash, Lock, LockOpen, X } from "@phosphor-icons/react"; +import { useRef, useState, type DragEvent, type MutableRefObject } from "react"; +import { DownloadSimple, Eye, EyeSlash, FolderPlus, Lock, LockOpen, Plus, Stack, Trash, X } from "@phosphor-icons/react"; +import { commandIds } from "@commands/ids"; import type { ImageDocument } from "@core/document"; import type { Layer } from "@core/layer"; +import type { ArtboardId } from "@core/id"; import type { SelectionState } from "@editor/state"; +import type { AppStore } from "@editor/store"; +import { findGroup, findLayerInfoInDocument, resolveLayerDrop, type LayerInfo } from "@input/index"; +import { downloadArtboardPng } from "./exportArtboardPng"; export type LayersSheetProps = { document: ImageDocument; selection: SelectionState; open: boolean; + dispatch: AppStore["dispatch"]; onClose: () => void; }; -export function LayersSheet({ document, selection, open, onClose }: LayersSheetProps) { +export function LayersSheet({ document, selection, open, dispatch, onClose }: LayersSheetProps) { + const selectedArtboardId = selection.artboardId ?? document.artboards[0]?.id; + const selectedLayer = findLayerInfoInDocument(document, selection.layerIds[0]); + const canGroup = Boolean(selection.artboardId && selection.layerIds.length > 0); + const canUngroup = selectedLayer?.layer.type === "group"; + const draggedLayerId = useRef(); + const [editingTitle, setEditingTitle] = useState(); + return (