feat(layers): add layer management panel

This commit is contained in:
syntaxbullet
2026-07-03 17:26:50 +02:00
parent ba3f253ee5
commit fedeaffa57
27 changed files with 1525 additions and 156 deletions

View File

@@ -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";

View 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
View 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;
}

View File

@@ -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",

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;