- Enhanced cursor behavior for new tools: semantic select, mask lasso, and mask rectangle. - Updated mask edit state to include mask asset ID and kind. - Implemented inpaint region commands for adding, applying, and removing inpaint regions. - Introduced new operations for lasso and semantic selection tools. - Created UI components for candidate review and inpaint region management. - Added tests for inpaint region commands to ensure functionality. - Updated various components to support new inpaint features and improve user experience.
842 lines
32 KiB
TypeScript
842 lines
32 KiB
TypeScript
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<LayerId, LayerId>;
|
|
};
|
|
|
|
export type DocumentRenameLayerPayload = {
|
|
layerId: LayerId;
|
|
name: string;
|
|
};
|
|
|
|
export type DocumentSetLayerClippingMaskPayload = {
|
|
layerId: LayerId;
|
|
maskLayerId?: LayerId;
|
|
};
|
|
|
|
export type DocumentAddLayerMaskPayload = {
|
|
layerId: LayerId;
|
|
asset: Asset;
|
|
maskLayer: RasterLayer;
|
|
};
|
|
|
|
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<DocumentAddArtboardPayload> = {
|
|
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<DocumentSetArtboardBoundsPayload> = {
|
|
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<DocumentResizeArtboardPayload> = {
|
|
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<DocumentRemoveArtboardPayload> = {
|
|
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<LayerId>();
|
|
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<DocumentSetArtboardVisiblePayload> = {
|
|
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<DocumentSetArtboardLockedPayload> = {
|
|
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<DocumentRenameArtboardPayload> = {
|
|
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<DocumentAddAssetPayload> = {
|
|
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<DocumentUpdateAssetSourcePayload> = {
|
|
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<DocumentAddImageLayerPayload> = {
|
|
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<DocumentAddRasterLayerPayload> = {
|
|
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<DocumentAddGroupLayerPayload> = {
|
|
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<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 documentAddTextLayerCommand: Command<DocumentAddTextLayerPayload> = {
|
|
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<DocumentSetTextLayerPayload> = {
|
|
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<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);
|
|
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<DocumentGroupLayersPayload> = {
|
|
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<DocumentUngroupLayerPayload> = {
|
|
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<DocumentSetLayerVisiblePayload> = {
|
|
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<DocumentSetLayerLockedPayload> = {
|
|
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<DocumentSetLayerOpacityPayload> = {
|
|
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<DocumentSetLayerSourceRectPayload> = {
|
|
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<DocumentDuplicateLayerPayload> = {
|
|
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<LayerId, LayerId>): 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<DocumentRenameLayerPayload> = {
|
|
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<DocumentSetLayerClippingMaskPayload> = {
|
|
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<DocumentAddLayerMaskPayload> = {
|
|
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: "brush",
|
|
interactionMode: { type: "tool", tool: "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: "brush",
|
|
interactionMode: { type: "tool", tool: "brush" },
|
|
},
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
export const documentApplyLayerMaskOperationCommand: Command<DocumentApplyLayerMaskOperationPayload> = {
|
|
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<DocumentRemoveLayerMaskPayload> = {
|
|
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<DocumentRemoveLayerPayload> = {
|
|
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<unknown>[];
|
|
|
|
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 },
|
|
},
|
|
});
|
|
}
|