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

@@ -154,6 +154,35 @@ describe("document commands", () => {
expect(ungrouped.editor.selection.layerIds).toEqual(["a", "b"]);
});
test("groups nested sibling layers in place", () => {
const state = documentWithLayers([{ ...group("parent", "Parent"), children: [group("a", "A"), group("b", "B"), group("c", "C")] }]);
const grouped = documentGroupLayersCommand.execute({ state }, { artboardId: "a1", layerIds: ["a", "b"], group: group("g", "Group") });
const parent = grouped.document.artboards[0]?.layers[0];
const nestedGroup = parent?.type === "group" ? parent.children[0] : undefined;
expect(parent?.type).toBe("group");
expect(parent?.type === "group" ? parent.children.map((layer) => layer.id) : []).toEqual(["g", "c"]);
expect(nestedGroup?.type === "group" ? nestedGroup.children.map((layer) => layer.id) : []).toEqual(["a", "b"]);
expect(grouped.editor.selection).toEqual({ artboardId: "a1", layerIds: ["g"] });
});
test("does not group layers from different parents", () => {
const state = documentWithLayers([group("a", "A"), { ...group("parent", "Parent"), children: [group("b", "B")] }]);
const grouped = documentGroupLayersCommand.execute({ state }, { artboardId: "a1", layerIds: ["a", "b"], group: group("g", "Group") });
expect(grouped).toBe(state);
});
test("does not move groups into their own descendants", () => {
const state = documentWithLayers([{ ...group("parent", "Parent"), children: [group("child", "Child")] }]);
const moved = documentMoveLayerCommand.execute({ state }, { layerId: "parent", toArtboardId: "a1", toParentGroupId: "child", toIndex: 0 });
expect(moved).toBe(state);
});
test("removes layers", () => {
const state = documentWithLayers([group("a", "A"), group("b", "B")]);
const selectedState = { ...state, editor: { ...state.editor, selection: { artboardId: "a1", layerIds: ["a"] } } };
@@ -219,6 +248,12 @@ describe("document commands", () => {
expect(masked.document.assets).toContainEqual(maskAsset());
expect(masked.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["mask", "target"]);
expect(masked.document.artboards[0]?.layers[1]?.layerMask).toEqual({
kind: "raster",
maskLayerId: "mask",
enabled: true,
inverted: false,
});
expect(masked.document.artboards[0]?.layers[1]?.clippingMask).toEqual({ maskLayerId: "mask" });
expect(masked.editor.maskEdit).toEqual({ targetLayerId: "target", maskLayerId: "mask" });
expect(masked.editor.tools.activeTool).toBe("brush");
@@ -233,6 +268,7 @@ describe("document commands", () => {
const unmasked = documentRemoveLayerMaskCommand.execute({ state }, { layerId: "target" });
expect(unmasked.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["target"]);
expect(unmasked.document.artboards[0]?.layers[0]?.layerMask).toBeUndefined();
expect(unmasked.document.artboards[0]?.layers[0]?.clippingMask).toBeUndefined();
expect(unmasked.editor.maskEdit).toBeUndefined();
});

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 {

View File

@@ -6,6 +6,7 @@ import {
generationApplyCandidateAsLayerCommand,
generationRemoveCandidateCommand,
generationReplaceCandidatePixelsCommand,
generationSetCompareModeCommand,
} from "./generation";
describe("generation commands", () => {
@@ -18,8 +19,20 @@ describe("generation commands", () => {
expect(added.editor.generation.candidates).toEqual([candidate]);
expect(added.editor.generation.selectedCandidateId).toBe(candidate.id);
expect(added.editor.generation.compareMode).toBe("result");
expect(removed.editor.generation.candidates).toEqual([]);
expect(removed.editor.generation.selectedCandidateId).toBeUndefined();
expect(removed.editor.generation.compareMode).toBe("result");
});
test("sets generation compare mode without touching candidates", () => {
const state = generationAddCandidateCommand.execute({ state: createInitialAppState("Test") }, { candidate: generationCandidate("candidate-1") });
const next = generationSetCompareModeCommand.execute({ state }, { mode: "split" });
expect(next.editor.generation.candidates).toEqual([generationCandidate("candidate-1")]);
expect(next.editor.generation.selectedCandidateId).toBe("candidate-1");
expect(next.editor.generation.compareMode).toBe("split");
});
test("applies candidates as top-level layers", () => {
@@ -31,9 +44,15 @@ describe("generation commands", () => {
);
expect(next.document.assets.find((asset) => asset.id === "generated-asset")?.source).toBe("generated-source");
expect(next.document.assets.find((asset) => asset.id === "generated-asset")?.provenance).toMatchObject({
kind: "generated",
candidateId: "candidate-1",
acceptance: "layer",
seed: 123,
});
expect(next.document.artboards[0]?.layers[0]?.id).toBe("generated-layer");
expect(next.editor.selection).toEqual({ artboardId: "a1", layerIds: ["generated-layer"] });
expect(next.editor.generation).toEqual({ candidates: [], selectedCandidateId: undefined });
expect(next.editor.generation).toEqual({ candidates: [], selectedCandidateId: undefined, compareMode: "result" });
});
test("replaces source asset pixels for inpaint candidates", () => {
@@ -46,8 +65,17 @@ describe("generation commands", () => {
expect(next.document.assets.find((asset) => asset.id === "source-asset")?.source).toBe("composited-source");
expect(next.document.assets.find((asset) => asset.id === "source-asset")?.mimeType).toBe("image/png");
expect(next.document.assets.find((asset) => asset.id === "source-asset")?.provenance).toMatchObject({
kind: "generated",
candidateId: "candidate-1",
acceptance: "replacement",
inpaint: {
sourceAssetId: "source-asset",
maskAssetId: "mask-asset",
},
});
expect(next.editor.selection).toEqual({ artboardId: "a1", layerIds: ["source-layer"] });
expect(next.editor.generation).toEqual({ candidates: [], selectedCandidateId: undefined });
expect(next.editor.generation).toEqual({ candidates: [], selectedCandidateId: undefined, compareMode: "result" });
});
});

View File

@@ -1,9 +1,10 @@
import type { Asset } from "@core/asset";
import type { AssetGenerationProvenance, GeneratedAssetAcceptance } from "@core/asset-provenance";
import type { ImageDocument } from "@core/document";
import type { ArtboardId, AssetId, LayerId } from "@core/id";
import type { ImageLayer } from "@core/image-layer";
import type { Layer } from "@core/layer";
import type { GenerationCandidate, GenerationState } from "@editor/state";
import type { GenerationCandidate, GenerationCompareMode, GenerationState } from "@editor/state";
import type { Command } from "./command";
import { commandIds } from "./ids";
@@ -15,6 +16,10 @@ export type GenerationSelectCandidatePayload = {
candidateId?: string;
};
export type GenerationSetCompareModePayload = {
mode: GenerationCompareMode;
};
export type GenerationRemoveCandidatePayload = {
candidateId: string;
};
@@ -33,6 +38,7 @@ export type GenerationReplaceCandidatePixelsPayload = {
};
const maxCandidates = 12;
const generationCompareModes = new Set<GenerationCompareMode>(["result", "before", "split"]);
export const generationAddCandidateCommand: Command<GenerationAddCandidatePayload> = {
id: commandIds.generationAddCandidate,
@@ -47,6 +53,7 @@ export const generationAddCandidateCommand: Command<GenerationAddCandidatePayloa
generation: {
candidates,
selectedCandidateId: payload.candidate.id,
compareMode: "result",
},
},
};
@@ -73,6 +80,26 @@ export const generationSelectCandidateCommand: Command<GenerationSelectCandidate
},
};
export const generationSetCompareModeCommand: Command<GenerationSetCompareModePayload> = {
id: commandIds.generationSetCompareMode,
name: "Set generation compare mode",
history: { mode: "ignore" },
execute({ state }, payload) {
if (!generationCompareModes.has(payload.mode)) return state;
if (state.editor.generation.compareMode === payload.mode) return state;
return {
...state,
editor: {
...state.editor,
generation: {
...state.editor.generation,
compareMode: payload.mode,
},
},
};
},
};
export const generationRemoveCandidateCommand: Command<GenerationRemoveCandidatePayload> = {
id: commandIds.generationRemoveCandidate,
name: "Remove generation candidate",
@@ -88,6 +115,7 @@ export const generationRemoveCandidateCommand: Command<GenerationRemoveCandidate
generation: {
candidates,
selectedCandidateId,
compareMode: candidates.length > 0 ? state.editor.generation.compareMode : "result",
},
},
};
@@ -99,12 +127,12 @@ export const generationClearCandidatesCommand: Command = {
name: "Clear generation candidates",
history: { mode: "ignore" },
execute({ state }) {
if (state.editor.generation.candidates.length === 0 && !state.editor.generation.selectedCandidateId) return state;
if (state.editor.generation.candidates.length === 0 && !state.editor.generation.selectedCandidateId && state.editor.generation.compareMode === "result") return state;
return {
...state,
editor: {
...state.editor,
generation: { candidates: [], selectedCandidateId: undefined },
generation: { candidates: [], selectedCandidateId: undefined, compareMode: "result" },
},
};
},
@@ -125,6 +153,7 @@ export const generationApplyCandidateAsLayerCommand: Command<GenerationApplyCand
mimeType: candidate.mimeType,
source: candidate.source,
intrinsicSize: { ...candidate.intrinsicSize },
provenance: generationProvenance(candidate, payload.variant ? "variant-layer" : "layer"),
};
const layer: ImageLayer = {
id: payload.layerId,
@@ -167,7 +196,16 @@ export const generationReplaceCandidatePixelsCommand: Command<GenerationReplaceC
...state,
document: {
...state.document,
assets: state.document.assets.map((asset) => asset.id === targetAsset.id ? { ...asset, source: payload.source, mimeType: payload.mimeType ?? asset.mimeType } : asset),
assets: state.document.assets.map((asset) =>
asset.id === targetAsset.id
? {
...asset,
source: payload.source,
mimeType: payload.mimeType ?? asset.mimeType,
provenance: generationProvenance(candidate, "replacement"),
}
: asset,
),
},
editor: {
...state.editor,
@@ -181,6 +219,7 @@ export const generationReplaceCandidatePixelsCommand: Command<GenerationReplaceC
export const generationCommands = [
generationAddCandidateCommand,
generationSelectCandidateCommand,
generationSetCompareModeCommand,
generationRemoveCandidateCommand,
generationClearCandidatesCommand,
generationApplyCandidateAsLayerCommand,
@@ -219,6 +258,58 @@ function findLayerInTree(layers: readonly Layer[], layerId: LayerId): Layer | un
}
function clearCommittedGenerationPreview(generation: GenerationState): GenerationState {
if (generation.candidates.length === 0 && !generation.selectedCandidateId) return generation;
return { candidates: [], selectedCandidateId: undefined };
if (generation.candidates.length === 0 && !generation.selectedCandidateId && generation.compareMode === "result") return generation;
return { candidates: [], selectedCandidateId: undefined, compareMode: "result" };
}
function generationProvenance(candidate: GenerationCandidate, acceptance: GeneratedAssetAcceptance): AssetGenerationProvenance {
return {
kind: "generated",
candidateId: candidate.id,
mode: candidate.mode,
acceptance,
prompt: candidate.settings.prompt,
negativePrompt: candidate.settings.negativePrompt,
seed: candidate.seed,
outputSize: { ...candidate.intrinsicSize },
settings: {
architecture: candidate.settings.architecture,
model: candidate.settings.model,
textEncoder: candidate.settings.textEncoder,
vae: candidate.settings.vae,
strength: candidate.settings.strength,
steps: candidate.settings.steps,
cfg: candidate.settings.cfg,
sampler: candidate.settings.sampler,
scheduler: candidate.settings.scheduler,
width: candidate.settings.width,
height: candidate.settings.height,
},
inpaint: candidate.inpaint
? {
targetLayerId: candidate.inpaint.targetLayerId,
maskLayerId: candidate.inpaint.maskLayerId,
sourceAssetId: candidate.inpaint.sourceAssetId,
maskAssetId: candidate.inpaint.maskAssetId,
crop: {
assetBounds: { ...candidate.inpaint.crop.assetBounds },
documentBounds: { ...candidate.inpaint.crop.documentBounds },
padding: candidate.inpaint.crop.padding,
maskedAreaOnly: candidate.inpaint.crop.maskedAreaOnly,
},
mask: {
polarity: candidate.inpaint.mask.polarity,
activeBounds: { ...candidate.inpaint.mask.activeBounds },
},
backend: {
growMaskBy: candidate.inpaint.backend.growMaskBy,
maskedContent: candidate.inpaint.backend.maskedContent,
maskBlur: candidate.inpaint.backend.maskBlur,
maskFeather: candidate.inpaint.backend.maskFeather,
maskExpand: candidate.inpaint.backend.maskExpand,
cropPadding: candidate.inpaint.backend.cropPadding,
},
}
: undefined,
};
}

View File

@@ -38,6 +38,7 @@ export const commandIds = {
toolExitTemporaryPan: "tool.exitTemporaryPan",
generationAddCandidate: "generation.addCandidate",
generationSelectCandidate: "generation.selectCandidate",
generationSetCompareMode: "generation.setCompareMode",
generationRemoveCandidate: "generation.removeCandidate",
generationClearCandidates: "generation.clearCandidates",
generationApplyCandidateAsLayer: "generation.applyCandidateAsLayer",

View File

@@ -58,6 +58,7 @@ export {
generationRemoveCandidateCommand,
generationReplaceCandidatePixelsCommand,
generationSelectCandidateCommand,
generationSetCompareModeCommand,
} from "./generation";
export type {
GenerationAddCandidatePayload,
@@ -65,6 +66,7 @@ export type {
GenerationRemoveCandidatePayload,
GenerationReplaceCandidatePixelsPayload,
GenerationSelectCandidatePayload,
GenerationSetCompareModePayload,
} from "./generation";
export type { CommandDispatcher, Dispatch } from "./dispatcher";
export type { CommandId, CommandPayloads } from "./payloads";

View File

@@ -29,6 +29,7 @@ import type {
GenerationRemoveCandidatePayload,
GenerationReplaceCandidatePixelsPayload,
GenerationSelectCandidatePayload,
GenerationSetCompareModePayload,
} from "./generation";
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
import type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
@@ -81,6 +82,7 @@ export type CommandPayloads = {
[commandIds.toolExitTemporaryPan]: void;
[commandIds.generationAddCandidate]: GenerationAddCandidatePayload;
[commandIds.generationSelectCandidate]: GenerationSelectCandidatePayload;
[commandIds.generationSetCompareMode]: GenerationSetCompareModePayload;
[commandIds.generationRemoveCandidate]: GenerationRemoveCandidatePayload;
[commandIds.generationClearCandidates]: void;
[commandIds.generationApplyCandidateAsLayer]: GenerationApplyCandidateAsLayerPayload;

View File

@@ -2,6 +2,7 @@ import type { ImageDocument } from "@core/document";
import type { Vec2D } from "@core/geometry";
import type { LayerId, ArtboardId, AssetId } from "@core/id";
import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import type { MaskViewMode } from "@editor/state";
import { generateArchitectureDefaults } from "@editor/tools";
import type { BrushSettings, ChromaKeySettings, GenerateSettings, MagicWandSettings, ToolId } from "@editor/tools";
@@ -253,7 +254,7 @@ export const toolEnterMaskEditCommand: Command<ToolEnterMaskEditPayload> = {
const targetLocation = findLayerLocation(state.document, payload.targetLayerId);
const maskLocation = findLayerLocation(state.document, payload.maskLayerId);
if (!targetLocation || !maskLocation) return state;
if (targetLocation.layer.clippingMask?.maskLayerId !== payload.maskLayerId) return state;
if (getLayerMask(targetLocation.layer)?.maskLayerId !== payload.maskLayerId) return state;
if (maskLocation.layer.type === "group") return state;
return {