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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user