feat: add non-destructive adjustment layers
This commit is contained in:
39
commands/adjustment.test.ts
Normal file
39
commands/adjustment.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
} : {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user