feat: add contextual layer inspector

This commit is contained in:
syntaxbullet
2026-07-11 12:07:36 +02:00
parent bef2fb3051
commit 4d7358af4b
14 changed files with 405 additions and 42 deletions

View File

@@ -10,6 +10,7 @@ import {
documentAddRasterLayerCommand,
documentApplyLayerMaskOperationCommand,
documentGroupLayersCommand,
documentDuplicateLayerCommand,
documentMoveLayerCommand,
documentRemoveArtboardCommand,
documentRemoveLayerCommand,
@@ -21,6 +22,7 @@ import {
documentSetArtboardVisibleCommand,
documentSetLayerClippingMaskCommand,
documentSetLayerLockedCommand,
documentSetLayerOpacityCommand,
documentSetLayerVisibleCommand,
documentUpdateAssetSourceCommand,
documentUngroupLayerCommand,
@@ -335,6 +337,46 @@ describe("document commands", () => {
expect(locked.document.artboards[0]?.layers[0]?.locked).toBe(true);
});
test("sets normalized opacity and refuses locked layer edits", () => {
const state = documentWithLayers([raster("a", "A")]);
const translucent = documentSetLayerOpacityCommand.execute({ state }, { layerId: "a", opacity: 0.35 });
const clamped = documentSetLayerOpacityCommand.execute({ state: translucent }, { layerId: "a", opacity: 2 });
const lockedState = documentSetLayerLockedCommand.execute({ state: clamped }, { layerId: "a", locked: true });
const ignored = documentSetLayerOpacityCommand.execute({ state: lockedState }, { layerId: "a", opacity: 0 });
expect(translucent.document.artboards[0]?.layers[0]?.opacity).toBe(0.35);
expect(clamped.document.artboards[0]?.layers[0]?.opacity).toBe(1);
expect(ignored).toBe(lockedState);
});
test("duplicates a nested group with remapped child mask references", () => {
const maskedTarget = { ...raster("target", "Target"), layerMask: { kind: "raster" as const, maskLayerId: "mask", enabled: true, inverted: false } };
const parent = { ...group("parent", "Parent"), children: [raster("mask", "Mask"), maskedTarget] };
const state = documentWithLayers([parent]);
const next = documentDuplicateLayerCommand.execute(
{ state },
{ layerId: "parent", idByLayerId: { parent: "parent-copy", mask: "mask-copy", target: "target-copy" } },
);
const duplicate = next.document.artboards[0]?.layers[1];
expect(next.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["parent", "parent-copy"]);
expect(duplicate?.type === "group" ? duplicate.children.map((layer) => layer.id) : []).toEqual(["mask-copy", "target-copy"]);
expect(duplicate?.type === "group" ? duplicate.children[1]?.layerMask?.maskLayerId : undefined).toBe("mask-copy");
expect(next.editor.selection.layerIds).toEqual(["parent-copy"]);
});
test("duplicates an attached top-level mask beside its target", () => {
const target = { ...raster("target", "Target"), layerMask: { kind: "raster" as const, maskLayerId: "mask", enabled: true, inverted: false } };
const state = documentWithLayers([raster("mask", "Mask"), target]);
const next = documentDuplicateLayerCommand.execute(
{ state },
{ layerId: "target", idByLayerId: { mask: "mask-copy", target: "target-copy" } },
);
expect(next.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["mask", "target", "mask-copy", "target-copy"]);
expect(next.document.artboards[0]?.layers[3]?.layerMask?.maskLayerId).toBe("mask-copy");
});
test("sets artboard bounds", () => {
const state = documentAddArtboardCommand.execute(
{ state: createInitialAppState("Test") },

View File

@@ -6,6 +6,7 @@ import type { ArtboardId, AssetId, LayerId } from "@core/id";
import type { ImageLayer } from "@core/image-layer";
import { getLayerMask } from "@core/layer-mask-utils";
import type { RasterLayer } from "@core/raster-layer";
import type { Layer } from "@core/layer";
import type { LayerGroup } from "@core/layer-group";
import type { Command } from "./command";
import { commandIds } from "./ids";
@@ -98,6 +99,16 @@ export type DocumentSetLayerLockedPayload = {
locked: boolean;
};
export type DocumentSetLayerOpacityPayload = {
layerId: LayerId;
opacity: number;
};
export type DocumentDuplicateLayerPayload = {
layerId: LayerId;
idByLayerId: Record<LayerId, LayerId>;
};
export type DocumentRenameLayerPayload = {
layerId: LayerId;
name: string;
@@ -402,6 +413,56 @@ export const documentSetLayerLockedCommand: Command<DocumentSetLayerLockedPayloa
},
};
export const documentSetLayerOpacityCommand: Command<DocumentSetLayerOpacityPayload> = {
id: commandIds.documentSetLayerOpacity,
name: "Set layer opacity",
execute({ state }, payload) {
const location = findLayerLocation(state.document, payload.layerId);
if (!location || location.layer.locked || !Number.isFinite(payload.opacity)) return state;
const opacity = Math.min(1, Math.max(0, payload.opacity));
return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => ({ ...layer, opacity })) };
},
};
export const documentDuplicateLayerCommand: Command<DocumentDuplicateLayerPayload> = {
id: commandIds.documentDuplicateLayer,
name: "Duplicate layer",
execute({ state }, payload) {
const location = findLayerLocation(state.document, payload.layerId);
if (!location || location.layer.locked) return state;
const maskId = getLayerMask(location.layer)?.maskLayerId;
const mask = maskId ? location.siblings.find((layer) => layer.id === maskId) : undefined;
const sourceLayers = mask ? [mask, location.layer] : [location.layer];
const sourceIds = new Set(sourceLayers.flatMap((layer) => [...collectLayerIds(layer)]));
const mappedIds = [...sourceIds].map((id) => payload.idByLayerId[id]);
if (mappedIds.some((id) => !id) || new Set(mappedIds).size !== mappedIds.length || mappedIds.some((id) => findLayerLocation(state.document, id!))) return state;
const duplicates = sourceLayers.map((layer) => duplicateLayerTree(layer, payload.idByLayerId));
const insertionIndex = Math.max(...sourceLayers.map((layer) => location.siblings.findIndex((candidate) => candidate.id === layer.id))) + 1;
const siblings = [...location.siblings.slice(0, insertionIndex), ...duplicates, ...location.siblings.slice(insertionIndex)];
const duplicatedLayerId = payload.idByLayerId[payload.layerId];
if (!duplicatedLayerId) return state;
return {
...state,
document: replaceLayerListInDocument(state.document, location.artboardId, location.parentGroupId, siblings),
editor: { ...state.editor, selection: { artboardId: location.artboardId, layerIds: [duplicatedLayerId] } },
};
},
};
function duplicateLayerTree(layer: Layer, idByLayerId: Record<LayerId, LayerId>): Layer {
const layerMask = getLayerMask(layer);
const duplicatedMaskId = layerMask ? idByLayerId[layerMask.maskLayerId] : undefined;
const duplicated = {
...layer,
id: idByLayerId[layer.id]!,
name: `${layer.name} copy`,
transform: { ...layer.transform, position: { ...layer.transform.position }, scale: { ...layer.transform.scale } },
...(layerMask && duplicatedMaskId ? { layerMask: { ...layerMask, maskLayerId: duplicatedMaskId }, clippingMask: layer.clippingMask ? { maskLayerId: duplicatedMaskId } : undefined } : {}),
};
return layer.type === "group" ? { ...duplicated, type: "group", children: layer.children.map((child) => duplicateLayerTree(child, idByLayerId)) } : duplicated;
}
export const documentRenameLayerCommand: Command<DocumentRenameLayerPayload> = {
id: commandIds.documentRenameLayer,
name: "Rename layer",
@@ -619,6 +680,8 @@ export const documentCommands = [
documentRemoveLayerCommand,
documentSetLayerVisibleCommand,
documentSetLayerLockedCommand,
documentSetLayerOpacityCommand,
documentDuplicateLayerCommand,
documentRenameLayerCommand,
documentSetLayerClippingMaskCommand,
documentAddLayerMaskCommand,

View File

@@ -17,6 +17,8 @@ export const commandIds = {
documentRemoveLayer: "document.removeLayer",
documentSetLayerVisible: "document.setLayerVisible",
documentSetLayerLocked: "document.setLayerLocked",
documentSetLayerOpacity: "document.setLayerOpacity",
documentDuplicateLayer: "document.duplicateLayer",
documentRenameLayer: "document.renameLayer",
documentSetLayerClippingMask: "document.setLayerClippingMask",
documentAddLayerMask: "document.addLayerMask",
@@ -56,6 +58,7 @@ export const commandIds = {
transformBegin: "transform.begin",
transformUpdate: "transform.update",
transformSetBounds: "transform.setBounds",
transformSetRotation: "transform.setRotation",
transformEnd: "transform.end",
viewportPan: "viewport.pan",
viewportSetZoom: "viewport.setZoom",

View File

@@ -11,6 +11,7 @@ export {
documentApplyLayerMaskOperationCommand,
documentCommands,
documentGroupLayersCommand,
documentDuplicateLayerCommand,
documentMoveLayerCommand,
documentRemoveArtboardCommand,
documentRemoveLayerCommand,
@@ -22,6 +23,7 @@ export {
documentSetArtboardVisibleCommand,
documentSetLayerClippingMaskCommand,
documentSetLayerLockedCommand,
documentSetLayerOpacityCommand,
documentSetLayerVisibleCommand,
documentUpdateAssetSourceCommand,
documentUngroupLayerCommand,
@@ -35,6 +37,7 @@ export type {
DocumentAddRasterLayerPayload,
DocumentApplyLayerMaskOperationPayload,
DocumentGroupLayersPayload,
DocumentDuplicateLayerPayload,
DocumentMoveLayerPayload,
DocumentRemoveArtboardPayload,
DocumentRemoveLayerPayload,
@@ -46,6 +49,7 @@ export type {
DocumentSetArtboardVisiblePayload,
DocumentSetLayerClippingMaskPayload,
DocumentSetLayerLockedPayload,
DocumentSetLayerOpacityPayload,
DocumentSetLayerVisiblePayload,
DocumentUpdateAssetSourcePayload,
DocumentUngroupLayerPayload,
@@ -90,8 +94,8 @@ export { createCommandRegistry } from "./registry";
export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection";
export type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
export { toolChooseGenerateIntentCommand, toolCommands, toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetChromaKeySettingsCommand, toolSetGenerateSettingsCommand, toolSetMagicWandSettingsCommand, toolSetMaskViewModeCommand } from "./tool";
export { transformBeginCommand, transformCommands, transformEndCommand, transformSetBoundsCommand, transformUpdateCommand } from "./transform";
export type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
export { transformBeginCommand, transformCommands, transformEndCommand, transformSetBoundsCommand, transformSetRotationCommand, transformUpdateCommand } from "./transform";
export type { TransformBeginPayload, TransformSetBoundsPayload, TransformSetRotationPayload, TransformUpdatePayload } from "./transform";
export type { ToolChooseGenerateIntentPayload, ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
export {
viewportCommands,

View File

@@ -8,6 +8,7 @@ import type {
DocumentAddRasterLayerPayload,
DocumentApplyLayerMaskOperationPayload,
DocumentGroupLayersPayload,
DocumentDuplicateLayerPayload,
DocumentMoveLayerPayload,
DocumentRemoveArtboardPayload,
DocumentRemoveLayerPayload,
@@ -19,6 +20,7 @@ import type {
DocumentSetArtboardVisiblePayload,
DocumentSetLayerClippingMaskPayload,
DocumentSetLayerLockedPayload,
DocumentSetLayerOpacityPayload,
DocumentSetLayerVisiblePayload,
DocumentUpdateAssetSourcePayload,
DocumentUngroupLayerPayload,
@@ -45,7 +47,7 @@ import type {
} from "./palette";
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
import type { ToolChooseGenerateIntentPayload, ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
import type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
import type { TransformBeginPayload, TransformSetBoundsPayload, TransformSetRotationPayload, TransformUpdatePayload } from "./transform";
import type { WorkspaceSetPanelPayload } from "./workspace";
import type { EditorSetPointerSessionPayload } from "./editor";
import type { ProjectOpenPayload } from "./project";
@@ -79,6 +81,8 @@ export type CommandPayloads = {
[commandIds.documentRemoveLayer]: DocumentRemoveLayerPayload;
[commandIds.documentSetLayerVisible]: DocumentSetLayerVisiblePayload;
[commandIds.documentSetLayerLocked]: DocumentSetLayerLockedPayload;
[commandIds.documentSetLayerOpacity]: DocumentSetLayerOpacityPayload;
[commandIds.documentDuplicateLayer]: DocumentDuplicateLayerPayload;
[commandIds.documentRenameLayer]: DocumentRenameLayerPayload;
[commandIds.documentSetLayerClippingMask]: DocumentSetLayerClippingMaskPayload;
[commandIds.selectionSet]: SelectionSetPayload;
@@ -115,6 +119,7 @@ export type CommandPayloads = {
[commandIds.transformBegin]: TransformBeginPayload;
[commandIds.transformUpdate]: TransformUpdatePayload;
[commandIds.transformSetBounds]: TransformSetBoundsPayload;
[commandIds.transformSetRotation]: TransformSetRotationPayload;
[commandIds.transformEnd]: void;
[commandIds.viewportPan]: ViewportPanPayload;
[commandIds.viewportSetZoom]: ViewportSetZoomPayload;

View File

@@ -1,7 +1,8 @@
import { describe, expect, test } from "bun:test";
import { createInitialAppState } from "@editor/initial-state";
import { documentAddArtboardCommand } from "./document";
import { transformBeginCommand, transformEndCommand, transformSetBoundsCommand, transformUpdateCommand } from "./transform";
import { transformBeginCommand, transformEndCommand, transformSetBoundsCommand, transformSetRotationCommand, transformUpdateCommand } from "./transform";
import { documentAddAssetCommand, documentAddRasterLayerCommand, documentAddLayerMaskCommand, documentSetLayerLockedCommand } from "./document";
function artboardState() {
return documentAddArtboardCommand.execute(
@@ -79,6 +80,19 @@ describe("transform commands", () => {
expect(updated.document.artboards[0]?.bounds).toEqual({ x: 4, y: 19, w: 1, h: 1 });
});
test("sets leaf rotation and keeps its attached mask aligned", () => {
let state = artboardState();
state = documentAddAssetCommand.execute({ state }, { asset: { id: "asset", name: "Asset", mimeType: "image/png", source: "asset", intrinsicSize: { w: 10, h: 10 } } });
state = documentAddRasterLayerCommand.execute({ state }, { artboardId: "a1", layer: { id: "layer", type: "raster", name: "Layer", visible: true, locked: false, opacity: 1, assetId: "asset", transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 } } });
state = documentAddLayerMaskCommand.execute({ state }, { layerId: "layer", asset: { id: "mask-asset", name: "Mask", mimeType: "image/png", source: "mask", intrinsicSize: { w: 10, h: 10 } }, maskLayer: { id: "mask", type: "raster", name: "Mask", visible: true, locked: false, opacity: 1, assetId: "mask-asset", transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 } } });
const rotated = transformSetRotationCommand.execute({ state }, { target: { type: "layer", id: "layer" }, rotation: Math.PI / 2 });
expect(rotated.document.artboards[0]?.layers.map((layer) => layer.transform.rotation)).toEqual([Math.PI / 2, Math.PI / 2]);
const locked = documentSetLayerLockedCommand.execute({ state: rotated }, { layerId: "layer", locked: true });
expect(transformSetRotationCommand.execute({ state: locked }, { target: { type: "layer", id: "layer" }, rotation: 0 })).toBe(locked);
});
test("ends transform session", () => {
const started = transformBeginCommand.execute(
{ state: artboardState() },

View File

@@ -1,8 +1,10 @@
import type { Rect, Vec2D } from "@core/geometry";
import type { Layer } from "@core/layer";
import { applyTransformTargetBounds } from "./transform-document";
import type { TransformHandle, TransformTarget } from "@editor/transform";
import type { Command } from "./command";
import { commandIds } from "./ids";
import type { AppState } from "@editor/state";
export type TransformBeginPayload = {
target: TransformTarget;
@@ -21,6 +23,11 @@ export type TransformSetBoundsPayload = {
bounds: Rect;
};
export type TransformSetRotationPayload = {
target: TransformTarget;
rotation: number;
};
export const transformBeginCommand: Command<TransformBeginPayload> = {
id: commandIds.transformBegin,
name: "Begin transform",
@@ -70,6 +77,7 @@ export const transformSetBoundsCommand: Command<TransformSetBoundsPayload> = {
id: commandIds.transformSetBounds,
name: "Set transform bounds",
execute({ state }, payload) {
if (isTargetLocked(state, payload.target)) return state;
return {
...state,
document: applyTransformTargetBounds(state.document, payload.target, normalizeRect(payload.bounds)),
@@ -77,6 +85,42 @@ export const transformSetBoundsCommand: Command<TransformSetBoundsPayload> = {
},
};
export const transformSetRotationCommand: Command<TransformSetRotationPayload> = {
id: commandIds.transformSetRotation,
name: "Set transform rotation",
execute({ state }, payload) {
if (payload.target.type !== "layer" || !Number.isFinite(payload.rotation) || isTargetLocked(state, payload.target)) return state;
const location = state.document.artboards.flatMap((artboard) => findLayerInTree(artboard.layers, payload.target.id)).find(Boolean);
if (!location || location.type === "group") return state;
const maskId = "layerMask" in location ? location.layerMask?.maskLayerId : undefined;
const ids = new Set([payload.target.id, ...(maskId ? [maskId] : [])]);
return {
...state,
document: {
...state.document,
artboards: state.document.artboards.map((artboard) => ({ ...artboard, layers: mapRotation(artboard.layers, ids, payload.rotation) })),
},
};
},
};
function isTargetLocked(state: AppState, target: TransformTarget) {
if (target.type === "artboard") return state.document.artboards.find((artboard) => artboard.id === target.id)?.locked !== false;
return state.document.artboards.flatMap((artboard) => findLayerInTree(artboard.layers, target.id)).find(Boolean)?.locked !== false;
}
function findLayerInTree(layers: Layer[], id: string): Layer[] {
return layers.flatMap((layer) => layer.id === id ? [layer] : layer.type === "group" ? findLayerInTree(layer.children, id) : []);
}
function mapRotation(layers: Layer[], ids: ReadonlySet<string>, rotation: number): Layer[] {
return layers.map((layer) => ({
...layer,
...(ids.has(layer.id) ? { transform: { ...layer.transform, rotation } } : {}),
...(layer.type === "group" ? { children: mapRotation(layer.children, ids, rotation) } : {}),
})) as Layer[];
}
export const transformEndCommand: Command = {
id: commandIds.transformEnd,
name: "End transform",
@@ -94,7 +138,7 @@ export const transformEndCommand: Command = {
},
};
export const transformCommands = [transformBeginCommand, transformUpdateCommand, transformSetBoundsCommand, transformEndCommand] satisfies Command<unknown>[];
export const transformCommands = [transformBeginCommand, transformUpdateCommand, transformSetBoundsCommand, transformSetRotationCommand, transformEndCommand] satisfies Command<unknown>[];
function transformBounds(bounds: Rect, handle: TransformHandle, delta: Vec2D, constrained = false): Rect {
if (handle === "body") {