feat(layers): add layer management panel
This commit is contained in:
@@ -1,6 +1,24 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { createInitialAppState } from "@editor/initial-state";
|
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", () => {
|
describe("document commands", () => {
|
||||||
test("adds transparent artboard", () => {
|
test("adds transparent artboard", () => {
|
||||||
@@ -15,6 +33,8 @@ describe("document commands", () => {
|
|||||||
name: "Artboard 1",
|
name: "Artboard 1",
|
||||||
bounds: { x: 0, y: 0, w: 320, h: 240 },
|
bounds: { x: 0, y: 0, w: 320, h: 240 },
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
layers: [],
|
layers: [],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -57,6 +77,114 @@ describe("document commands", () => {
|
|||||||
expect(next.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["l1"]);
|
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", () => {
|
test("sets artboard bounds", () => {
|
||||||
const state = documentAddArtboardCommand.execute(
|
const state = documentAddArtboardCommand.execute(
|
||||||
{ state: createInitialAppState("Test") },
|
{ 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 });
|
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 { 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 { 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 type { Command } from "./command";
|
||||||
import { commandIds } from "./ids";
|
import { commandIds } from "./ids";
|
||||||
|
|
||||||
@@ -16,15 +19,82 @@ export type DocumentSetArtboardBoundsPayload = {
|
|||||||
bounds: Rect;
|
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 = {
|
export type DocumentAddAssetPayload = {
|
||||||
asset: Asset;
|
asset: Asset;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DocumentAddImageLayerPayload = {
|
export type DocumentAddImageLayerPayload = {
|
||||||
artboardId: ArtboardId;
|
artboardId: ArtboardId;
|
||||||
|
parentGroupId?: LayerId;
|
||||||
layer: ImageLayer;
|
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> = {
|
export const documentAddArtboardCommand: Command<DocumentAddArtboardPayload> = {
|
||||||
id: commandIds.documentAddArtboard,
|
id: commandIds.documentAddArtboard,
|
||||||
name: "Add artboard",
|
name: "Add artboard",
|
||||||
@@ -40,6 +110,8 @@ export const documentAddArtboardCommand: Command<DocumentAddArtboardPayload> = {
|
|||||||
name: payload.name,
|
name: payload.name,
|
||||||
bounds: payload.bounds,
|
bounds: payload.bounds,
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
layers: [],
|
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> = {
|
export const documentAddAssetCommand: Command<DocumentAddAssetPayload> = {
|
||||||
id: commandIds.documentAddAsset,
|
id: commandIds.documentAddAsset,
|
||||||
name: "Add asset",
|
name: "Add asset",
|
||||||
@@ -86,19 +218,336 @@ export const documentAddImageLayerCommand: Command<DocumentAddImageLayerPayload>
|
|||||||
execute({ state }, payload) {
|
execute({ state }, payload) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
document: {
|
document: insertLayer(state.document, payload.artboardId, payload.parentGroupId, payload.layer),
|
||||||
...state.document,
|
};
|
||||||
artboards: state.document.artboards.map((artboard) =>
|
|
||||||
artboard.id === payload.artboardId ? { ...artboard, layers: [...artboard.layers, payload.layer] } : artboard,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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) } },
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const documentCommands = [
|
export const documentCommands = [
|
||||||
documentAddArtboardCommand,
|
documentAddArtboardCommand,
|
||||||
documentSetArtboardBoundsCommand,
|
documentSetArtboardBoundsCommand,
|
||||||
|
documentRemoveArtboardCommand,
|
||||||
|
documentSetArtboardVisibleCommand,
|
||||||
|
documentSetArtboardLockedCommand,
|
||||||
|
documentRenameArtboardCommand,
|
||||||
documentAddAssetCommand,
|
documentAddAssetCommand,
|
||||||
documentAddImageLayerCommand,
|
documentAddImageLayerCommand,
|
||||||
|
documentAddGroupLayerCommand,
|
||||||
|
documentMoveLayerCommand,
|
||||||
|
documentGroupLayersCommand,
|
||||||
|
documentUngroupLayerCommand,
|
||||||
|
documentRemoveLayerCommand,
|
||||||
|
documentSetLayerVisibleCommand,
|
||||||
|
documentSetLayerLockedCommand,
|
||||||
|
documentRenameLayerCommand,
|
||||||
|
documentSetLayerClippingMaskCommand,
|
||||||
] satisfies Command<unknown>[];
|
] 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 = {
|
export const commandIds = {
|
||||||
documentAddArtboard: "document.addArtboard",
|
documentAddArtboard: "document.addArtboard",
|
||||||
documentSetArtboardBounds: "document.setArtboardBounds",
|
documentSetArtboardBounds: "document.setArtboardBounds",
|
||||||
|
documentRemoveArtboard: "document.removeArtboard",
|
||||||
|
documentSetArtboardVisible: "document.setArtboardVisible",
|
||||||
|
documentSetArtboardLocked: "document.setArtboardLocked",
|
||||||
|
documentRenameArtboard: "document.renameArtboard",
|
||||||
documentAddAsset: "document.addAsset",
|
documentAddAsset: "document.addAsset",
|
||||||
documentAddImageLayer: "document.addImageLayer",
|
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",
|
selectionSet: "selection.set",
|
||||||
selectionClear: "selection.clear",
|
selectionClear: "selection.clear",
|
||||||
selectionAddLayer: "selection.addLayer",
|
selectionAddLayer: "selection.addLayer",
|
||||||
@@ -11,6 +24,7 @@ export const commandIds = {
|
|||||||
toolExitTemporaryPan: "tool.exitTemporaryPan",
|
toolExitTemporaryPan: "tool.exitTemporaryPan",
|
||||||
transformBegin: "transform.begin",
|
transformBegin: "transform.begin",
|
||||||
transformUpdate: "transform.update",
|
transformUpdate: "transform.update",
|
||||||
|
transformSetBounds: "transform.setBounds",
|
||||||
transformEnd: "transform.end",
|
transformEnd: "transform.end",
|
||||||
viewportPan: "viewport.pan",
|
viewportPan: "viewport.pan",
|
||||||
viewportSetZoom: "viewport.setZoom",
|
viewportSetZoom: "viewport.setZoom",
|
||||||
|
|||||||
@@ -2,15 +2,41 @@ export type { Command, CommandContext } from "./command";
|
|||||||
export {
|
export {
|
||||||
documentAddArtboardCommand,
|
documentAddArtboardCommand,
|
||||||
documentAddAssetCommand,
|
documentAddAssetCommand,
|
||||||
|
documentAddGroupLayerCommand,
|
||||||
documentAddImageLayerCommand,
|
documentAddImageLayerCommand,
|
||||||
documentCommands,
|
documentCommands,
|
||||||
|
documentGroupLayersCommand,
|
||||||
|
documentMoveLayerCommand,
|
||||||
|
documentRemoveArtboardCommand,
|
||||||
|
documentRemoveLayerCommand,
|
||||||
|
documentRenameArtboardCommand,
|
||||||
|
documentRenameLayerCommand,
|
||||||
documentSetArtboardBoundsCommand,
|
documentSetArtboardBoundsCommand,
|
||||||
|
documentSetArtboardLockedCommand,
|
||||||
|
documentSetArtboardVisibleCommand,
|
||||||
|
documentSetLayerClippingMaskCommand,
|
||||||
|
documentSetLayerLockedCommand,
|
||||||
|
documentSetLayerVisibleCommand,
|
||||||
|
documentUngroupLayerCommand,
|
||||||
} from "./document";
|
} from "./document";
|
||||||
export type {
|
export type {
|
||||||
DocumentAddArtboardPayload,
|
DocumentAddArtboardPayload,
|
||||||
DocumentAddAssetPayload,
|
DocumentAddAssetPayload,
|
||||||
|
DocumentAddGroupLayerPayload,
|
||||||
DocumentAddImageLayerPayload,
|
DocumentAddImageLayerPayload,
|
||||||
|
DocumentGroupLayersPayload,
|
||||||
|
DocumentMoveLayerPayload,
|
||||||
|
DocumentRemoveArtboardPayload,
|
||||||
|
DocumentRemoveLayerPayload,
|
||||||
|
DocumentRenameArtboardPayload,
|
||||||
|
DocumentRenameLayerPayload,
|
||||||
DocumentSetArtboardBoundsPayload,
|
DocumentSetArtboardBoundsPayload,
|
||||||
|
DocumentSetArtboardLockedPayload,
|
||||||
|
DocumentSetArtboardVisiblePayload,
|
||||||
|
DocumentSetLayerClippingMaskPayload,
|
||||||
|
DocumentSetLayerLockedPayload,
|
||||||
|
DocumentSetLayerVisiblePayload,
|
||||||
|
DocumentUngroupLayerPayload,
|
||||||
} from "./document";
|
} from "./document";
|
||||||
export type { CommandDispatcher, Dispatch } from "./dispatcher";
|
export type { CommandDispatcher, Dispatch } from "./dispatcher";
|
||||||
export type { CommandId, CommandPayloads } from "./payloads";
|
export type { CommandId, CommandPayloads } from "./payloads";
|
||||||
@@ -20,8 +46,8 @@ export { createCommandRegistry } from "./registry";
|
|||||||
export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection";
|
export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection";
|
||||||
export type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
export type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
||||||
export { toolCommands, toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand, toolSetActiveCommand } from "./tool";
|
export { toolCommands, toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand, toolSetActiveCommand } from "./tool";
|
||||||
export { transformBeginCommand, transformCommands, transformEndCommand, transformUpdateCommand } from "./transform";
|
export { transformBeginCommand, transformCommands, transformEndCommand, transformSetBoundsCommand, transformUpdateCommand } from "./transform";
|
||||||
export type { TransformBeginPayload, TransformUpdatePayload } from "./transform";
|
export type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
|
||||||
export type { ToolSetActivePayload } from "./tool";
|
export type { ToolSetActivePayload } from "./tool";
|
||||||
export {
|
export {
|
||||||
viewportCommands,
|
viewportCommands,
|
||||||
|
|||||||
@@ -2,12 +2,25 @@ import { commandIds } from "./ids";
|
|||||||
import type {
|
import type {
|
||||||
DocumentAddArtboardPayload,
|
DocumentAddArtboardPayload,
|
||||||
DocumentAddAssetPayload,
|
DocumentAddAssetPayload,
|
||||||
|
DocumentAddGroupLayerPayload,
|
||||||
DocumentAddImageLayerPayload,
|
DocumentAddImageLayerPayload,
|
||||||
|
DocumentGroupLayersPayload,
|
||||||
|
DocumentMoveLayerPayload,
|
||||||
|
DocumentRemoveArtboardPayload,
|
||||||
|
DocumentRemoveLayerPayload,
|
||||||
|
DocumentRenameArtboardPayload,
|
||||||
|
DocumentRenameLayerPayload,
|
||||||
DocumentSetArtboardBoundsPayload,
|
DocumentSetArtboardBoundsPayload,
|
||||||
|
DocumentSetArtboardLockedPayload,
|
||||||
|
DocumentSetArtboardVisiblePayload,
|
||||||
|
DocumentSetLayerClippingMaskPayload,
|
||||||
|
DocumentSetLayerLockedPayload,
|
||||||
|
DocumentSetLayerVisiblePayload,
|
||||||
|
DocumentUngroupLayerPayload,
|
||||||
} from "./document";
|
} from "./document";
|
||||||
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
||||||
import type { ToolSetActivePayload } from "./tool";
|
import type { ToolSetActivePayload } from "./tool";
|
||||||
import type { TransformBeginPayload, TransformUpdatePayload } from "./transform";
|
import type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
|
||||||
import type {
|
import type {
|
||||||
ViewportFitArtboardPayload,
|
ViewportFitArtboardPayload,
|
||||||
ViewportPanPayload,
|
ViewportPanPayload,
|
||||||
@@ -19,8 +32,21 @@ import type {
|
|||||||
export type CommandPayloads = {
|
export type CommandPayloads = {
|
||||||
[commandIds.documentAddArtboard]: DocumentAddArtboardPayload;
|
[commandIds.documentAddArtboard]: DocumentAddArtboardPayload;
|
||||||
[commandIds.documentSetArtboardBounds]: DocumentSetArtboardBoundsPayload;
|
[commandIds.documentSetArtboardBounds]: DocumentSetArtboardBoundsPayload;
|
||||||
|
[commandIds.documentRemoveArtboard]: DocumentRemoveArtboardPayload;
|
||||||
|
[commandIds.documentSetArtboardVisible]: DocumentSetArtboardVisiblePayload;
|
||||||
|
[commandIds.documentSetArtboardLocked]: DocumentSetArtboardLockedPayload;
|
||||||
|
[commandIds.documentRenameArtboard]: DocumentRenameArtboardPayload;
|
||||||
[commandIds.documentAddAsset]: DocumentAddAssetPayload;
|
[commandIds.documentAddAsset]: DocumentAddAssetPayload;
|
||||||
[commandIds.documentAddImageLayer]: DocumentAddImageLayerPayload;
|
[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.selectionSet]: SelectionSetPayload;
|
||||||
[commandIds.selectionClear]: void;
|
[commandIds.selectionClear]: void;
|
||||||
[commandIds.selectionAddLayer]: SelectionAddLayerPayload;
|
[commandIds.selectionAddLayer]: SelectionAddLayerPayload;
|
||||||
@@ -29,6 +55,7 @@ export type CommandPayloads = {
|
|||||||
[commandIds.toolExitTemporaryPan]: void;
|
[commandIds.toolExitTemporaryPan]: void;
|
||||||
[commandIds.transformBegin]: TransformBeginPayload;
|
[commandIds.transformBegin]: TransformBeginPayload;
|
||||||
[commandIds.transformUpdate]: TransformUpdatePayload;
|
[commandIds.transformUpdate]: TransformUpdatePayload;
|
||||||
|
[commandIds.transformSetBounds]: TransformSetBoundsPayload;
|
||||||
[commandIds.transformEnd]: void;
|
[commandIds.transformEnd]: void;
|
||||||
[commandIds.viewportPan]: ViewportPanPayload;
|
[commandIds.viewportPan]: ViewportPanPayload;
|
||||||
[commandIds.viewportSetZoom]: ViewportSetZoomPayload;
|
[commandIds.viewportSetZoom]: ViewportSetZoomPayload;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { createInitialAppState } from "@editor/initial-state";
|
import { createInitialAppState } from "@editor/initial-state";
|
||||||
import { documentAddArtboardCommand } from "./document";
|
import { documentAddArtboardCommand } from "./document";
|
||||||
import { transformBeginCommand, transformEndCommand, transformUpdateCommand } from "./transform";
|
import { transformBeginCommand, transformEndCommand, transformSetBoundsCommand, transformUpdateCommand } from "./transform";
|
||||||
|
|
||||||
function artboardState() {
|
function artboardState() {
|
||||||
return documentAddArtboardCommand.execute(
|
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 });
|
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", () => {
|
test("ends transform session", () => {
|
||||||
const started = transformBeginCommand.execute(
|
const started = transformBeginCommand.execute(
|
||||||
{ state: artboardState() },
|
{ state: artboardState() },
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ export type TransformUpdatePayload = {
|
|||||||
shiftKey?: boolean;
|
shiftKey?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TransformSetBoundsPayload = {
|
||||||
|
target: TransformTarget;
|
||||||
|
bounds: Rect;
|
||||||
|
};
|
||||||
|
|
||||||
export const transformBeginCommand: Command<TransformBeginPayload> = {
|
export const transformBeginCommand: Command<TransformBeginPayload> = {
|
||||||
id: commandIds.transformBegin,
|
id: commandIds.transformBegin,
|
||||||
name: "Begin transform",
|
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 = {
|
export const transformEndCommand: Command = {
|
||||||
id: commandIds.transformEnd,
|
id: commandIds.transformEnd,
|
||||||
name: "End transform",
|
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 {
|
function transformBounds(bounds: Rect, handle: TransformHandle, delta: Vec2D, constrained = false): Rect {
|
||||||
if (handle === "body") {
|
if (handle === "body") {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ describe("viewport commands", () => {
|
|||||||
...viewportSetSizeCommand.execute(context(), { w: 1000, h: 800 }),
|
...viewportSetSizeCommand.execute(context(), { w: 1000, h: 800 }),
|
||||||
document: {
|
document: {
|
||||||
...context().state.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,
|
...context().state,
|
||||||
document: {
|
document: {
|
||||||
...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: [] }],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,5 +7,7 @@ export type Artboard = {
|
|||||||
name: string;
|
name: string;
|
||||||
bounds: Rect;
|
bounds: Rect;
|
||||||
backgroundColor: string;
|
backgroundColor: string;
|
||||||
|
visible: boolean;
|
||||||
|
locked: boolean;
|
||||||
layers: Layer[];
|
layers: Layer[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ const document: ImageDocument = {
|
|||||||
name: "Artboard",
|
name: "Artboard",
|
||||||
bounds: { x: 0, y: 0, w: 100, h: 80 },
|
bounds: { x: 0, y: 0, w: 100, h: 80 },
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
layers: [
|
layers: [
|
||||||
{
|
{
|
||||||
id: "l1",
|
id: "l1",
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export {
|
|||||||
} from "./dom";
|
} from "./dom";
|
||||||
export type { CommandKeybind, GlobalKeybindConsumer, Keybind, KeybindEvent, KeybindMap } from "./keyboard";
|
export type { CommandKeybind, GlobalKeybindConsumer, Keybind, KeybindEvent, KeybindMap } from "./keyboard";
|
||||||
export { handleKeybind, keybindFromEvent } 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 { handleArtboardSelection } from "./selection";
|
||||||
export { createTransformControlsInputController, hitTestArtboardTransformHandle } from "./transform-controls";
|
export { createTransformControlsInputController, hitTestArtboardTransformHandle } from "./transform-controls";
|
||||||
export type { TransformControlsInputController } from "./transform-controls";
|
export type { TransformControlsInputController } from "./transform-controls";
|
||||||
|
|||||||
89
input/layers-panel.test.ts
Normal file
89
input/layers-panel.test.ts
Normal file
@@ -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<Layer, { type: "group" }> {
|
||||||
|
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: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
95
input/layers-panel.ts
Normal file
95
input/layers-panel.ts
Normal file
@@ -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<Layer, { type: "group" }> | 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;
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ describe("selection input", () => {
|
|||||||
...createInitialAppState("Test"),
|
...createInitialAppState("Test"),
|
||||||
document: {
|
document: {
|
||||||
...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: {
|
editor: {
|
||||||
...createInitialAppState("Test").editor,
|
...createInitialAppState("Test").editor,
|
||||||
@@ -47,6 +47,8 @@ describe("selection input", () => {
|
|||||||
name: "Artboard",
|
name: "Artboard",
|
||||||
bounds: { x: -100, y: -100, w: 200, h: 200 },
|
bounds: { x: -100, y: -100, w: 200, h: 200 },
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
layers: [
|
layers: [
|
||||||
{
|
{
|
||||||
id: "l1",
|
id: "l1",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export function handleArtboardSelection(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const artboard = [...options.document.artboards].reverse().find((candidate) => {
|
const artboard = [...options.document.artboards].reverse().find((candidate) => {
|
||||||
|
if (!candidate.visible || candidate.locked) return false;
|
||||||
const bounds = candidate.bounds;
|
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;
|
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 }) {
|
function findTopmostLayerAtPoint(document: ImageDocument, point: { x: number; y: number }) {
|
||||||
for (const artboard of [...document.artboards].reverse()) {
|
for (const artboard of [...document.artboards].reverse()) {
|
||||||
|
if (!artboard.visible || artboard.locked) continue;
|
||||||
const layerId = findTopmostLayerInTreeAtPoint(document, [...artboard.layers].reverse(), point);
|
const layerId = findTopmostLayerInTreeAtPoint(document, [...artboard.layers].reverse(), point);
|
||||||
if (layerId) return { artboardId: artboard.id, layerId };
|
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 {
|
function findTopmostLayerInTreeAtPoint(document: ImageDocument, layers: Layer[], point: { x: number; y: number }): string | undefined {
|
||||||
for (const layer of layers) {
|
for (const layer of layers) {
|
||||||
|
if (!layer.visible || layer.locked) continue;
|
||||||
if (layer.type === "group") {
|
if (layer.type === "group") {
|
||||||
const childId = findTopmostLayerInTreeAtPoint(document, [...layer.children].reverse(), point);
|
const childId = findTopmostLayerInTreeAtPoint(document, [...layer.children].reverse(), point);
|
||||||
if (childId) return childId;
|
if (childId) return childId;
|
||||||
|
|||||||
@@ -16,12 +16,40 @@ describe("transform controls input", () => {
|
|||||||
expect(hitTestArtboardTransformHandle({ x: 10, y: 10 }, bounds, viewport)).toBeUndefined();
|
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", () => {
|
test("dispatches transform lifecycle for selected artboard", () => {
|
||||||
let state = {
|
let state = {
|
||||||
...createInitialAppState("Test"),
|
...createInitialAppState("Test"),
|
||||||
document: {
|
document: {
|
||||||
...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: {
|
editor: {
|
||||||
...createInitialAppState("Test").editor,
|
...createInitialAppState("Test").editor,
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import type { Dispatch } from "@commands/dispatcher";
|
|||||||
import type { ImageDocument } from "@core/document";
|
import type { ImageDocument } from "@core/document";
|
||||||
import type { Rect, Vec2D } from "@core/geometry";
|
import type { Rect, Vec2D } from "@core/geometry";
|
||||||
import type { EditorState } from "@editor/state";
|
import type { EditorState } from "@editor/state";
|
||||||
|
import { isPanInteractionMode } from "@editor/tools";
|
||||||
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
|
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
|
||||||
import type { TransformHandle } from "@editor/transform";
|
import type { TransformHandle } from "@editor/transform";
|
||||||
|
import { findLayerInfoInDocument } from "./layers-panel";
|
||||||
import type { PointerInputEvent } from "./pointer";
|
import type { PointerInputEvent } from "./pointer";
|
||||||
|
|
||||||
export type TransformControlsInputController = {
|
export type TransformControlsInputController = {
|
||||||
@@ -23,11 +25,11 @@ export function createTransformControlsInputController(options: {
|
|||||||
if (event.pointerType !== "mouse" || (event.buttons & 1) !== 1) return false;
|
if (event.pointerType !== "mouse" || (event.buttons & 1) !== 1) return false;
|
||||||
|
|
||||||
const editor = options.getEditor();
|
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 document = options.getDocument();
|
||||||
const target = selectedTransformTarget(document, editor.selection);
|
const target = selectedTransformTarget(document, editor.selection);
|
||||||
if (!target) return false;
|
if (!target || isTransformTargetLocked(document, target)) return false;
|
||||||
|
|
||||||
const bounds = resolveTransformTargetBounds(document, target);
|
const bounds = resolveTransformTargetBounds(document, target);
|
||||||
if (!bounds) return false;
|
if (!bounds) return false;
|
||||||
@@ -45,7 +47,7 @@ export function createTransformControlsInputController(options: {
|
|||||||
},
|
},
|
||||||
pointerMove(event) {
|
pointerMove(event) {
|
||||||
const editor = options.getEditor();
|
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 });
|
options.dispatch(commandIds.transformUpdate, { point: viewportPointToDocumentPoint(event.position, editor.viewport), shiftKey: event.shiftKey });
|
||||||
return true;
|
return true;
|
||||||
@@ -68,6 +70,15 @@ export function hitTestArtboardTransformHandle(position: Vec2D, bounds: Rect, vi
|
|||||||
return undefined;
|
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 }[] {
|
function transformHandleRects(rect: Rect): { handle: TransformHandle; rect: Rect }[] {
|
||||||
const size = 12;
|
const size = 12;
|
||||||
const half = size / 2;
|
const half = size / 2;
|
||||||
|
|||||||
@@ -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) {
|
export function renderLayers(context: WebGlRendererContext, document: ImageDocument, viewport: ViewportState, imageTextureRenderer: ImageTextureRenderer) {
|
||||||
for (const artboard of document.artboards) {
|
for (const artboard of document.artboards) {
|
||||||
|
if (!artboard.visible) continue;
|
||||||
const clipRect = documentRectToScreenRect(context.canvas, artboard.bounds, viewport);
|
const clipRect = documentRectToScreenRect(context.canvas, artboard.bounds, viewport);
|
||||||
for (const layer of artboard.layers) renderLayer(context, document, viewport, layer, imageTextureRenderer, clipRect);
|
for (const layer of artboard.layers) renderLayer(context, document, viewport, layer, imageTextureRenderer, clipRect);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
|
|||||||
context.enable(context.SCISSOR_TEST);
|
context.enable(context.SCISSOR_TEST);
|
||||||
|
|
||||||
for (const artboard of frame.document.artboards) {
|
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);
|
imageTextureRenderer.syncAssets(frame.document.assets);
|
||||||
renderLayers(rendererContext, frame.document, frame.editor.viewport, imageTextureRenderer);
|
renderLayers(rendererContext, frame.document, frame.editor.viewport, imageTextureRenderer);
|
||||||
|
|||||||
48
view/App.tsx
48
view/App.tsx
@@ -1,12 +1,12 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { ImageStudioApp } from "@app/app";
|
import type { ImageStudioApp } from "@app/app";
|
||||||
import { BottomControlsIsland } from "./BottomControlsIsland";
|
import { BottomControlsIsland } from "./BottomControlsIsland";
|
||||||
import { CanvasViewport } from "./CanvasViewport";
|
import { CanvasViewport } from "./CanvasViewport";
|
||||||
import { LayersSheet } from "./LayersSheet";
|
import { LayersSheet } from "./LayersSheet";
|
||||||
import { ToolOverlay } from "./ToolOverlay";
|
import { ToolOverlay } from "./ToolOverlay";
|
||||||
import { labelForTool } from "./toolLabels";
|
import { labelForTool } from "./toolLabels";
|
||||||
import { resolveTransformTargetBounds } from "@editor/transform-targets";
|
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
|
||||||
import { getSelectionSummary } from "./selectionSummary";
|
import { handleDeleteSelectionKey, keybindEventFromKeyboardEvent } from "@input/index";
|
||||||
import { useAppState } from "./useAppState";
|
import { useAppState } from "./useAppState";
|
||||||
import { useImageImport } from "./useImageImport";
|
import { useImageImport } from "./useImageImport";
|
||||||
import { useViewportActivityIsland } from "./useViewportActivityIsland";
|
import { useViewportActivityIsland } from "./useViewportActivityIsland";
|
||||||
@@ -22,10 +22,35 @@ export function App({ app }: AppProps) {
|
|||||||
const viewportActivityIsland = useViewportActivityIsland(state.editor.viewport);
|
const viewportActivityIsland = useViewportActivityIsland(state.editor.viewport);
|
||||||
const imageImport = useImageImport(app.store);
|
const imageImport = useImageImport(app.store);
|
||||||
const [layersOpen, setLayersOpen] = useState(false);
|
const [layersOpen, setLayersOpen] = useState(false);
|
||||||
const selectionSummary = getSelectionSummary(state.document, state.editor.selection);
|
const transformTarget = state.editor.transformSession?.target ?? selectedTransformTarget(state.document, state.editor.selection);
|
||||||
const transformBounds = state.editor.transformSession
|
|
||||||
? resolveTransformTargetBounds(state.document, state.editor.transformSession.target)
|
useEffect(() => {
|
||||||
: undefined;
|
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 (
|
return (
|
||||||
<main className="relative h-full overflow-hidden bg-background text-foreground">
|
<main className="relative h-full overflow-hidden bg-background text-foreground">
|
||||||
@@ -44,16 +69,15 @@ export function App({ app }: AppProps) {
|
|||||||
dispatch={app.store.dispatch}
|
dispatch={app.store.dispatch}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<LayersSheet document={state.document} selection={state.editor.selection} open={layersOpen} onClose={() => setLayersOpen(false)} />
|
<LayersSheet document={state.document} selection={state.editor.selection} open={layersOpen} dispatch={app.store.dispatch} onClose={() => setLayersOpen(false)} />
|
||||||
<div className="absolute inset-x-0 bottom-4 z-10 flex justify-center">
|
<div className="absolute inset-x-0 bottom-4 z-10 flex justify-center">
|
||||||
<BottomControlsIsland
|
<BottomControlsIsland
|
||||||
viewport={state.editor.viewport}
|
viewport={state.editor.viewport}
|
||||||
visible={Boolean(transformBounds) || selectionSummary.type !== "none" || viewportActivityIsland.visible}
|
visible={Boolean(transformBounds) || viewportActivityIsland.visible}
|
||||||
action={viewportActivityIsland.action}
|
action={viewportActivityIsland.action}
|
||||||
selection={selectionSummary}
|
transformBounds={viewportActivityIsland.visible ? undefined : transformBounds}
|
||||||
transformBounds={transformBounds}
|
transformTarget={viewportActivityIsland.visible ? undefined : transformTarget}
|
||||||
dispatch={app.store.dispatch}
|
dispatch={app.store.dispatch}
|
||||||
onOpenLayers={() => setLayersOpen(true)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<CanvasViewport store={app.store} />
|
<CanvasViewport store={app.store} />
|
||||||
|
|||||||
@@ -1,25 +1,22 @@
|
|||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
import type { ViewportState } from "@editor/state";
|
import type { ViewportState } from "@editor/state";
|
||||||
import { PanControls } from "./bottom-controls/PanControls";
|
import { PanControls } from "./bottom-controls/PanControls";
|
||||||
import { SelectionControls } from "./bottom-controls/SelectionControls";
|
|
||||||
import { TransformControls } from "./bottom-controls/TransformControls";
|
import { TransformControls } from "./bottom-controls/TransformControls";
|
||||||
import { ZoomControls } from "./bottom-controls/ZoomControls";
|
import { ZoomControls } from "./bottom-controls/ZoomControls";
|
||||||
import type { Rect } from "@core/geometry";
|
import type { Rect } from "@core/geometry";
|
||||||
import type { SelectionSummary } from "./selectionSummary";
|
import type { TransformTarget } from "@editor/transform";
|
||||||
|
|
||||||
export type BottomControlsAction = "pan" | "zoom";
|
export type BottomControlsAction = "pan" | "zoom";
|
||||||
|
|
||||||
export type BottomControlsIslandProps = {
|
export type BottomControlsIslandProps = {
|
||||||
viewport: ViewportState;
|
viewport: ViewportState;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
action: BottomControlsAction;
|
action: BottomControlsAction;
|
||||||
selection: SelectionSummary;
|
|
||||||
transformBounds?: Rect;
|
transformBounds?: Rect;
|
||||||
|
transformTarget?: TransformTarget;
|
||||||
dispatch: AppStore["dispatch"];
|
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 zoomPercent = Math.round(viewport.zoom * 100);
|
||||||
const x = Math.round(viewport.center.x);
|
const x = Math.round(viewport.center.x);
|
||||||
const y = Math.round(viewport.center.y);
|
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"
|
visible ? "pointer-events-auto translate-y-0 opacity-100" : "pointer-events-none translate-y-3 opacity-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{transformBounds ? (
|
{transformBounds && transformTarget ? (
|
||||||
<TransformControls bounds={transformBounds} />
|
<TransformControls bounds={transformBounds} target={transformTarget} dispatch={dispatch} />
|
||||||
) : selection.type !== "none" ? (
|
|
||||||
<SelectionControls selection={selection} onOpenLayers={onOpenLayers} />
|
|
||||||
) : action === "pan" ? (
|
) : action === "pan" ? (
|
||||||
<PanControls x={x} y={y} />
|
<PanControls x={x} y={y} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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 { ImageDocument } from "@core/document";
|
||||||
import type { Layer } from "@core/layer";
|
import type { Layer } from "@core/layer";
|
||||||
|
import type { ArtboardId } from "@core/id";
|
||||||
import type { SelectionState } from "@editor/state";
|
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 = {
|
export type LayersSheetProps = {
|
||||||
document: ImageDocument;
|
document: ImageDocument;
|
||||||
selection: SelectionState;
|
selection: SelectionState;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
dispatch: AppStore["dispatch"];
|
||||||
onClose: () => void;
|
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<string>();
|
||||||
|
const [editingTitle, setEditingTitle] = useState<EditingTitle>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
aria-hidden={!open}
|
aria-hidden={!open}
|
||||||
className={`pointer-events-auto absolute right-3 top-12 z-20 w-72 rounded-xl border border-white/10 bg-black/75 text-xs text-white shadow-xl backdrop-blur transition-all duration-200 ${
|
className={`pointer-events-auto absolute right-4 top-12 z-20 w-96 overflow-hidden rounded-2xl border border-white/10 bg-zinc-950/85 text-sm text-white shadow-2xl shadow-black/40 backdrop-blur-xl transition-all duration-200 ${
|
||||||
open ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-4 opacity-0"
|
open ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-4 opacity-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<header className="flex h-10 items-center justify-between border-b border-white/10 px-3">
|
<header className="flex h-14 items-center justify-between border-b border-white/10 bg-white/[0.03] px-4">
|
||||||
<span className="font-medium">Layers</span>
|
<div>
|
||||||
|
<div className="font-medium tracking-wide">Layers</div>
|
||||||
|
<div className="text-xs text-white/40">Press L to open</div>
|
||||||
|
</div>
|
||||||
<button type="button" className={iconButtonClass()} aria-label="Close layers" onClick={onClose}>
|
<button type="button" className={iconButtonClass()} aria-label="Close layers" onClick={onClose}>
|
||||||
<X size={16} weight="regular" />
|
<X size={18} weight="regular" />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<div className="max-h-96 overflow-auto p-2">
|
<div className="flex flex-wrap gap-2 border-b border-white/10 bg-black/20 p-3">
|
||||||
{document.artboards.map((artboard) => (
|
<button type="button" className={toolbarButtonClass()} onClick={() => addArtboard(document, dispatch)}>
|
||||||
<section key={artboard.id} className="mb-2 last:mb-0">
|
<Plus size={16} /> Artboard
|
||||||
<div className={`rounded-md px-2 py-1 text-white/60 ${selection.artboardId === artboard.id ? "bg-white/10 text-white" : ""}`}>
|
</button>
|
||||||
{artboard.name}
|
<button type="button" className={toolbarButtonClass()} disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addLayer(document, selectedArtboardId, selectedLayer, dispatch)}>
|
||||||
|
<Plus size={16} /> Layer
|
||||||
|
</button>
|
||||||
|
<button type="button" className={toolbarButtonClass()} disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addGroup(selectedArtboardId, dispatch)}>
|
||||||
|
<FolderPlus size={16} /> Group
|
||||||
|
</button>
|
||||||
|
<button type="button" className={toolbarButtonClass()} disabled={!canGroup} onClick={() => selection.artboardId && groupSelection(selection.artboardId, selection.layerIds, dispatch)}>
|
||||||
|
<Stack size={16} /> Group selected
|
||||||
|
</button>
|
||||||
|
<button type="button" className={toolbarButtonClass()} disabled={!canUngroup} onClick={() => selectedLayer && dispatch(commandIds.documentUngroupLayer, { groupId: selectedLayer.layer.id })}>
|
||||||
|
Ungroup
|
||||||
|
</button>
|
||||||
|
<button type="button" className={toolbarButtonClass()} disabled={!selectedLayer} onClick={() => selectedLayer && moveLayer(document, selectedLayer, -1, dispatch)}>
|
||||||
|
Up
|
||||||
|
</button>
|
||||||
|
<button type="button" className={toolbarButtonClass()} disabled={!selectedLayer} onClick={() => selectedLayer && moveLayer(document, selectedLayer, 1, dispatch)}>
|
||||||
|
Down
|
||||||
|
</button>
|
||||||
|
<button type="button" className={toolbarButtonClass()} disabled={!selectedLayer && !selection.artboardId} onClick={() => deleteSelection(selection, selectedLayer, dispatch)}>
|
||||||
|
<Trash size={16} /> Delete
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 space-y-1 pl-2">
|
<div className="max-h-[32rem] overflow-auto p-3">
|
||||||
{artboard.layers.length === 0 ? (
|
{document.artboards.map((artboard) => (
|
||||||
<div className="px-2 py-1 text-white/40">No layers</div>
|
<section key={artboard.id} className="mb-4 last:mb-0">
|
||||||
|
<div
|
||||||
|
className={`flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left transition ${selection.artboardId === artboard.id && selection.layerIds.length === 0 ? "bg-sky-400/15 text-sky-100 ring-1 ring-sky-300/20" : "text-white/65 hover:bg-white/[0.06] hover:text-white"}`}
|
||||||
|
onDragOver={(event) => {
|
||||||
|
if (draggedLayerId.current) event.preventDefault();
|
||||||
|
}}
|
||||||
|
onDrop={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (draggedLayerId.current) dispatch(commandIds.documentMoveLayer, { layerId: draggedLayerId.current, toArtboardId: artboard.id, toIndex: artboard.layers.length });
|
||||||
|
draggedLayerId.current = undefined;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button type="button" className="text-white/55 transition hover:text-white" aria-label={artboard.visible ? "Hide artboard" : "Show artboard"} onClick={() => dispatch(commandIds.documentSetArtboardVisible, { id: artboard.id, visible: !artboard.visible })}>
|
||||||
|
{artboard.visible ? <Eye size={17} weight="regular" /> : <EyeSlash size={17} weight="regular" />}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="text-white/55 transition hover:text-white" aria-label={artboard.locked ? "Unlock artboard" : "Lock artboard"} onClick={() => dispatch(commandIds.documentSetArtboardLocked, { id: artboard.id, locked: !artboard.locked })}>
|
||||||
|
{artboard.locked ? <Lock size={17} weight="regular" /> : <LockOpen size={17} weight="regular" />}
|
||||||
|
</button>
|
||||||
|
{editingTitle?.type === "artboard" && editingTitle.id === artboard.id ? (
|
||||||
|
<RenameInput
|
||||||
|
value={editingTitle.draft}
|
||||||
|
onChange={(draft) => setEditingTitle({ ...editingTitle, draft })}
|
||||||
|
onCancel={() => setEditingTitle(undefined)}
|
||||||
|
onCommit={() => {
|
||||||
|
dispatch(commandIds.documentRenameArtboard, { id: artboard.id, name: editingTitle.draft });
|
||||||
|
setEditingTitle(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
artboard.layers.map((layer) => <LayerRow key={layer.id} layer={layer} depth={0} selectedLayerIds={selection.layerIds} />)
|
<button
|
||||||
|
type="button"
|
||||||
|
className="min-w-0 flex-1 truncate text-left font-medium"
|
||||||
|
onClick={() => dispatch(commandIds.selectionSet, { artboardId: artboard.id, layerIds: [] })}
|
||||||
|
onDoubleClick={() => setEditingTitle({ type: "artboard", id: artboard.id, draft: artboard.name })}
|
||||||
|
>
|
||||||
|
{artboard.name}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-white/55 transition hover:text-white"
|
||||||
|
aria-label={`Export ${artboard.name} as PNG`}
|
||||||
|
title="Export PNG"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
void downloadArtboardPng(artboard, document.assets);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DownloadSimple size={17} weight="regular" />
|
||||||
|
</button>
|
||||||
|
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white/45">{artboard.layers.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 space-y-1.5 pl-3">
|
||||||
|
{artboard.layers.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-white/10 px-3 py-4 text-center text-white/35">No layers yet</div>
|
||||||
|
) : (
|
||||||
|
artboard.layers.map((layer) => (
|
||||||
|
<LayerRow
|
||||||
|
key={layer.id}
|
||||||
|
document={document}
|
||||||
|
artboardId={artboard.id}
|
||||||
|
layer={layer}
|
||||||
|
depth={0}
|
||||||
|
selectedLayerIds={selection.layerIds}
|
||||||
|
draggedLayerId={draggedLayerId}
|
||||||
|
editingTitle={editingTitle}
|
||||||
|
setEditingTitle={setEditingTitle}
|
||||||
|
selectedMaskLayer={selectedLayer}
|
||||||
|
dispatch={dispatch}
|
||||||
|
/>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -44,20 +149,256 @@ export function LayersSheet({ document, selection, open, onClose }: LayersSheetP
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LayerRow({ layer, depth, selectedLayerIds }: { layer: Layer; depth: number; selectedLayerIds: string[] }) {
|
function LayerRow({
|
||||||
|
document,
|
||||||
|
artboardId,
|
||||||
|
layer,
|
||||||
|
depth,
|
||||||
|
selectedLayerIds,
|
||||||
|
draggedLayerId,
|
||||||
|
editingTitle,
|
||||||
|
setEditingTitle,
|
||||||
|
selectedMaskLayer,
|
||||||
|
dispatch,
|
||||||
|
}: {
|
||||||
|
document: ImageDocument;
|
||||||
|
artboardId: ArtboardId;
|
||||||
|
layer: Layer;
|
||||||
|
depth: number;
|
||||||
|
selectedLayerIds: string[];
|
||||||
|
draggedLayerId: MutableRefObject<string | undefined>;
|
||||||
|
editingTitle: EditingTitle | undefined;
|
||||||
|
setEditingTitle: (editingTitle: EditingTitle | undefined) => void;
|
||||||
|
selectedMaskLayer?: LayerInfo;
|
||||||
|
dispatch: AppStore["dispatch"];
|
||||||
|
}) {
|
||||||
const selected = selectedLayerIds.includes(layer.id);
|
const selected = selectedLayerIds.includes(layer.id);
|
||||||
|
const layerInfo = findLayerInfoInDocument(document, layer.id);
|
||||||
|
const maskLayer = layer.clippingMask ? findLayerInfoInDocument(document, layer.clippingMask.maskLayerId)?.layer : undefined;
|
||||||
|
const maskIndent = maskLayer ? 24 : 0;
|
||||||
|
const canSetMask =
|
||||||
|
Boolean(selectedMaskLayer && layerInfo && selectedMaskLayer.layer.id !== layer.id && selectedMaskLayer.artboardId === layerInfo.artboardId && selectedMaskLayer.parentGroupId === layerInfo.parentGroupId);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={`flex items-center gap-2 rounded-md px-2 py-1 ${selected ? "bg-white text-black" : "text-white/80 hover:bg-white/10 hover:text-white"}`} style={{ paddingLeft: 8 + depth * 12 }}>
|
<div
|
||||||
{layer.visible ? <Eye size={14} weight="regular" /> : <EyeSlash size={14} weight="regular" />}
|
draggable
|
||||||
{layer.locked ? <Lock size={14} weight="regular" /> : <LockOpen size={14} weight="regular" />}
|
className={`group flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left transition ${selected ? "bg-white text-black shadow-sm" : "text-white/75 hover:bg-white/[0.07] hover:text-white"}`}
|
||||||
<span className="min-w-0 flex-1 truncate">{layer.name}</span>
|
style={{ paddingLeft: 12 + depth * 16 + maskIndent }}
|
||||||
|
onDragStart={(event) => {
|
||||||
|
event.dataTransfer.effectAllowed = "move";
|
||||||
|
event.dataTransfer.setData("text/plain", layer.id);
|
||||||
|
draggedLayerId.current = layer.id;
|
||||||
|
}}
|
||||||
|
onDragEnd={() => {
|
||||||
|
draggedLayerId.current = undefined;
|
||||||
|
}}
|
||||||
|
onDragOver={(event) => {
|
||||||
|
if (draggedLayerId.current && draggedLayerId.current !== layer.id) event.preventDefault();
|
||||||
|
}}
|
||||||
|
onDrop={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const sourceLayerId = draggedLayerId.current ?? event.dataTransfer.getData("text/plain");
|
||||||
|
if (sourceLayerId && sourceLayerId !== layer.id) dropLayer(document, sourceLayerId, { artboardId, layer }, event, dispatch);
|
||||||
|
draggedLayerId.current = undefined;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{maskLayer ? <span className={selected ? "text-black/40" : "text-sky-200/55"}>↳</span> : null}
|
||||||
|
<button type="button" className={selected ? "text-black/55" : "text-white/45 transition hover:text-white"} aria-label={layer.visible ? "Hide layer" : "Show layer"} onClick={() => dispatch(commandIds.documentSetLayerVisible, { layerId: layer.id, visible: !layer.visible })}>
|
||||||
|
{layer.visible ? <Eye size={17} weight="regular" /> : <EyeSlash size={17} weight="regular" />}
|
||||||
|
</button>
|
||||||
|
{editingTitle?.type === "layer" && editingTitle.id === layer.id ? (
|
||||||
|
<RenameInput
|
||||||
|
value={editingTitle.draft}
|
||||||
|
onChange={(draft) => setEditingTitle({ ...editingTitle, draft })}
|
||||||
|
onCancel={() => setEditingTitle(undefined)}
|
||||||
|
onCommit={() => {
|
||||||
|
dispatch(commandIds.documentRenameLayer, { layerId: layer.id, name: editingTitle.draft });
|
||||||
|
setEditingTitle(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="min-w-0 flex-1 truncate text-left font-medium"
|
||||||
|
onClick={() => dispatch(commandIds.selectionSet, { artboardId, layerIds: [layer.id] })}
|
||||||
|
onDoubleClick={() => setEditingTitle({ type: "layer", id: layer.id, draft: layer.name })}
|
||||||
|
>
|
||||||
|
{layer.name}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={selected ? "text-black/45" : "text-white/25 transition hover:text-white/70"}
|
||||||
|
aria-label={layer.clippingMask ? "Clear layer mask" : "Use selected layer as mask"}
|
||||||
|
title={layer.clippingMask ? "Clear mask" : "Use selected layer as mask"}
|
||||||
|
disabled={!layer.clippingMask && !canSetMask}
|
||||||
|
onClick={() => dispatch(commandIds.documentSetLayerClippingMask, { layerId: layer.id, maskLayerId: layer.clippingMask ? undefined : selectedMaskLayer?.layer.id })}
|
||||||
|
>
|
||||||
|
<Stack size={17} weight={layer.clippingMask ? "fill" : "regular"} />
|
||||||
|
</button>
|
||||||
|
<button type="button" className={selected ? "text-black/45" : "text-white/25 transition hover:text-white/70"} aria-label={layer.locked ? "Unlock layer" : "Lock layer"} onClick={() => dispatch(commandIds.documentSetLayerLocked, { layerId: layer.id, locked: !layer.locked })}>
|
||||||
|
{layer.locked ? <Lock size={17} weight="regular" /> : <LockOpen size={17} weight="regular" />}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{layer.type === "group" ? layer.children.map((child) => <LayerRow key={child.id} layer={child} depth={depth + 1} selectedLayerIds={selectedLayerIds} />) : null}
|
{maskLayer ? (
|
||||||
|
<div className="mt-1 flex items-center gap-2 text-xs text-sky-100/60" style={{ paddingLeft: 52 + depth * 16 + maskIndent }}>
|
||||||
|
<span className="h-px w-5 bg-sky-200/25" />
|
||||||
|
<span>masked by {maskLayer.name}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{layer.type === "group"
|
||||||
|
? layer.children.map((child) => (
|
||||||
|
<LayerRow
|
||||||
|
key={child.id}
|
||||||
|
document={document}
|
||||||
|
artboardId={artboardId}
|
||||||
|
layer={child}
|
||||||
|
depth={depth + 1}
|
||||||
|
selectedLayerIds={selectedLayerIds}
|
||||||
|
draggedLayerId={draggedLayerId}
|
||||||
|
editingTitle={editingTitle}
|
||||||
|
setEditingTitle={setEditingTitle}
|
||||||
|
selectedMaskLayer={selectedMaskLayer}
|
||||||
|
dispatch={dispatch}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function iconButtonClass() {
|
type EditingTitle =
|
||||||
return "grid size-7 place-items-center rounded-full text-white/80 transition hover:bg-white/10 hover:text-white focus:outline-none focus-visible:outline-none";
|
| { type: "artboard"; id: ArtboardId; draft: string }
|
||||||
|
| { type: "layer"; id: string; draft: string };
|
||||||
|
|
||||||
|
function RenameInput({ value, onChange, onCommit, onCancel }: { value: string; onChange: (value: string) => void; onCommit: () => void; onCancel: () => void }) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className="min-w-0 flex-1 rounded-md bg-white px-2 py-1 font-medium text-black outline-none ring-1 ring-black/10 focus:ring-sky-300/50"
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
onBlur={onCommit}
|
||||||
|
onFocus={(event) => event.currentTarget.select()}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onDoubleClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (event.key === "Enter") event.currentTarget.blur();
|
||||||
|
if (event.key === "Escape") onCancel();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropLayer(
|
||||||
|
document: ImageDocument,
|
||||||
|
sourceLayerId: string,
|
||||||
|
target: { artboardId: ArtboardId; layer: Layer },
|
||||||
|
event: DragEvent<HTMLElement>,
|
||||||
|
dispatch: AppStore["dispatch"],
|
||||||
|
) {
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
const command = resolveLayerDrop({
|
||||||
|
document,
|
||||||
|
sourceLayerId,
|
||||||
|
target,
|
||||||
|
verticalRatio: (event.clientY - rect.top) / Math.max(1, rect.height),
|
||||||
|
});
|
||||||
|
if (command) dispatch(commandIds.documentMoveLayer, command);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteSelection(selection: SelectionState, selectedLayer: LayerInfo | undefined, dispatch: AppStore["dispatch"]) {
|
||||||
|
if (selectedLayer) {
|
||||||
|
dispatch(commandIds.documentRemoveLayer, { layerId: selectedLayer.layer.id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selection.artboardId) dispatch(commandIds.documentRemoveArtboard, { id: selection.artboardId });
|
||||||
|
}
|
||||||
|
|
||||||
|
function addArtboard(document: ImageDocument, dispatch: AppStore["dispatch"]) {
|
||||||
|
const index = document.artboards.length + 1;
|
||||||
|
dispatch(commandIds.documentAddArtboard, {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: `Artboard ${index}`,
|
||||||
|
bounds: { x: (index - 1) * 40, y: (index - 1) * 40, w: 800, h: 600 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLayer(document: ImageDocument, artboardId: ArtboardId, selectedLayer: LayerInfo | undefined, dispatch: AppStore["dispatch"]) {
|
||||||
|
const artboard = document.artboards.find((candidate) => candidate.id === artboardId);
|
||||||
|
if (!artboard) return;
|
||||||
|
|
||||||
|
const assetId = crypto.randomUUID();
|
||||||
|
const layerId = crypto.randomUUID();
|
||||||
|
const width = Math.max(1, Math.round(artboard.bounds.w));
|
||||||
|
const height = Math.max(1, Math.round(artboard.bounds.h));
|
||||||
|
const source = `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"></svg>`)}`;
|
||||||
|
|
||||||
|
dispatch(commandIds.documentAddAsset, {
|
||||||
|
asset: {
|
||||||
|
id: assetId,
|
||||||
|
name: "Empty Layer",
|
||||||
|
mimeType: "image/svg+xml",
|
||||||
|
source,
|
||||||
|
intrinsicSize: { w: width, h: height },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
dispatch(commandIds.documentAddImageLayer, {
|
||||||
|
artboardId,
|
||||||
|
parentGroupId: selectedLayer?.layer.type === "group" ? selectedLayer.layer.id : undefined,
|
||||||
|
layer: {
|
||||||
|
id: layerId,
|
||||||
|
type: "image",
|
||||||
|
name: "Layer",
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
opacity: 1,
|
||||||
|
assetId,
|
||||||
|
transform: { position: { x: artboard.bounds.x, y: artboard.bounds.y }, scale: { x: 1, y: 1 }, rotation: 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
dispatch(commandIds.selectionSet, { artboardId, layerIds: [layerId] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function addGroup(artboardId: ArtboardId, dispatch: AppStore["dispatch"]) {
|
||||||
|
dispatch(commandIds.documentAddGroupLayer, { artboardId, group: createGroup("Group") });
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupSelection(artboardId: ArtboardId, layerIds: string[], dispatch: AppStore["dispatch"]) {
|
||||||
|
dispatch(commandIds.documentGroupLayers, { artboardId, layerIds, group: createGroup("Group") });
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveLayer(document: ImageDocument, info: LayerInfo, direction: -1 | 1, dispatch: AppStore["dispatch"]) {
|
||||||
|
const siblings = info.parentGroupId ? findGroup(document, info.parentGroupId)?.children : document.artboards.find((artboard) => artboard.id === info.artboardId)?.layers;
|
||||||
|
if (!siblings) return;
|
||||||
|
const currentIndex = siblings.findIndex((layer) => layer.id === info.layer.id);
|
||||||
|
if (currentIndex < 0) return;
|
||||||
|
dispatch(commandIds.documentMoveLayer, {
|
||||||
|
layerId: info.layer.id,
|
||||||
|
toArtboardId: info.artboardId,
|
||||||
|
toParentGroupId: info.parentGroupId,
|
||||||
|
toIndex: currentIndex + direction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGroup(name: string): Layer {
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
type: "group",
|
||||||
|
name,
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
opacity: 1,
|
||||||
|
transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 },
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolbarButtonClass() {
|
||||||
|
return "inline-flex h-8 items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.04] px-3 text-white/75 transition hover:border-white/20 hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35";
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconButtonClass() {
|
||||||
|
return "grid size-8 place-items-center rounded-full text-white/70 transition hover:bg-white/10 hover:text-white focus:outline-none focus-visible:outline-none";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import { Stack } from "@phosphor-icons/react";
|
|
||||||
import type { SelectionSummary } from "../selectionSummary";
|
|
||||||
import { BottomControlDivider } from "./Divider";
|
|
||||||
import { bottomControlButtonClass } from "./styles";
|
|
||||||
|
|
||||||
export type SelectionControlsProps = {
|
|
||||||
selection: SelectionSummary;
|
|
||||||
onOpenLayers: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SelectionControls({ selection, onOpenLayers }: SelectionControlsProps) {
|
|
||||||
const label = selectionLabel(selection);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button type="button" className={bottomControlButtonClass()} aria-label="Open layers" title="Open layers" onClick={onOpenLayers}>
|
|
||||||
<Stack size={16} weight="regular" />
|
|
||||||
</button>
|
|
||||||
<BottomControlDivider />
|
|
||||||
<span className="max-w-48 truncate px-1 text-white">{label}</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectionLabel(selection: SelectionSummary) {
|
|
||||||
switch (selection.type) {
|
|
||||||
case "artboard":
|
|
||||||
return `${selection.name} · ${selection.layerCount} layers`;
|
|
||||||
case "layer":
|
|
||||||
return selection.name;
|
|
||||||
case "multi-layer":
|
|
||||||
return `${selection.count} layers selected`;
|
|
||||||
case "none":
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +1,100 @@
|
|||||||
|
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
|
||||||
import { BoundingBox } from "@phosphor-icons/react";
|
import { BoundingBox } from "@phosphor-icons/react";
|
||||||
|
import { commandIds } from "@commands/ids";
|
||||||
import type { Rect } from "@core/geometry";
|
import type { Rect } from "@core/geometry";
|
||||||
|
import type { AppStore } from "@editor/store";
|
||||||
|
import type { TransformTarget } from "@editor/transform";
|
||||||
import { BottomControlDivider } from "./Divider";
|
import { BottomControlDivider } from "./Divider";
|
||||||
import { bottomControlIconSlotClass, bottomControlLabelClass, bottomControlValueClass } from "./styles";
|
import { bottomControlIconSlotClass, bottomControlInputClass, bottomControlLabelClass } from "./styles";
|
||||||
|
|
||||||
export type TransformControlsProps = {
|
export type TransformControlsProps = {
|
||||||
bounds: Rect;
|
bounds: Rect;
|
||||||
|
target: TransformTarget;
|
||||||
|
dispatch: AppStore["dispatch"];
|
||||||
|
};
|
||||||
|
|
||||||
|
type BoundsField = keyof Rect;
|
||||||
|
|
||||||
|
export function TransformControls({ bounds, target, dispatch }: TransformControlsProps) {
|
||||||
|
const [draft, setDraft] = useState(() => draftFromBounds(bounds));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(draftFromBounds(bounds));
|
||||||
|
}, [bounds.x, bounds.y, bounds.w, bounds.h]);
|
||||||
|
|
||||||
|
const commitField = (field: BoundsField) => {
|
||||||
|
const value = Number.parseFloat(draft[field]);
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
setDraft(draftFromBounds(bounds));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(commandIds.transformSetBounds, {
|
||||||
|
target,
|
||||||
|
bounds: {
|
||||||
|
...bounds,
|
||||||
|
[field]: field === "w" || field === "h" ? Math.max(1, value) : value,
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TransformControls({ bounds }: TransformControlsProps) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center justify-center gap-2 tabular-nums">
|
<div className="flex w-full items-center justify-center gap-2 tabular-nums">
|
||||||
<span className={bottomControlIconSlotClass()}>
|
<span className={bottomControlIconSlotClass()}>
|
||||||
<BoundingBox size={16} weight="regular" />
|
<BoundingBox size={16} weight="regular" />
|
||||||
</span>
|
</span>
|
||||||
<BottomControlDivider />
|
<BottomControlDivider />
|
||||||
<span className={bottomControlLabelClass()}>X</span>
|
<BoundsInput label="X" field="x" draft={draft.x} setDraft={setDraft} commitField={commitField} />
|
||||||
<span className={bottomControlValueClass()}>{Math.round(bounds.x)}</span>
|
<BoundsInput label="Y" field="y" draft={draft.y} setDraft={setDraft} commitField={commitField} />
|
||||||
<span className={bottomControlLabelClass()}>Y</span>
|
|
||||||
<span className={bottomControlValueClass()}>{Math.round(bounds.y)}</span>
|
|
||||||
<BottomControlDivider />
|
<BottomControlDivider />
|
||||||
<span className={bottomControlLabelClass()}>W</span>
|
<BoundsInput label="W" field="w" draft={draft.w} setDraft={setDraft} commitField={commitField} />
|
||||||
<span className={bottomControlValueClass()}>{Math.round(bounds.w)}</span>
|
<BoundsInput label="H" field="h" draft={draft.h} setDraft={setDraft} commitField={commitField} />
|
||||||
<span className={bottomControlLabelClass()}>H</span>
|
|
||||||
<span className={bottomControlValueClass()}>{Math.round(bounds.h)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function BoundsInput({
|
||||||
|
label,
|
||||||
|
field,
|
||||||
|
draft,
|
||||||
|
setDraft,
|
||||||
|
commitField,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
field: BoundsField;
|
||||||
|
draft: string;
|
||||||
|
setDraft: Dispatch<SetStateAction<Record<BoundsField, string>>>;
|
||||||
|
commitField: (field: BoundsField) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-1">
|
||||||
|
<span className={bottomControlLabelClass()}>{label}</span>
|
||||||
|
<input
|
||||||
|
className={bottomControlInputClass()}
|
||||||
|
inputMode="decimal"
|
||||||
|
value={draft}
|
||||||
|
aria-label={label}
|
||||||
|
onChange={(event) => setDraft((current) => ({ ...current, [field]: event.target.value }))}
|
||||||
|
onBlur={() => commitField(field)}
|
||||||
|
onFocus={(event) => event.currentTarget.select()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
commitField(field);
|
||||||
|
event.currentTarget.blur();
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") event.currentTarget.blur();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftFromBounds(bounds: Rect): Record<BoundsField, string> {
|
||||||
|
return {
|
||||||
|
x: String(Math.round(bounds.x)),
|
||||||
|
y: String(Math.round(bounds.y)),
|
||||||
|
w: String(Math.round(bounds.w)),
|
||||||
|
h: String(Math.round(bounds.h)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export function bottomControlValueClass() {
|
|||||||
return "min-w-10 text-center text-white";
|
return "min-w-10 text-center text-white";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function bottomControlInputClass() {
|
||||||
|
return "h-7 w-14 px-1 text-center text-white outline-none transition placeholder:text-white/30 focus:text-white";
|
||||||
|
}
|
||||||
|
|
||||||
export function bottomControlIconSlotClass() {
|
export function bottomControlIconSlotClass() {
|
||||||
return "grid size-7 place-items-center rounded-full text-white/80";
|
return "grid size-7 place-items-center rounded-full text-white/80";
|
||||||
}
|
}
|
||||||
|
|||||||
72
view/exportArtboardPng.ts
Normal file
72
view/exportArtboardPng.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import type { Artboard } from "@core/artboard";
|
||||||
|
import type { Asset } from "@core/asset";
|
||||||
|
import type { Layer } from "@core/layer";
|
||||||
|
|
||||||
|
export async function downloadArtboardPng(artboard: Artboard, assets: readonly Asset[]) {
|
||||||
|
const width = Math.max(1, Math.round(artboard.bounds.w));
|
||||||
|
const height = Math.max(1, Math.round(artboard.bounds.h));
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) throw new Error("Canvas 2D is not available");
|
||||||
|
|
||||||
|
if (artboard.backgroundColor !== "transparent") {
|
||||||
|
context.fillStyle = artboard.backgroundColor;
|
||||||
|
context.fillRect(0, 0, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.save();
|
||||||
|
context.translate(-artboard.bounds.x, -artboard.bounds.y);
|
||||||
|
for (const layer of artboard.layers) await drawLayer(context, layer, assets);
|
||||||
|
context.restore();
|
||||||
|
|
||||||
|
const url = canvas.toDataURL("image/png");
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = `${safeFilename(artboard.name)}.png`;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drawLayer(context: CanvasRenderingContext2D, layer: Layer, assets: readonly Asset[]) {
|
||||||
|
if (!layer.visible) return;
|
||||||
|
|
||||||
|
context.save();
|
||||||
|
context.globalAlpha *= layer.opacity;
|
||||||
|
|
||||||
|
if (layer.type === "group") {
|
||||||
|
for (const child of layer.children) await drawLayer(context, child, assets);
|
||||||
|
context.restore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const asset = assets.find((candidate) => candidate.id === layer.assetId);
|
||||||
|
if (!asset) {
|
||||||
|
context.restore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = await loadImage(asset.source);
|
||||||
|
context.drawImage(
|
||||||
|
image,
|
||||||
|
layer.transform.position.x,
|
||||||
|
layer.transform.position.y,
|
||||||
|
asset.intrinsicSize.w * layer.transform.scale.x,
|
||||||
|
asset.intrinsicSize.h * layer.transform.scale.y,
|
||||||
|
);
|
||||||
|
context.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImage(source: string) {
|
||||||
|
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => resolve(image);
|
||||||
|
image.onerror = () => reject(new Error("Failed to load image for export"));
|
||||||
|
image.src = source;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeFilename(name: string) {
|
||||||
|
return name.trim().replace(/[^a-z0-9-_]+/gi, "-").replace(/^-+|-+$/g, "") || "artboard";
|
||||||
|
}
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import type { ImageDocument } from "@core/document";
|
|
||||||
import type { Layer } from "@core/layer";
|
|
||||||
import type { SelectionState } from "@editor/state";
|
|
||||||
|
|
||||||
export type SelectionSummary =
|
|
||||||
| { type: "none" }
|
|
||||||
| { type: "artboard"; name: string; layerCount: number }
|
|
||||||
| { type: "layer"; name: string; layer: Layer }
|
|
||||||
| { type: "multi-layer"; count: number; layers: Layer[] };
|
|
||||||
|
|
||||||
export function getSelectionSummary(document: ImageDocument, selection: SelectionState): SelectionSummary {
|
|
||||||
const selectedLayers = selection.layerIds.flatMap((layerId) => {
|
|
||||||
const layer = findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
|
|
||||||
return layer ? [layer] : [];
|
|
||||||
});
|
|
||||||
|
|
||||||
if (selectedLayers.length === 1 && selectedLayers[0]) {
|
|
||||||
return { type: "layer", name: selectedLayers[0].name, layer: selectedLayers[0] };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedLayers.length > 1) {
|
|
||||||
return { type: "multi-layer", count: selectedLayers.length, layers: selectedLayers };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selection.artboardId) {
|
|
||||||
const artboard = document.artboards.find((candidate) => candidate.id === selection.artboardId);
|
|
||||||
if (artboard) return { type: "artboard", name: artboard.name, layerCount: artboard.layers.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { type: "none" };
|
|
||||||
}
|
|
||||||
|
|
||||||
function findLayer(layers: Layer[], layerId: string): Layer | undefined {
|
|
||||||
for (const layer of layers) {
|
|
||||||
if (layer.id === layerId) return layer;
|
|
||||||
if (layer.type === "group") {
|
|
||||||
const child = findLayer(layer.children, layerId);
|
|
||||||
if (child) return child;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user