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

@@ -1,8 +1,9 @@
import { useMemo, useRef, useState, type DragEvent, type MutableRefObject } from "react";
import { ArrowDown, ArrowUp, DownloadSimple, Eye, EyeSlash, FolderPlus, Lock, LockOpen, Plus, Stack, Trash } from "@phosphor-icons/react";
import { useEffect, useMemo, useRef, useState, type DragEvent, type MutableRefObject } from "react";
import { ArrowDown, ArrowUp, DownloadSimple, Eye, EyeSlash, FolderPlus, Lock, LockOpen, Plus, SlidersHorizontal, Stack, Trash } from "@phosphor-icons/react";
import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document";
import type { Layer } from "@core/layer";
import type { ColorAdjustment } from "@core/adjustment-layer";
import { getLayerMask } from "@core/layer-mask-utils";
import type { ArtboardId } from "@core/id";
import { createDocumentReadIndex, type DocumentReadIndex } from "@editor/document-indexes";
@@ -10,7 +11,7 @@ import type { MaskEditState, SelectionState } from "@editor/state";
import type { AppStore } from "@editor/store";
import { resolveLayerDrop } from "@input/index";
import type { DocumentActions } from "@app/document-actions";
import { addArtboard, addEmptyLayer, addGroupLayer, addLayerMask, deleteSelection, groupLayers, moveLayer } from "@operations/document/layerActions";
import { addAdjustmentLayer, addArtboard, addEmptyLayer, addGroupLayer, addLayerMask, deleteSelection, groupLayers, moveLayer } from "@operations/document/layerActions";
import { MaskOperationButtons, MaskStatus } from "./layers/MaskControls";
import { LayerThumbnail } from "./layers/LayerThumbnail";
import { createLayerThumbnailIndex, type LayerThumbnailModel } from "./layers/thumbnailModel";
@@ -91,6 +92,9 @@ function LayersSheetBody({
<button type="button" className={toolbarButtonClass()} aria-label="Add group" title="Add group" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addGroupLayer(selectedArtboardId, dispatch)}>
<FolderPlus size={24} />
</button>
<button type="button" className={toolbarButtonClass()} aria-label="Add color adjustment" title="Add non-destructive artboard color adjustment" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addAdjustmentLayer(selectedArtboardId, dispatch)}>
<SlidersHorizontal size={24} />
</button>
</div>
</header>
<div className="mb-4 grid grid-cols-5 gap-2">
@@ -110,6 +114,7 @@ function LayersSheetBody({
<Trash size={24} />
</button>
</div>
{selectedLayer?.layer.type === "adjustment" ? <AdjustmentInspector layer={selectedLayer.layer} dispatch={dispatch} /> : null}
<div className="min-h-0 flex-1 overflow-auto pb-2">
{document.artboards.map((artboard) => {
const displayLayerCount = documentIndex.displayLayerCountByArtboardId.get(artboard.id) ?? 0;
@@ -199,6 +204,61 @@ function LayersSheetBody({
);
}
function AdjustmentInspector({ layer, dispatch }: { layer: Extract<Layer, { type: "adjustment" }>; dispatch: AppStore["dispatch"] }) {
const [draft, setDraft] = useState<ColorAdjustment>(() => cloneAdjustment(layer.adjustment));
useEffect(() => setDraft(cloneAdjustment(layer.adjustment)), [layer.id, layer.adjustment]);
const fields = [
["Brightness", "brightness", draft.brightness],
["Contrast", "contrast", draft.contrast],
["Saturation", "saturation", draft.saturation],
["Red", "red", draft.colorBalance.red],
["Green", "green", draft.colorBalance.green],
["Blue", "blue", draft.colorBalance.blue],
] as const;
const commit = () => {
if (JSON.stringify(draft) === JSON.stringify(layer.adjustment)) return;
dispatch(commandIds.documentSetAdjustment, { layerId: layer.id, adjustment: draft });
};
return (
<section aria-label="Adjustment settings" className="mb-3 rounded-[1.5rem] bg-white/[0.05] p-3">
<p className="mb-2 text-xs text-white/45">
Affects visible artboard layers beneath it. Adjustment layers stay at artboard level.
</p>
<div className="grid grid-cols-2 gap-2">
{fields.map(([label, key, value]) => (
<label key={key} className="text-[0.7rem] text-white/55">
<span>{label}</span>
<input
className="mt-1 w-full accent-violet-300"
type="range"
min="-1"
max="1"
step="0.01"
value={value}
disabled={layer.locked}
onChange={(event) => setDraft(withAdjustmentValue(draft, key, Number(event.currentTarget.value)))}
onPointerUp={commit}
onBlur={commit}
/>
</label>
))}
</div>
</section>
);
}
function cloneAdjustment(adjustment: ColorAdjustment): ColorAdjustment {
return { ...adjustment, colorBalance: { ...adjustment.colorBalance } };
}
function withAdjustmentValue(adjustment: ColorAdjustment, key: "brightness" | "contrast" | "saturation" | "red" | "green" | "blue", value: number): ColorAdjustment {
if (key === "brightness" || key === "contrast" || key === "saturation") return { ...adjustment, [key]: value };
return { ...adjustment, colorBalance: { ...adjustment.colorBalance, [key]: value } };
}
function LayerRow({
document,
documentIndex,
@@ -234,8 +294,8 @@ function LayerRow({
const layerInfo = documentIndex.layerInfoById.get(layer.id);
const layerMask = getLayerMask(layer);
const maskLayer = layerMask ? documentIndex.layerById.get(layerMask.maskLayerId) : undefined;
const maskAsset = maskLayer && maskLayer.type !== "group" ? documentIndex.assetById.get(maskLayer.assetId) : undefined;
const canAddMask = Boolean(layerInfo && layer.type !== "group" && !layerMask);
const maskAsset = maskLayer && maskLayer.type !== "group" && maskLayer.type !== "adjustment" ? documentIndex.assetById.get(maskLayer.assetId) : undefined;
const canAddMask = Boolean(layerInfo && layer.type !== "group" && layer.type !== "adjustment" && !layerMask);
const editingMask = Boolean(maskEdit && layerMask && maskEdit.targetLayerId === layer.id && maskEdit.maskLayerId === layerMask.maskLayerId);
const thumbnail = thumbnailByLayerId.get(layer.id) ?? { kind: "empty" };
const maskThumbnail = maskLayer ? thumbnailByLayerId.get(maskLayer.id) : undefined;
@@ -354,7 +414,7 @@ function LayerRow({
>
Hide
</button>
{maskAsset && maskLayer.type !== "group" ? (
{maskAsset && maskLayer.type !== "group" && maskLayer.type !== "adjustment" ? (
<MaskOperationButtons maskLayerId={maskLayer.id} maskAsset={maskAsset} dispatch={dispatch} />
) : null}
</div>

View File

@@ -31,7 +31,7 @@ export function TransformControls({ bounds, target, documentIndex, layerInfo, di
const layer = layerInfo?.layer;
const locked = layer?.locked ?? false;
const mask = layer ? getLayerMask(layer) : undefined;
const rotatedMaskEditingUnsupported = Boolean(layer && layer.type !== "group" && layer.transform.rotation !== 0);
const rotatedMaskEditingUnsupported = Boolean(layer && layer.type !== "group" && layer.type !== "adjustment" && layer.transform.rotation !== 0);
useEffect(() => {
setDraft(draftFromBounds(bounds));
@@ -39,7 +39,7 @@ export function TransformControls({ bounds, target, documentIndex, layerInfo, di
useEffect(() => setOpacityDraft(String(Math.round((layer?.opacity ?? 1) * 100))), [layer?.id, layer?.opacity]);
useEffect(() => setRotationDraft(rotationDegrees(layerInfo)), [layer?.id, layer?.transform.rotation]);
useEffect(() => setCropDraft(cropDraftFor(layerInfo, documentIndex)), [documentIndex, layer?.id, layer?.type === "group" ? undefined : layer?.sourceRect]);
useEffect(() => setCropDraft(cropDraftFor(layerInfo, documentIndex)), [documentIndex, layer?.id, layer?.type === "image" || layer?.type === "raster" ? layer.sourceRect : undefined]);
useEffect(() => setResizeDraft((current) => ({ ...current, w: String(Math.round(bounds.w)), h: String(Math.round(bounds.h)) })), [bounds.w, bounds.h, target]);
const commitField = (field: BoundsField) => {
@@ -89,8 +89,8 @@ export function TransformControls({ bounds, target, documentIndex, layerInfo, di
label="Rotation"
suffix="°"
value={rotationDraft}
disabled={locked || layer.type === "group"}
title={layer.type === "group" ? "Group rotation is not supported" : undefined}
disabled={locked || layer.type === "group" || layer.type === "adjustment"}
title={layer.type === "group" || layer.type === "adjustment" ? "Group rotation is not supported" : undefined}
onChange={setRotationDraft}
onCommit={() => {
const value = Number.parseFloat(rotationDraft);
@@ -102,12 +102,12 @@ export function TransformControls({ bounds, target, documentIndex, layerInfo, di
<button type="button" className={actionButtonClass()} disabled={locked} title={locked ? "Unlock the layer to duplicate it" : "Duplicate layer"} onClick={() => duplicateLayer(documentIndex, layerInfo!, dispatch)}>
<Copy size={20} /> Duplicate
</button>
{layer.type !== "group" ? (
{layer.type !== "group" && layer.type !== "adjustment" ? (
<button type="button" className={actionButtonClass()} disabled={locked || layer.transform.rotation !== 0} title={layer.transform.rotation !== 0 ? "Reset rotation before cropping" : "Crop visible source pixels"} onClick={() => setCropOpen((open) => !open)}>
<Crop size={20} /> Crop
</button>
) : null}
{layer.type !== "group" ? (
{layer.type !== "group" && layer.type !== "adjustment" ? (
<button
type="button"
className={actionButtonClass()}
@@ -122,7 +122,7 @@ export function TransformControls({ bounds, target, documentIndex, layerInfo, di
</button>
) : null}
{locked ? <span className="text-xs text-amber-200/70">Unlock to edit</span> : null}
{cropOpen && layer.type !== "group" ? <CropEditor draft={cropDraft} setDraft={setCropDraft} onCancel={() => { setCropDraft(cropDraftFor(layerInfo, documentIndex)); setCropOpen(false); }} onReset={() => { dispatch(commandIds.documentSetLayerSourceRect, { layerId: layer.id }); setCropOpen(false); }} onApply={() => { const sourceRect = parseRectDraft(cropDraft); if (sourceRect) { dispatch(commandIds.documentSetLayerSourceRect, { layerId: layer.id, sourceRect }); setCropOpen(false); } }} /> : null}
{cropOpen && layer.type !== "group" && layer.type !== "adjustment" ? <CropEditor draft={cropDraft} setDraft={setCropDraft} onCancel={() => { setCropDraft(cropDraftFor(layerInfo, documentIndex)); setCropOpen(false); }} onReset={() => { dispatch(commandIds.documentSetLayerSourceRect, { layerId: layer.id }); setCropOpen(false); }} onApply={() => { const sourceRect = parseRectDraft(cropDraft); if (sourceRect) { dispatch(commandIds.documentSetLayerSourceRect, { layerId: layer.id, sourceRect }); setCropOpen(false); } }} /> : null}
</>
) : target.type === "artboard" ? (
<>
@@ -154,7 +154,7 @@ function ArtboardResizeEditor({ draft, setDraft, onApply, onCancel }: { draft: {
}
function cropDraftFor(layerInfo: IndexedLayerInfo | undefined, index: DocumentReadIndex): CropDraft {
if (!layerInfo || layerInfo.layer.type === "group") return { x: "0", y: "0", w: "1", h: "1" };
if (!layerInfo || layerInfo.layer.type === "group" || layerInfo.layer.type === "adjustment") return { x: "0", y: "0", w: "1", h: "1" };
const asset = index.assetById.get(layerInfo.layer.assetId);
const rect = layerInfo.layer.sourceRect ?? { x: 0, y: 0, w: asset?.intrinsicSize.w ?? 1, h: asset?.intrinsicSize.h ?? 1 };
return draftFromBounds(rect);

View File

@@ -1,4 +1,4 @@
import { FolderSimple, ImageBroken } from "@phosphor-icons/react";
import { FolderSimple, ImageBroken, SlidersHorizontal } from "@phosphor-icons/react";
import type { LayerThumbnailModel, RasterThumbnailModel } from "./thumbnailModel";
export function LayerThumbnail({ model, label, compact = false }: { model: LayerThumbnailModel; label: string; compact?: boolean }) {
@@ -15,6 +15,7 @@ export function LayerThumbnail({ model, label, compact = false }: { model: Layer
</>
) : null}
{model.kind === "empty" ? <ImageBroken size={compact ? 14 : 17} className="text-white/35" /> : null}
{model.kind === "adjustment" ? <SlidersHorizontal size={compact ? 14 : 17} className="text-violet-200" /> : null}
</span>
);
}

View File

@@ -14,6 +14,7 @@ export type RasterThumbnailModel = {
export type LayerThumbnailModel =
| RasterThumbnailModel
| { kind: "adjustment" }
| { kind: "group"; previews: readonly RasterThumbnailModel[] }
| { kind: "empty" };
@@ -23,6 +24,7 @@ export function resolveLayerThumbnail(
assetById: ReadonlyMap<AssetId, Asset>,
excludedLayerIds: ReadonlySet<LayerId> = new Set(),
): LayerThumbnailModel {
if (layer.type === "adjustment") return { kind: "adjustment" };
if (layer.type !== "group") return resolveRasterThumbnail(layer, assetById) ?? { kind: "empty" };
const previews: RasterThumbnailModel[] = [];
@@ -56,6 +58,7 @@ export function createLayerThumbnailIndex(
const { layer } = frame;
if (frame.visited || layer.type !== "group") {
if (layer.type !== "group") {
if (layer.type === "adjustment") { result.set(layer.id, { kind: "adjustment" }); continue; }
result.set(layer.id, resolveRasterThumbnail(layer, assetById) ?? { kind: "empty" });
continue;
}
@@ -80,6 +83,7 @@ export function createLayerThumbnailIndex(
}
function resolveRasterThumbnail(layer: Exclude<Layer, { type: "group" }>, assetById: ReadonlyMap<AssetId, Asset>): RasterThumbnailModel | undefined {
if (layer.type === "adjustment") return undefined;
const asset = assetById.get(layer.assetId);
if (!asset || !asset.source.trim() || !positiveFinite(asset.intrinsicSize.w) || !positiveFinite(asset.intrinsicSize.h)) return undefined;