feat: add non-destructive adjustment layers

This commit is contained in:
syntaxbullet
2026-07-11 12:31:06 +02:00
parent b928fb599b
commit 606426c885
30 changed files with 427 additions and 49 deletions

View File

@@ -0,0 +1,39 @@
import { describe, expect, test } from "bun:test";
import { createInitialAppState } from "@editor/initial-state";
import { neutralColorAdjustment } from "@core/adjustment-layer";
import { documentAddArtboardCommand, documentAddAdjustmentLayerCommand, documentSetAdjustmentCommand, documentDuplicateLayerCommand, documentMoveLayerCommand } from "./document";
const transform = { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 };
function stateWithAdjustment() {
let state = documentAddArtboardCommand.execute({ state: createInitialAppState("test") }, { id: "board", name: "Board", bounds: { x: 0, y: 0, w: 10, h: 10 } });
state = documentAddAdjustmentLayerCommand.execute({ state }, { artboardId: "board", layer: { id: "adjust", type: "adjustment", name: "Color", visible: true, locked: false, opacity: 1, transform, adjustment: neutralColorAdjustment } });
return state;
}
describe("adjustment commands", () => {
test("creates and selects a persistent adjustment node", () => {
const state = stateWithAdjustment();
expect(state.document.artboards[0]?.layers[0]?.type).toBe("adjustment");
expect(state.editor.selection.layerIds).toEqual(["adjust"]);
});
test("validates edits and preserves locked layers", () => {
const state = stateWithAdjustment();
const changed = documentSetAdjustmentCommand.execute({ state }, { layerId: "adjust", adjustment: { ...neutralColorAdjustment, brightness: .25 } });
const invalid = documentSetAdjustmentCommand.execute({ state: changed }, { layerId: "adjust", adjustment: { ...neutralColorAdjustment, contrast: 2 } });
expect((changed.document.artboards[0]?.layers[0] as { adjustment: { brightness: number } }).adjustment.brightness).toBe(.25);
expect(invalid).toBe(changed);
});
test("duplicates and moves adjustment nodes like tree siblings", () => {
let state = stateWithAdjustment();
state = documentDuplicateLayerCommand.execute({ state }, { layerId: "adjust", idByLayerId: { adjust: "copy" } });
state = documentMoveLayerCommand.execute({ state }, { layerId: "copy", toArtboardId: "board", toIndex: 0 });
expect(state.document.artboards[0]?.layers.map((layer) => [layer.id, layer.type])).toEqual([["copy", "adjustment"], ["adjust", "adjustment"]]);
});
test("rejects nested creation, movement, and grouping to preserve exact scope", () => {
const state = stateWithAdjustment();
const nested = documentAddAdjustmentLayerCommand.execute({ state }, { artboardId: "board", parentGroupId: "group", layer: { id: "nested", type: "adjustment", name: "Nested", visible: true, locked: false, opacity: 1, transform, adjustment: neutralColorAdjustment } });
const moved = documentMoveLayerCommand.execute({ state }, { layerId: "adjust", toArtboardId: "board", toParentGroupId: "group", toIndex: 0 });
expect(nested).toBe(state);
expect(moved).toBe(state);
});
});

View File

