feat(layers): add layer management panel
This commit is contained in:
@@ -5,6 +5,8 @@ export {
|
||||
} from "./dom";
|
||||
export type { CommandKeybind, GlobalKeybindConsumer, Keybind, KeybindEvent, KeybindMap } from "./keyboard";
|
||||
export { handleKeybind, keybindFromEvent } from "./keyboard";
|
||||
export { findGroup, findLayerInfoInDocument, handleDeleteSelectionKey, resolveLayerDrop } from "./layers-panel";
|
||||
export type { LayerDropTarget, LayerInfo } from "./layers-panel";
|
||||
export { handleArtboardSelection } from "./selection";
|
||||
export { createTransformControlsInputController, hitTestArtboardTransformHandle } from "./transform-controls";
|
||||
export type { TransformControlsInputController } from "./transform-controls";
|
||||
|
||||
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"),
|
||||
document: {
|
||||
...createInitialAppState("Test").document,
|
||||
artboards: [{ id: "a1", name: "Artboard", bounds: { x: -50, y: -50, w: 100, h: 100 }, backgroundColor: "transparent", layers: [] }],
|
||||
artboards: [{ id: "a1", name: "Artboard", bounds: { x: -50, y: -50, w: 100, h: 100 }, backgroundColor: "transparent", visible: true, locked: false, layers: [] }],
|
||||
},
|
||||
editor: {
|
||||
...createInitialAppState("Test").editor,
|
||||
@@ -47,6 +47,8 @@ describe("selection input", () => {
|
||||
name: "Artboard",
|
||||
bounds: { x: -100, y: -100, w: 200, h: 200 },
|
||||
backgroundColor: "transparent",
|
||||
visible: true,
|
||||
locked: false,
|
||||
layers: [
|
||||
{
|
||||
id: "l1",
|
||||
|
||||
@@ -22,6 +22,7 @@ export function handleArtboardSelection(options: {
|
||||
}
|
||||
|
||||
const artboard = [...options.document.artboards].reverse().find((candidate) => {
|
||||
if (!candidate.visible || candidate.locked) return false;
|
||||
const bounds = candidate.bounds;
|
||||
return point.x >= bounds.x && point.x <= bounds.x + bounds.w && point.y >= bounds.y && point.y <= bounds.y + bounds.h;
|
||||
});
|
||||
@@ -37,6 +38,7 @@ export function handleArtboardSelection(options: {
|
||||
|
||||
function findTopmostLayerAtPoint(document: ImageDocument, point: { x: number; y: number }) {
|
||||
for (const artboard of [...document.artboards].reverse()) {
|
||||
if (!artboard.visible || artboard.locked) continue;
|
||||
const layerId = findTopmostLayerInTreeAtPoint(document, [...artboard.layers].reverse(), point);
|
||||
if (layerId) return { artboardId: artboard.id, layerId };
|
||||
}
|
||||
@@ -46,6 +48,7 @@ function findTopmostLayerAtPoint(document: ImageDocument, point: { x: number; y:
|
||||
|
||||
function findTopmostLayerInTreeAtPoint(document: ImageDocument, layers: Layer[], point: { x: number; y: number }): string | undefined {
|
||||
for (const layer of layers) {
|
||||
if (!layer.visible || layer.locked) continue;
|
||||
if (layer.type === "group") {
|
||||
const childId = findTopmostLayerInTreeAtPoint(document, [...layer.children].reverse(), point);
|
||||
if (childId) return childId;
|
||||
|
||||
@@ -16,12 +16,40 @@ describe("transform controls input", () => {
|
||||
expect(hitTestArtboardTransformHandle({ x: 10, y: 10 }, bounds, viewport)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("does not transform while temporary pan is active", () => {
|
||||
const state = {
|
||||
...createInitialAppState("Test"),
|
||||
document: {
|
||||
...createInitialAppState("Test").document,
|
||||
artboards: [{ id: "a1", name: "Artboard", bounds: { x: -50, y: -50, w: 100, h: 100 }, backgroundColor: "transparent", visible: true, locked: false, layers: [] }],
|
||||
},
|
||||
editor: {
|
||||
...createInitialAppState("Test").editor,
|
||||
viewport: { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } },
|
||||
selection: { artboardId: "a1", layerIds: [] },
|
||||
tools: { activeTool: "select" as const, interactionMode: { type: "temporary-pan" as const, previousTool: "select" as const } },
|
||||
},
|
||||
};
|
||||
const dispatched: unknown[] = [];
|
||||
const controller = createTransformControlsInputController({
|
||||
getDocument: () => state.document,
|
||||
getEditor: () => state.editor,
|
||||
dispatch: (commandId, payload) => {
|
||||
dispatched.push({ commandId, payload });
|
||||
return ignoredState;
|
||||
},
|
||||
});
|
||||
|
||||
expect(controller.pointerDown(pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }))).toBe(false);
|
||||
expect(dispatched).toEqual([]);
|
||||
});
|
||||
|
||||
test("dispatches transform lifecycle for selected artboard", () => {
|
||||
let state = {
|
||||
...createInitialAppState("Test"),
|
||||
document: {
|
||||
...createInitialAppState("Test").document,
|
||||
artboards: [{ id: "a1", name: "Artboard", bounds: { x: -50, y: -50, w: 100, h: 100 }, backgroundColor: "transparent", layers: [] }],
|
||||
artboards: [{ id: "a1", name: "Artboard", bounds: { x: -50, y: -50, w: 100, h: 100 }, backgroundColor: "transparent", visible: true, locked: false, layers: [] }],
|
||||
},
|
||||
editor: {
|
||||
...createInitialAppState("Test").editor,
|
||||
|
||||
@@ -3,8 +3,10 @@ import type { Dispatch } from "@commands/dispatcher";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { Rect, Vec2D } from "@core/geometry";
|
||||
import type { EditorState } from "@editor/state";
|
||||
import { isPanInteractionMode } from "@editor/tools";
|
||||
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
|
||||
import type { TransformHandle } from "@editor/transform";
|
||||
import { findLayerInfoInDocument } from "./layers-panel";
|
||||
import type { PointerInputEvent } from "./pointer";
|
||||
|
||||
export type TransformControlsInputController = {
|
||||
@@ -23,11 +25,11 @@ export function createTransformControlsInputController(options: {
|
||||
if (event.pointerType !== "mouse" || (event.buttons & 1) !== 1) return false;
|
||||
|
||||
const editor = options.getEditor();
|
||||
if (editor.tools.activeTool !== "select") return false;
|
||||
if (editor.tools.activeTool !== "select" || isPanInteractionMode(editor.tools.interactionMode)) return false;
|
||||
|
||||
const document = options.getDocument();
|
||||
const target = selectedTransformTarget(document, editor.selection);
|
||||
if (!target) return false;
|
||||
if (!target || isTransformTargetLocked(document, target)) return false;
|
||||
|
||||
const bounds = resolveTransformTargetBounds(document, target);
|
||||
if (!bounds) return false;
|
||||
@@ -45,7 +47,7 @@ export function createTransformControlsInputController(options: {
|
||||
},
|
||||
pointerMove(event) {
|
||||
const editor = options.getEditor();
|
||||
if (!editor.transformSession) return false;
|
||||
if (!editor.transformSession || isPanInteractionMode(editor.tools.interactionMode)) return false;
|
||||
|
||||
options.dispatch(commandIds.transformUpdate, { point: viewportPointToDocumentPoint(event.position, editor.viewport), shiftKey: event.shiftKey });
|
||||
return true;
|
||||
@@ -68,6 +70,15 @@ export function hitTestArtboardTransformHandle(position: Vec2D, bounds: Rect, vi
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isTransformTargetLocked(document: ImageDocument, target: { type: "artboard" | "layer"; id: string }) {
|
||||
if (target.type === "artboard") {
|
||||
const artboard = document.artboards.find((candidate) => candidate.id === target.id);
|
||||
return !artboard || !artboard.visible || artboard.locked;
|
||||
}
|
||||
const layer = findLayerInfoInDocument(document, target.id)?.layer;
|
||||
return !layer || !layer.visible || layer.locked;
|
||||
}
|
||||
|
||||
function transformHandleRects(rect: Rect): { handle: TransformHandle; rect: Rect }[] {
|
||||
const size = 12;
|
||||
const half = size / 2;
|
||||
|
||||
Reference in New Issue
Block a user