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

@@ -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),
} : {