@@ -8,6 +8,7 @@ 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 { AdjustmentLayer, ColorAdjustment } from "@core/adjustment-layer";
import type { Command } from "./command";
import { commandIds } from "./ids";
@@ -73,6 +74,8 @@ export type DocumentAddGroupLayerPayload = {
parentGroupId?: LayerId;
group: LayerGroup;
};
export type DocumentAddAdjustmentLayerPayload = { artboardId: ArtboardId; parentGroupId?: LayerId; layer: AdjustmentLayer };
export type DocumentSetAdjustmentPayload = { layerId: LayerId; adjustment: ColorAdjustment };
export type DocumentMoveLayerPayload = {
layerId: LayerId;
@@ -353,10 +356,31 @@ export const documentAddGroupLayerCommand: Command<DocumentAddGroupLayerPayload>
},
};
export const documentAddAdjustmentLayerCommand: Command<DocumentAddAdjustmentLayerPayload> = {
id: commandIds.documentAddAdjustmentLayer,
name: "Add adjustment layer",
execute({ state }, payload) {
if (payload.parentGroupId || !validAdjustment(payload.layer.adjustment)) return state;
return { ...state, document: insertLayer(state.document, payload.artboardId, undefined, payload.layer, 0), editor: { ...state.editor, selection: { artboardId: payload.artboardId, layerIds: [payload.layer.id] } } };
},
};
export const documentSetAdjustmentCommand: Command<DocumentSetAdjustmentPayload> = {
id: commandIds.documentSetAdjustment,
name: "Edit adjustment layer",
execute({ state }, payload) {
const location = findLayerLocation(state.document, payload.layerId);
if (!location || location.layer.type !== "adjustment" || location.layer.locked || !validAdjustment(payload.adjustment)) return state;
return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => layer.type === "adjustment" ? { ...layer, adjustment: payload.adjustment } : layer) };
},
};
export const documentMoveLayerCommand: Command<DocumentMoveLayerPayload> = {
id: commandIds.documentMoveLayer,
name: "Move layer",
execute({ state }, payload) {
const source = findLayerLocation(state.document, payload.layerId);
if (source?.layer.type === "adjustment" && payload.toParentGroupId) return state;
if (payload.toParentGroupId && !findGroup(state.document, payload.toParentGroupId)) return state;
const removed = removeLayerFromDocument(state.document, payload.layerId);
@@ -390,6 +414,7 @@ export const documentGroupLayersCommand: Command<DocumentGroupLayersPayload> = {
const location = findLayerLocation(state.document, layerId);
return location ? [location] : [];
});
if (requestedLocations.some((location) => location.layer.type === "adjustment")) return state;
if (requestedLocations.length !== requestedIds.length) return state;
const firstLocation = requestedLocations[0];
@@ -463,14 +488,14 @@ export const documentSetLayerSourceRectCommand: Command<DocumentSetLayerSourceRe
name: "Crop layer",
execute({ state }, payload) {
const location = findLayerLocation(state.document, payload.layerId);
if (!location || location.layer.type === "group" || location.layer.locked || location.layer.transform.rotation !== 0) return state;
if (!location || location.layer.type === "group" || location.layer.type === "adjustment" || location.layer.locked || location.layer.transform.rotation !== 0) return state;
const leaf = location.layer;
const asset = state.document.assets.find((candidate) => candidate.id === leaf.assetId);
if (!asset) return state;
const sourceRect = payload.sourceRect ? clampSourceRect(payload.sourceRect, asset.intrinsicSize.w, asset.intrinsicSize.h) : undefined;
if (payload.sourceRect && !sourceRect) return state;
return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => {
if (layer.type === "group") return layer;
if (layer.type === "group" || layer.type === "adjustment") return layer;
if (sourceRect) return { ...layer, sourceRect };
const uncropped = { ...layer };
delete uncropped.sourceRect;
@@ -549,6 +574,7 @@ export const documentSetLayerClippingMaskCommand: Command<DocumentSetLayerClippi
const targetLocation = findLayerLocation(state.document, payload.layerId);
const maskLocation = findLayerLocation(state.document, payload.maskLayerId);
if (!targetLocation || !maskLocation) return state;
if (targetLocation.layer.type === "adjustment") return state;
if (targetLocation.artboardId !== maskLocation.artboardId || targetLocation.parentGroupId !== maskLocation.parentGroupId) return state;
const removed = removeLayerFromDocument(state.document, payload.layerId);
@@ -575,7 +601,7 @@ export const documentAddLayerMaskCommand: Command<DocumentAddLayerMaskPayload> =
name: "Add layer mask",
execute({ state }, payload) {
const targetLocation = findLayerLocation(state.document, payload.layerId);
if (!targetLocation || targetLocation.layer.type === "group") return state;
if (!targetLocation || targetLocation.layer.type === "group" || targetLocation.layer.type === "adjustment") return state;
const existingMaskId = getLayerMask(targetLocation.layer)?.maskLayerId;
if (existingMaskId) {
@@ -638,7 +664,7 @@ export const documentApplyLayerMaskOperationCommand: Command<DocumentApplyLayerM
if (!payload.source.trim()) return state;
const maskLocation = findLayerLocation(state.document, payload.maskLayerId);
if (!maskLocation || maskLocation.layer.type === "group") return state;
if (!maskLocation || maskLocation.layer.type === "group" || maskLocation.layer.type === "adjustment") return state;
if (!isReferencedMaskLayer(state.document, payload.maskLayerId)) return state;
const maskAssetId = maskLocation.layer.assetId;
@@ -730,6 +756,8 @@ export const documentCommands = [
documentAddImageLayerCommand,
documentAddRasterLayerCommand,
documentAddGroupLayerCommand,
documentAddAdjustmentLayerCommand,
documentSetAdjustmentCommand,
documentMoveLayerCommand,
documentGroupLayersCommand,
documentUngroupLayerCommand,
@@ -750,6 +778,10 @@ function validRect(rect: Rect): Rect | undefined {
return [rect.x, rect.y, rect.w, rect.h].every(Number.isFinite) && rect.w >= 1 && rect.h >= 1 ? { ...rect } : undefined;
}
function validAdjustment(value: ColorAdjustment): boolean {
return [value.brightness, value.contrast, value.saturation, value.colorBalance.red, value.colorBalance.green, value.colorBalance.blue].every((number) => Number.isFinite(number) && number >= -1 && number <= 1);
}
function clampSourceRect(rect: Rect, width: number, height: number): Rect | undefined {
if (![rect.x, rect.y, rect.w, rect.h].every(Number.isFinite)) return undefined;
const x = Math.max(0, Math.min(width - 1, rect.x));
@@ -760,7 +792,7 @@ function clampSourceRect(rect: Rect, width: number, height: number): Rect | unde
}
function scaleLayerTree(layers: Layer[], before: Rect, after: Rect, scaleX: number, scaleY: number): Layer[] {
return layers.map((layer) => layer.type === "group" ? {
return layers.map((layer) => layer.type === "adjustment" ? layer : layer.type === "group" ? {
...layer,
children: scaleLayerTree(layer.children, before, after, scaleX, scaleY),
} : {

View File

@@ -12,6 +12,8 @@ export const commandIds = {
documentAddImageLayer: "document.addImageLayer",
documentAddRasterLayer: "document.addRasterLayer",
documentAddGroupLayer: "document.addGroupLayer",
documentAddAdjustmentLayer: "document.addAdjustmentLayer",
documentSetAdjustment: "document.setAdjustment",
documentMoveLayer: "document.moveLayer",
documentGroupLayers: "document.groupLayers",
documentUngroupLayer: "document.ungroupLayer",

View File

@@ -5,6 +5,8 @@ export {
documentAddArtboardCommand,
documentAddAssetCommand,
documentAddGroupLayerCommand,
documentAddAdjustmentLayerCommand,
documentSetAdjustmentCommand,
documentAddImageLayerCommand,
documentAddLayerMaskCommand,
documentAddRasterLayerCommand,
@@ -32,6 +34,8 @@ export type {
DocumentAddArtboardPayload,
DocumentAddAssetPayload,
DocumentAddGroupLayerPayload,
DocumentAddAdjustmentLayerPayload,
DocumentSetAdjustmentPayload,
DocumentAddImageLayerPayload,
DocumentAddLayerMaskPayload,
DocumentAddRasterLayerPayload,

View File

@@ -3,6 +3,8 @@ import type {
DocumentAddArtboardPayload,
DocumentAddAssetPayload,
DocumentAddGroupLayerPayload,
DocumentAddAdjustmentLayerPayload,
DocumentSetAdjustmentPayload,
DocumentAddImageLayerPayload,
DocumentAddLayerMaskPayload,
DocumentAddRasterLayerPayload,
@@ -75,6 +77,8 @@ export type CommandPayloads = {
[commandIds.documentAddImageLayer]: DocumentAddImageLayerPayload;
[commandIds.documentAddRasterLayer]: DocumentAddRasterLayerPayload;
[commandIds.documentAddGroupLayer]: DocumentAddGroupLayerPayload;
[commandIds.documentAddAdjustmentLayer]: DocumentAddAdjustmentLayerPayload;
[commandIds.documentSetAdjustment]: DocumentSetAdjustmentPayload;
[commandIds.documentAddLayerMask]: DocumentAddLayerMaskPayload;
[commandIds.documentApplyLayerMaskOperation]: DocumentApplyLayerMaskOperationPayload;
[commandIds.documentRemoveLayerMask]: DocumentRemoveLayerMaskPayload;

View File

@@ -10,7 +10,7 @@ export function applyTransformTargetBounds(document: ImageDocument, target: Tran
if (target.type === "artboard") return { ...document, artboards: document.artboards.map((artboard) => artboard.id === target.id ? { ...artboard, bounds: { ...bounds } } : artboard) };
const layer = findLayer(document, target.id);
if (!layer) return document;
return layer.type === "group" ? applyGroupBounds(document, layer.id, bounds) : applyLeafBounds(document, layer.id, bounds);
return layer.type === "group" ? applyGroupBounds(document, layer.id, bounds) : layer.type === "adjustment" ? document : applyLeafBounds(document, layer.id, bounds);
}
function applyLeafBounds(document: ImageDocument, layerId: LayerId, bounds: Rect): ImageDocument {
@@ -39,13 +39,14 @@ function mapGroupBounds(document: ImageDocument, layers: Layer[], groupId: Layer
function scaleSubtree(document: ImageDocument, layer: Layer, initial: Rect, bounds: Rect, scale: { x: number; y: number }): Layer {
if (layer.type === "group") return { ...layer, children: layer.children.map((child) => scaleSubtree(document, child, initial, bounds, scale)) };
if (layer.type === "adjustment") return layer;
if (!document.assets.some((asset) => asset.id === layer.assetId)) return layer;
return { ...layer, transform: { ...layer.transform, position: { x: bounds.x + (layer.transform.position.x - initial.x) * scale.x, y: bounds.y + (layer.transform.position.y - initial.y) * scale.y }, scale: { x: layer.transform.scale.x * scale.x, y: layer.transform.scale.y * scale.y } } };
}
function mapLeafBounds(document: ImageDocument, layers: Layer[], layerId: LayerId, bounds: Rect): Layer[] {
return layers.map((layer) => {
if (layer.id === layerId && layer.type !== "group") {
if (layer.id === layerId && layer.type !== "group" && layer.type !== "adjustment") {
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
if (!asset) return layer;
const source = layer.sourceRect ?? { x: 0, y: 0, ...asset.intrinsicSize };