Files
image-studio/commands/document.ts
2026-07-11 12:07:36 +02:00

691 lines
24 KiB
TypeScript

import { findLayerLocation, isReferencedMaskLayer, mapLayerInDocument, insertLayer, replaceLayerListInDocument, replaceSelectedLayersWithGroup, removeLayerFromDocument, ungroupLayerInDocument, findGroup, removeLayerMaskReference, withLayerMask, removeUnreferencedMaskLayer, removeMissingMaskReferences, isMaskEditFor, isMaskEditValid, collectLayerIds, collectClippingMaskIds, collectAttachedMaskIds } 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 { 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 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 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 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 documentRemoveArtboardCommand: Command<DocumentRemoveArtboardPayload> = {
id: commandIds.documentRemoveArtboard,
name: "Remove artboard",
execute({ state }, payload) {
const removedSelectedArtboard = state.editor.selection.artboardId === payload.id;
const document = {
...state.document,
artboards: state.document.artboards.filter((artboard) => artboard.id !== payload.id),
};
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 documentMoveLayerCommand: Command<DocumentMoveLayerPayload> = {
id: commandIds.documentMoveLayer,
name: "Move layer",
execute({ state }, payload) {
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.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 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.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 === "group") return state;
const existingMaskId = getLayerMask(targetLocation.layer)?.maskLayerId;
if (existingMaskId) {
const existingMaskLocation = findLayerLocation(state.document, existingMaskId);
if (existingMaskLocation?.layer.type === "group") return state;
if (existingMaskLocation) {
return {
...state,
editor: {
...state.editor,
selection: { artboardId: targetLocation.artboardId, layerIds: [payload.layerId] },
maskEdit: { targetLayerId: payload.layerId, maskLayerId: existingMaskId },
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: { targetLayerId: payload.layerId, maskLayerId: maskLayer.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 === "group") 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 document = [...removedMaskLayerIds].reduce((nextDocument, maskLayerId) => removeUnreferencedMaskLayer(nextDocument, maskLayerId), cleanedReferences);
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,
documentRemoveArtboardCommand,
documentSetArtboardVisibleCommand,
documentSetArtboardLockedCommand,
documentRenameArtboardCommand,
documentAddAssetCommand,
documentUpdateAssetSourceCommand,
documentAddImageLayerCommand,
documentAddRasterLayerCommand,
documentAddGroupLayerCommand,
documentMoveLayerCommand,
documentGroupLayersCommand,
documentUngroupLayerCommand,
documentRemoveLayerCommand,
documentSetLayerVisibleCommand,
documentSetLayerLockedCommand,
documentSetLayerOpacityCommand,
documentDuplicateLayerCommand,
documentRenameLayerCommand,
documentSetLayerClippingMaskCommand,
documentAddLayerMaskCommand,
documentApplyLayerMaskOperationCommand,
documentRemoveLayerMaskCommand,
] satisfies Command<unknown>[];