feat: enhance layer mask handling and opacity management

- Introduced `getLayerMask` utility to streamline layer mask retrieval.
- Updated layer rendering logic to incorporate layer masks and opacity adjustments.
- Added functionality to prevent dropping a group into its descendants.
- Enhanced image texture rendering to support opacity parameters across various rendering functions.
- Implemented group layer bounds application for better scaling and positioning.
- Added tests to ensure correct behavior when handling layer masks and group layers.
- Created new types for asset generation provenance to track generated assets more effectively.
This commit is contained in:
syntaxbullet
2026-07-05 16:06:23 +02:00
parent a83024c35c
commit 5eaf37ba28
33 changed files with 635 additions and 88 deletions

View File

@@ -4,6 +4,7 @@ import type { Rect } from "@core/geometry";
import type { ArtboardId, AssetId, LayerId } from "@core/id";
import type { ImageLayer } from "@core/image-layer";
import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import type { RasterLayer } from "@core/raster-layer";
import type { LayerGroup } from "@core/layer-group";
import type { Command } from "./command";
@@ -316,7 +317,7 @@ export const documentMoveLayerCommand: Command<DocumentMoveLayerPayload> = {
const removed = removeLayerFromDocument(state.document, payload.layerId);
if (!removed.layer) return state;
const maskLayerId = removed.layer.clippingMask?.maskLayerId;
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;
@@ -338,25 +339,29 @@ export const documentGroupLayersCommand: Command<DocumentGroupLayersPayload> = {
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 artboard = state.document.artboards.find((candidate) => candidate.id === payload.artboardId);
if (!artboard) return state;
const requestedLocations = requestedIds.flatMap((layerId) => {
const location = findLayerLocation(state.document, layerId);
return location ? [location] : [];
});
if (requestedLocations.length !== requestedIds.length) return state;
const uniqueIds = [...new Set([...requestedIds, ...collectAttachedMaskIds(artboard.layers, requestedIds)])];
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 selected = artboard.layers.filter((layer) => uniqueIds.includes(layer.id));
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 firstIndex = artboard.layers.findIndex((layer) => layer.id === selected[0]?.id);
const group: LayerGroup = { ...payload.group, children: selected };
const document = {
...state.document,
artboards: state.document.artboards.map((candidate) =>
candidate.id === payload.artboardId
? { ...candidate, layers: [...candidate.layers.filter((layer) => !uniqueIds.includes(layer.id)).slice(0, firstIndex), group, ...candidate.layers.filter((layer) => !uniqueIds.includes(layer.id)).slice(firstIndex)] }
: candidate,
),
};
const document = replaceLayerListInDocument(
state.document,
firstLocation.artboardId,
firstLocation.parentGroupId,
replaceSelectedLayersWithGroup(firstLocation.siblings, uniqueIds, group),
);
return {
...state,
@@ -414,7 +419,7 @@ export const documentSetLayerClippingMaskCommand: Command<DocumentSetLayerClippi
if (payload.maskLayerId === payload.layerId) return state;
if (!payload.maskLayerId) {
const previousMaskId = findLayerLocation(state.document, payload.layerId)?.layer.clippingMask?.maskLayerId;
const previousMaskId = getLayerMask(findLayerLocation(state.document, payload.layerId)?.layer)?.maskLayerId;
return {
...state,
document: mapLayerInDocument(state.document, payload.layerId, (layer) => removeLayerMaskReference(layer)),
@@ -442,7 +447,7 @@ export const documentSetLayerClippingMaskCommand: Command<DocumentSetLayerClippi
removed.document,
maskLocationAfterRemoval.artboardId,
maskLocationAfterRemoval.parentGroupId,
{ ...removed.layer, clippingMask: { maskLayerId: payload.maskLayerId } },
withLayerMask(removed.layer, payload.maskLayerId),
maskLocationAfterRemoval.index + 1,
),
};
@@ -456,7 +461,7 @@ export const documentAddLayerMaskCommand: Command<DocumentAddLayerMaskPayload> =
const targetLocation = findLayerLocation(state.document, payload.layerId);
if (!targetLocation || targetLocation.layer.type === "group") return state;
const existingMaskId = targetLocation.layer.clippingMask?.maskLayerId;
const existingMaskId = getLayerMask(targetLocation.layer)?.maskLayerId;
if (existingMaskId) {
const existingMaskLocation = findLayerLocation(state.document, existingMaskId);
if (existingMaskLocation?.layer.type === "group") return state;
@@ -486,11 +491,12 @@ export const documentAddLayerMaskCommand: Command<DocumentAddLayerMaskPayload> =
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) => ({ ...layer, clippingMask: { maskLayerId: maskLayer.id } }));
const document = mapLayerInDocument(withMaskLayer, payload.layerId, (layer) => withLayerMask(layer, maskLayer.id));
return {
...state,
@@ -546,7 +552,7 @@ export const documentRemoveLayerMaskCommand: Command<DocumentRemoveLayerMaskPayl
name: "Remove layer mask",
execute({ state }, payload) {
const targetLocation = findLayerLocation(state.document, payload.layerId);
const maskLayerId = targetLocation?.layer.clippingMask?.maskLayerId;
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;
}
@@ -624,6 +630,7 @@ type LayerLocation = {
parentGroupId?: LayerId;
index: number;
layer: Layer;
siblings: readonly Layer[];
};
function findLayerLocation(document: ImageDocument, layerId: LayerId): LayerLocation | undefined {
@@ -640,7 +647,7 @@ function isReferencedMaskLayer(document: ImageDocument, maskLayerId: LayerId): b
function isReferencedMaskLayerInTree(layers: readonly Layer[], maskLayerId: LayerId): boolean {
for (const layer of layers) {
if (layer.clippingMask?.maskLayerId === maskLayerId) return true;
if (getLayerMask(layer)?.maskLayerId === maskLayerId) return true;
if (layer.type === "group" && isReferencedMaskLayerInTree(layer.children, maskLayerId)) return true;
}
return false;
@@ -650,7 +657,7 @@ function findLayerLocationInTree(layers: Layer[], layerId: LayerId, artboardId:
for (let index = 0; index < layers.length; index++) {
const layer = layers[index];
if (!layer) continue;
if (layer.id === layerId) return { artboardId, parentGroupId, index, layer };
if (layer.id === layerId) return { artboardId, parentGroupId, index, layer, siblings: layers };
if (layer.type === "group") {
const child = findLayerLocationInTree(layer.children, layerId, artboardId, layer.id);
if (child) return child;
@@ -693,6 +700,41 @@ function insertLayerInGroup(layers: Layer[], groupId: LayerId, layer: Layer, ind
});
}
function replaceLayerListInDocument(document: ImageDocument, artboardId: ArtboardId, parentGroupId: LayerId | undefined, layers: Layer[]): ImageDocument {
return {
...document,
artboards: document.artboards.map((artboard) => {
if (artboard.id !== artboardId) return artboard;
if (!parentGroupId) return { ...artboard, layers };
return { ...artboard, layers: replaceLayerListInGroup(artboard.layers, parentGroupId, layers) };
}),
};
}
function replaceLayerListInGroup(layers: Layer[], groupId: LayerId, children: Layer[]): Layer[] {
return layers.map((layer) => {
if (layer.type === "group" && layer.id === groupId) return { ...layer, children };
if (layer.type === "group") return { ...layer, children: replaceLayerListInGroup(layer.children, groupId, children) };
return layer;
});
}
function replaceSelectedLayersWithGroup(layers: readonly Layer[], selectedLayerIds: ReadonlySet<LayerId>, group: LayerGroup): Layer[] {
const next: Layer[] = [];
let inserted = false;
for (const layer of layers) {
if (!selectedLayerIds.has(layer.id)) {
next.push(layer);
continue;
}
if (!inserted) {
next.push(group);
inserted = true;
}
}
return next;
}
function removeLayerFromDocument(document: ImageDocument, layerId: LayerId): { document: ImageDocument; layer?: Layer } {
let removed: Layer | undefined;
return {
@@ -785,10 +827,24 @@ function findGroupInTree(layers: Layer[], groupId: LayerId): LayerGroup | undefi
function removeLayerMaskReference(layer: Layer): Layer {
const next = { ...layer };
delete next.layerMask;
delete next.clippingMask;
return next;
}
function withLayerMask(layer: Layer, maskLayerId: LayerId): Layer {
return {
...layer,
layerMask: {
kind: "raster",
maskLayerId,
enabled: true,
inverted: false,
},
clippingMask: { maskLayerId },
};
}
function removeUnreferencedMaskLayer(document: ImageDocument, maskLayerId: LayerId): ImageDocument {
if (isMaskLayerReferenced(document, maskLayerId)) return document;
return removeLayerFromDocument(document, maskLayerId).document;
@@ -801,7 +857,8 @@ function isMaskLayerReferenced(document: ImageDocument, maskLayerId: LayerId): b
function removeMissingMaskReferences(document: ImageDocument): ImageDocument {
const existingLayerIds = collectDocumentLayerIds(document);
return mapAllLayersInDocument(document, (layer) => {
if (!layer.clippingMask || existingLayerIds.has(layer.clippingMask.maskLayerId)) return layer;
const mask = getLayerMask(layer);
if (!mask || existingLayerIds.has(mask.maskLayerId)) return layer;
return removeLayerMaskReference(layer);
});
}
@@ -814,7 +871,7 @@ function isMaskEditValid(maskEdit: { targetLayerId: LayerId; maskLayerId: LayerI
if (!maskEdit) return false;
const target = findLayerLocation(document, maskEdit.targetLayerId)?.layer;
const mask = findLayerLocation(document, maskEdit.maskLayerId)?.layer;
return Boolean(target?.clippingMask?.maskLayerId === maskEdit.maskLayerId && mask && mask.type !== "group");
return Boolean(target && getLayerMask(target)?.maskLayerId === maskEdit.maskLayerId && mask && mask.type !== "group");
}
function collectDocumentLayerIds(document: ImageDocument): Set<LayerId> {
@@ -836,7 +893,8 @@ function collectLayerIdsFromTree(layers: readonly Layer[], ids = new Set<LayerId
function collectClippingMaskIds(layers: readonly Layer[], ids = new Set<LayerId>()): Set<LayerId> {
for (const layer of layers) {
if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId);
const mask = getLayerMask(layer);
if (mask) ids.add(mask.maskLayerId);
if (layer.type === "group") collectClippingMaskIds(layer.children, ids);
}
return ids;
@@ -844,7 +902,10 @@ function collectClippingMaskIds(layers: readonly Layer[], ids = new Set<LayerId>
function collectAttachedMaskIds(layers: readonly Layer[], layerIds: readonly LayerId[]): LayerId[] {
const layerIdSet = new Set(layerIds);
return layers.flatMap((layer) => (layerIdSet.has(layer.id) && layer.clippingMask ? [layer.clippingMask.maskLayerId] : []));
return layers.flatMap((layer) => {
const mask = getLayerMask(layer);
return layerIdSet.has(layer.id) && mask ? [mask.maskLayerId] : [];
});
}
function mapAllLayersInDocument(document: ImageDocument, mapLayer: (layer: Layer) => Layer): ImageDocument {