import { findLayerLocation, isReferencedMaskLayer, mapLayerInDocument, insertLayer, replaceLayerListInDocument, replaceSelectedLayersWithGroup, removeLayerFromDocument, ungroupLayerInDocument, findGroup, removeLayerMaskReference, withLayerMask, removeUnreferencedMaskLayer, removeMissingMaskReferences, isMaskEditFor, isMaskEditValid, collectLayerIds, collectClippingMaskIds, collectAttachedMaskIds, removeInpaintRegionsForTargets } from "./document-tree"; import type { Asset } from "@core/asset"; import type { ImageDocument } from "@core/document"; import type { Rect } from "@core/geometry"; 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 { AdjustmentLayer, ColorAdjustment } from "@core/adjustment-layer"; import type { TextLayer, TextStyle } from "@core/text-layer"; import { isValidTextStyle } from "@core/text-layer"; import type { Command } from "./command"; import { commandIds } from "./ids"; export type DocumentAddArtboardPayload = { id: ArtboardId; name: string; bounds: Rect; }; export type DocumentSetArtboardBoundsPayload = { id: ArtboardId; bounds: Rect; }; export type DocumentResizeArtboardPayload = { id: ArtboardId; bounds: Rect; scaleContents: boolean; }; export type DocumentRemoveArtboardPayload = { id: ArtboardId; }; export type DocumentSetArtboardVisiblePayload = { id: ArtboardId; visible: boolean; }; export type DocumentSetArtboardLockedPayload = { id: ArtboardId; locked: boolean; }; export type DocumentRenameArtboardPayload = { id: ArtboardId; name: string; }; export type DocumentAddAssetPayload = { asset: Asset; }; export type DocumentUpdateAssetSourcePayload = { assetId: AssetId; source: string; }; export type DocumentAddImageLayerPayload = { artboardId: ArtboardId; parentGroupId?: LayerId; layer: ImageLayer; }; export type DocumentAddRasterLayerPayload = { artboardId: ArtboardId; parentGroupId?: LayerId; layer: RasterLayer; }; export type DocumentAddGroupLayerPayload = { artboardId: ArtboardId; parentGroupId?: LayerId; group: LayerGroup; }; export type DocumentAddAdjustmentLayerPayload = { artboardId: ArtboardId; parentGroupId?: LayerId; layer: AdjustmentLayer }; export type DocumentSetAdjustmentPayload = { layerId: LayerId; adjustment: ColorAdjustment }; export type DocumentAddTextLayerPayload = { artboardId: ArtboardId; parentGroupId?: LayerId; layer: TextLayer }; export type DocumentSetTextLayerPayload = { layerId: LayerId; content: string; style: TextStyle }; export type DocumentMoveLayerPayload = { layerId: LayerId; toArtboardId: ArtboardId; toParentGroupId?: LayerId; toIndex: number; }; export type DocumentGroupLayersPayload = { artboardId: ArtboardId; layerIds: LayerId[]; group: LayerGroup; }; export type DocumentUngroupLayerPayload = { groupId: LayerId; }; export type DocumentRemoveLayerPayload = { layerId: LayerId; }; export type DocumentSetLayerVisiblePayload = { layerId: LayerId; visible: boolean; }; export type DocumentSetLayerLockedPayload = { layerId: LayerId; locked: boolean; }; export type DocumentSetLayerOpacityPayload = { layerId: LayerId; opacity: number; }; export type DocumentSetLayerSourceRectPayload = { layerId: LayerId; sourceRect?: Rect; }; export type DocumentDuplicateLayerPayload = { layerId: LayerId; idByLayerId: Record; }; export type DocumentRenameLayerPayload = { layerId: LayerId; name: string; }; export type DocumentSetLayerClippingMaskPayload = { layerId: LayerId; maskLayerId?: LayerId; }; export type DocumentAddLayerMaskPayload = { layerId: LayerId; asset: Asset; maskLayer: RasterLayer; activeTool?: "brush" | "feather"; }; export type LayerMaskOperation = | { type: "paint" } | { type: "magicWand" } | { type: "chromaKey" } | { type: "invert" } | { type: "fill"; fill: "white" | "black" | "clear" } | { type: "feather"; radius: number } | { type: "expand"; radius: number } | { type: "contract"; radius: number } | { type: "blur"; radius: number } | { type: "despeckle"; strength: number }; export type DocumentApplyLayerMaskOperationPayload = { maskLayerId: LayerId; source: string; mimeType?: string; operation: LayerMaskOperation; }; export type DocumentRemoveLayerMaskPayload = { layerId: LayerId; }; export const documentAddArtboardCommand: Command = { id: commandIds.documentAddArtboard, name: "Add artboard", execute({ state }, payload) { return { ...state, document: { ...state.document, artboards: [ ...state.document.artboards, { id: payload.id, name: payload.name, bounds: payload.bounds, backgroundColor: "transparent", visible: true, locked: false, layers: [], }, ], }, }; }, }; export const documentSetArtboardBoundsCommand: Command = { id: commandIds.documentSetArtboardBounds, name: "Set artboard bounds", execute({ state }, payload) { return { ...state, document: { ...state.document, artboards: state.document.artboards.map((artboard) => artboard.id === payload.id ? { ...artboard, bounds: { ...payload.bounds } } : artboard, ), }, }; }, }; export const documentResizeArtboardCommand: Command = { id: commandIds.documentResizeArtboard, name: "Resize artboard", execute({ state }, payload) { const artboard = state.document.artboards.find((candidate) => candidate.id === payload.id); const bounds = validRect(payload.bounds); if (!artboard || artboard.locked || !bounds) return state; const scaleX = bounds.w / artboard.bounds.w; const scaleY = bounds.h / artboard.bounds.h; return { ...state, document: { ...state.document, artboards: state.document.artboards.map((candidate) => candidate.id !== payload.id ? candidate : { ...candidate, bounds, layers: payload.scaleContents ? scaleLayerTree(candidate.layers, artboard.bounds, bounds, scaleX, scaleY) : candidate.layers, }), }, }; }, }; export const documentRemoveArtboardCommand: Command = { id: commandIds.documentRemoveArtboard, name: "Remove artboard", execute({ state }, payload) { const removedSelectedArtboard = state.editor.selection.artboardId === payload.id; const removedArtboard = state.document.artboards.find((artboard) => artboard.id === payload.id); const withoutArtboard = { ...state.document, artboards: state.document.artboards.filter((artboard) => artboard.id !== payload.id), }; const removedLayerIds = new Set(); for (const layer of removedArtboard?.layers ?? []) collectLayerIds(layer, removedLayerIds); const document = removeInpaintRegionsForTargets(withoutArtboard, removedLayerIds); return { ...state, document, editor: { ...state.editor, selection: removedSelectedArtboard ? { layerIds: [] } : state.editor.selection, maskEdit: isMaskEditValid(state.editor.maskEdit, document) ? state.editor.maskEdit : undefined, }, }; }, }; export const documentSetArtboardVisibleCommand: Command = { id: commandIds.documentSetArtboardVisible, name: "Set artboard visible", execute({ state }, payload) { return { ...state, document: { ...state.document, artboards: state.document.artboards.map((artboard) => (artboard.id === payload.id ? { ...artboard, visible: payload.visible } : artboard)), }, }; }, }; export const documentSetArtboardLockedCommand: Command = { id: commandIds.documentSetArtboardLocked, name: "Set artboard locked", execute({ state }, payload) { return { ...state, document: { ...state.document, artboards: state.document.artboards.map((artboard) => (artboard.id === payload.id ? { ...artboard, locked: payload.locked } : artboard)), }, }; }, }; export const documentRenameArtboardCommand: Command = { id: commandIds.documentRenameArtboard, name: "Rename artboard", execute({ state }, payload) { const name = payload.name.trim(); if (!name) return state; return { ...state, document: { ...state.document, artboards: state.document.artboards.map((artboard) => (artboard.id === payload.id ? { ...artboard, name } : artboard)), }, }; }, }; export const documentAddAssetCommand: Command = { id: commandIds.documentAddAsset, name: "Add asset", execute({ state }, payload) { if (state.document.assets.some((asset) => asset.id === payload.asset.id)) return state; return { ...state, document: { ...state.document, assets: [...state.document.assets, payload.asset], }, }; }, }; export const documentUpdateAssetSourceCommand: Command = { id: commandIds.documentUpdateAssetSource, name: "Update asset source", execute({ state }, payload) { return { ...state, document: { ...state.document, assets: state.document.assets.map((asset) => (asset.id === payload.assetId ? { ...asset, source: payload.source } : asset)), }, }; }, }; export const documentAddImageLayerCommand: Command = { id: commandIds.documentAddImageLayer, name: "Add image layer", execute({ state }, payload) { return { ...state, document: insertLayer(state.document, payload.artboardId, payload.parentGroupId, payload.layer), }; }, }; export const documentAddRasterLayerCommand: Command = { id: commandIds.documentAddRasterLayer, name: "Add raster layer", execute({ state }, payload) { return { ...state, document: insertLayer(state.document, payload.artboardId, payload.parentGroupId, payload.layer), }; }, }; export const documentAddGroupLayerCommand: Command = { id: commandIds.documentAddGroupLayer, name: "Add group layer", execute({ state }, payload) { return { ...state, document: insertLayer(state.document, payload.artboardId, payload.parentGroupId, payload.group), editor: { ...state.editor, selection: { artboardId: payload.artboardId, layerIds: [payload.group.id] } }, }; }, }; export const documentAddAdjustmentLayerCommand: Command = { 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 documentAddTextLayerCommand: Command = { id: commandIds.documentAddTextLayer, name: "Add text layer", execute({ state }, payload) { if (!payload.layer.content.trim() || !isValidTextStyle(payload.layer.style)) return state; const document = insertLayer(state.document, payload.artboardId, payload.parentGroupId, payload.layer); return { ...state, document, editor: { ...state.editor, selection: { artboardId: payload.artboardId, layerIds: [payload.layer.id] } } }; }, }; export const documentSetTextLayerCommand: Command = { id: commandIds.documentSetTextLayer, name: "Edit text layer", execute({ state }, payload) { const location = findLayerLocation(state.document, payload.layerId); if (!location || location.layer.type !== "text" || location.layer.locked || !payload.content.trim() || !isValidTextStyle(payload.style)) return state; return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => layer.type === "text" ? { ...layer, content: payload.content, style: { ...payload.style } } : layer) }; }, }; export const documentSetAdjustmentCommand: Command = { 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 = { 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); if (!removed.layer) return state; const maskLayerId = getLayerMask(removed.layer)?.maskLayerId; const removedMask = maskLayerId ? removeLayerFromDocument(removed.document, maskLayerId) : undefined; const documentAfterRemoval = removedMask?.document ?? removed.document; if (payload.toParentGroupId && !findGroup(documentAfterRemoval, payload.toParentGroupId)) return state; const documentWithMask = removedMask?.layer ? insertLayer(documentAfterRemoval, payload.toArtboardId, payload.toParentGroupId, removedMask.layer, payload.toIndex) : documentAfterRemoval; return { ...state, document: insertLayer(documentWithMask, payload.toArtboardId, payload.toParentGroupId, removed.layer, payload.toIndex + (removedMask?.layer ? 1 : 0)), }; }, }; export const documentGroupLayersCommand: Command = { id: commandIds.documentGroupLayers, name: "Group layers", execute({ state }, payload) { const requestedIds = [...new Set(payload.layerIds)]; if (requestedIds.length === 0) return state; if (findLayerLocation(state.document, payload.group.id)) return state; const requestedLocations = requestedIds.flatMap((layerId) => { 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]; if (!firstLocation || firstLocation.artboardId !== payload.artboardId) return state; if (requestedLocations.some((location) => location.artboardId !== firstLocation.artboardId || location.parentGroupId !== firstLocation.parentGroupId)) return state; const uniqueIds = new Set([...requestedIds, ...collectAttachedMaskIds(firstLocation.siblings, requestedIds)]); const selected = firstLocation.siblings.filter((layer) => uniqueIds.has(layer.id)); if (selected.length === 0) return state; const group: LayerGroup = { ...payload.group, children: selected }; const document = replaceLayerListInDocument( state.document, firstLocation.artboardId, firstLocation.parentGroupId, replaceSelectedLayersWithGroup(firstLocation.siblings, uniqueIds, group), ); return { ...state, document, editor: { ...state.editor, selection: { artboardId: payload.artboardId, layerIds: [group.id] } }, }; }, }; export const documentUngroupLayerCommand: Command = { id: commandIds.documentUngroupLayer, name: "Ungroup layer", execute({ state }, payload) { const result = ungroupLayerInDocument(state.document, payload.groupId); if (!result.changed) return state; return { ...state, document: result.document, editor: { ...state.editor, selection: { artboardId: result.artboardId, layerIds: result.children.map((layer) => layer.id) } }, }; }, }; export const documentSetLayerVisibleCommand: Command = { id: commandIds.documentSetLayerVisible, name: "Set layer visible", execute({ state }, payload) { return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => ({ ...layer, visible: payload.visible })) }; }, }; export const documentSetLayerLockedCommand: Command = { id: commandIds.documentSetLayerLocked, name: "Set layer locked", execute({ state }, payload) { return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => ({ ...layer, locked: payload.locked })) }; }, }; export const documentSetLayerOpacityCommand: Command = { 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 documentSetLayerSourceRectCommand: Command = { id: commandIds.documentSetLayerSourceRect, name: "Crop layer", execute({ state }, payload) { const location = findLayerLocation(state.document, payload.layerId); if (!location || (location.layer.type !== "image" && location.layer.type !== "raster") || 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 !== "image" && layer.type !== "raster") return layer; if (sourceRect) return { ...layer, sourceRect }; const uncropped = { ...layer }; delete uncropped.sourceRect; return uncropped; }) }; }, }; export const documentDuplicateLayerCommand: Command = { 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): 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 = { id: commandIds.documentRenameLayer, name: "Rename layer", execute({ state }, payload) { const name = payload.name.trim(); if (!name) return state; return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => ({ ...layer, name })) }; }, }; export const documentSetLayerClippingMaskCommand: Command = { id: commandIds.documentSetLayerClippingMask, name: "Set layer clipping mask", execute({ state }, payload) { if (payload.maskLayerId === payload.layerId) return state; if (!payload.maskLayerId) { const previousMaskId = getLayerMask(findLayerLocation(state.document, payload.layerId)?.layer)?.maskLayerId; return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => removeLayerMaskReference(layer)), editor: { ...state.editor, maskEdit: previousMaskId && isMaskEditFor(state.editor.maskEdit, payload.layerId, previousMaskId) ? undefined : state.editor.maskEdit, }, }; } const targetLocation = findLayerLocation(state.document, payload.layerId); const maskLocation = findLayerLocation(state.document, payload.maskLayerId); if (!targetLocation || !maskLocation) return state; if (targetLocation.layer.type === "adjustment" || targetLocation.layer.type === "text") return state; if (targetLocation.artboardId !== maskLocation.artboardId || targetLocation.parentGroupId !== maskLocation.parentGroupId) return state; const removed = removeLayerFromDocument(state.document, payload.layerId); if (!removed.layer) return state; const maskLocationAfterRemoval = findLayerLocation(removed.document, payload.maskLayerId); if (!maskLocationAfterRemoval) return state; return { ...state, document: insertLayer( removed.document, maskLocationAfterRemoval.artboardId, maskLocationAfterRemoval.parentGroupId, withLayerMask(removed.layer, payload.maskLayerId), maskLocationAfterRemoval.index + 1, ), }; }, }; export const documentAddLayerMaskCommand: Command = { id: commandIds.documentAddLayerMask, name: "Add layer mask", execute({ state }, payload) { const targetLocation = findLayerLocation(state.document, payload.layerId); if (!targetLocation || (targetLocation.layer.type !== "image" && targetLocation.layer.type !== "raster")) return state; const existingMaskId = getLayerMask(targetLocation.layer)?.maskLayerId; if (existingMaskId) { const existingMaskLocation = findLayerLocation(state.document, existingMaskId); const existingMaskLayer = existingMaskLocation?.layer; if (existingMaskLayer && existingMaskLayer.type !== "image" && existingMaskLayer.type !== "raster") return state; if (existingMaskLocation && existingMaskLayer) { return { ...state, editor: { ...state.editor, selection: { artboardId: targetLocation.artboardId, layerIds: [payload.layerId] }, maskEdit: { kind: "layerMask", targetLayerId: payload.layerId, maskLayerId: existingMaskId, maskAssetId: existingMaskLayer.assetId }, tools: { ...state.editor.tools, activeTool: payload.activeTool ?? "brush", interactionMode: { type: "tool", tool: payload.activeTool ?? "brush" }, }, }, }; } } if (payload.maskLayer.id === payload.layerId) return state; if (state.document.assets.some((asset) => asset.id === payload.asset.id)) return state; if (findLayerLocation(state.document, payload.maskLayer.id)) return state; const maskLayer: RasterLayer = { ...payload.maskLayer, visible: true, locked: false, opacity: 1, layerMask: undefined, clippingMask: undefined, }; const withAsset: ImageDocument = { ...state.document, assets: [...state.document.assets, payload.asset] }; const withMaskLayer = insertLayer(withAsset, targetLocation.artboardId, targetLocation.parentGroupId, maskLayer, targetLocation.index); const document = mapLayerInDocument(withMaskLayer, payload.layerId, (layer) => withLayerMask(layer, maskLayer.id)); return { ...state, document, editor: { ...state.editor, selection: { artboardId: targetLocation.artboardId, layerIds: [payload.layerId] }, maskEdit: { kind: "layerMask", targetLayerId: payload.layerId, maskLayerId: maskLayer.id, maskAssetId: payload.asset.id }, tools: { ...state.editor.tools, activeTool: payload.activeTool ?? "brush", interactionMode: { type: "tool", tool: payload.activeTool ?? "brush" }, }, }, }; }, }; export const documentApplyLayerMaskOperationCommand: Command = { id: commandIds.documentApplyLayerMaskOperation, name: "Apply layer mask operation", execute({ state }, payload) { if (!payload.source.trim()) return state; const maskLocation = findLayerLocation(state.document, payload.maskLayerId); if (!maskLocation || (maskLocation.layer.type !== "image" && maskLocation.layer.type !== "raster")) return state; if (!isReferencedMaskLayer(state.document, payload.maskLayerId)) return state; const maskAssetId = maskLocation.layer.assetId; return { ...state, document: { ...state.document, assets: state.document.assets.map((asset) => asset.id === maskAssetId ? { ...asset, source: payload.source, mimeType: payload.mimeType ?? asset.mimeType, } : asset, ), }, editor: { ...state.editor, brushStrokePreview: state.editor.brushStrokePreview?.assetId === maskAssetId ? undefined : state.editor.brushStrokePreview, }, }; }, }; export const documentRemoveLayerMaskCommand: Command = { id: commandIds.documentRemoveLayerMask, name: "Remove layer mask", execute({ state }, payload) { const targetLocation = findLayerLocation(state.document, payload.layerId); const maskLayerId = targetLocation?.layer ? getLayerMask(targetLocation.layer)?.maskLayerId : undefined; if (!targetLocation || !maskLayerId) { return state.editor.maskEdit?.targetLayerId === payload.layerId ? { ...state, editor: { ...state.editor, maskEdit: undefined } } : state; } const unmasked = mapLayerInDocument(state.document, payload.layerId, (layer) => removeLayerMaskReference(layer)); const document = removeUnreferencedMaskLayer(unmasked, maskLayerId); return { ...state, document, editor: { ...state.editor, selection: { artboardId: targetLocation.artboardId, layerIds: [payload.layerId] }, maskEdit: isMaskEditFor(state.editor.maskEdit, payload.layerId, maskLayerId) ? undefined : state.editor.maskEdit, }, }; }, }; export const documentRemoveLayerCommand: Command = { id: commandIds.documentRemoveLayer, name: "Remove layer", execute({ state }, payload) { const removed = removeLayerFromDocument(state.document, payload.layerId); if (!removed.layer) return state; const removedLayerIds = collectLayerIds(removed.layer); const removedMaskLayerIds = collectClippingMaskIds([removed.layer]); const cleanedReferences = removeMissingMaskReferences(removed.document); const withoutMasks = [...removedMaskLayerIds].reduce((nextDocument, maskLayerId) => removeUnreferencedMaskLayer(nextDocument, maskLayerId), cleanedReferences); const document = removeInpaintRegionsForTargets(withoutMasks, removedLayerIds); const selection = { ...state.editor.selection, layerIds: state.editor.selection.layerIds.filter((id) => !removedLayerIds.has(id)), }; return { ...state, document, editor: { ...state.editor, selection, maskEdit: isMaskEditValid(state.editor.maskEdit, document) ? state.editor.maskEdit : undefined, }, }; }, }; export const documentCommands = [ documentAddArtboardCommand, documentSetArtboardBoundsCommand, documentResizeArtboardCommand, documentRemoveArtboardCommand, documentSetArtboardVisibleCommand, documentSetArtboardLockedCommand, documentRenameArtboardCommand, documentAddAssetCommand, documentUpdateAssetSourceCommand, documentAddImageLayerCommand, documentAddRasterLayerCommand, documentAddGroupLayerCommand, documentAddAdjustmentLayerCommand, documentAddTextLayerCommand, documentSetTextLayerCommand, documentSetAdjustmentCommand, documentMoveLayerCommand, documentGroupLayersCommand, documentUngroupLayerCommand, documentRemoveLayerCommand, documentSetLayerVisibleCommand, documentSetLayerLockedCommand, documentSetLayerOpacityCommand, documentSetLayerSourceRectCommand, documentDuplicateLayerCommand, documentRenameLayerCommand, documentSetLayerClippingMaskCommand, documentAddLayerMaskCommand, documentApplyLayerMaskOperationCommand, documentRemoveLayerMaskCommand, ] satisfies Command[]; 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)); const y = Math.max(0, Math.min(height - 1, rect.y)); const w = Math.min(width - x, rect.w); const h = Math.min(height - y, rect.h); return w >= 1 && h >= 1 ? { x, y, w, h } : undefined; } function scaleLayerTree(layers: Layer[], before: Rect, after: Rect, scaleX: number, scaleY: number): Layer[] { return layers.map((layer) => layer.type === "adjustment" ? layer : layer.type === "group" ? { ...layer, children: scaleLayerTree(layer.children, before, after, scaleX, scaleY), } : { ...layer, transform: { ...layer.transform, position: { x: after.x + (layer.transform.position.x - before.x) * scaleX, y: after.y + (layer.transform.position.y - before.y) * scaleY, }, scale: { x: layer.transform.scale.x * scaleX, y: layer.transform.scale.y * scaleY }, }, }); }