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:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
56
core/asset-provenance.ts
Normal file
56
core/asset-provenance.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { Rect, Size } from "./geometry";
|
||||
import type { AssetId, LayerId } from "./id";
|
||||
|
||||
export type GeneratedAssetMode = "text-to-image" | "image-to-image" | "inpaint" | "outpaint";
|
||||
|
||||
export type GeneratedAssetAcceptance = "layer" | "variant-layer" | "replacement";
|
||||
|
||||
export type AssetGenerationProvenance = {
|
||||
kind: "generated";
|
||||
candidateId: string;
|
||||
mode: GeneratedAssetMode;
|
||||
acceptance: GeneratedAssetAcceptance;
|
||||
prompt: string;
|
||||
negativePrompt: string;
|
||||
seed: number;
|
||||
outputSize: Size;
|
||||
settings: {
|
||||
architecture: string;
|
||||
model: string;
|
||||
textEncoder: string;
|
||||
vae: string;
|
||||
strength: number;
|
||||
steps: number;
|
||||
cfg: number;
|
||||
sampler: string;
|
||||
scheduler: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
inpaint?: {
|
||||
targetLayerId: LayerId;
|
||||
maskLayerId: LayerId;
|
||||
sourceAssetId: AssetId;
|
||||
maskAssetId: AssetId;
|
||||
crop: {
|
||||
assetBounds: Rect;
|
||||
documentBounds: Rect;
|
||||
padding: number;
|
||||
maskedAreaOnly: boolean;
|
||||
};
|
||||
mask: {
|
||||
polarity: "hidden" | "revealed";
|
||||
activeBounds: Rect;
|
||||
};
|
||||
backend: {
|
||||
growMaskBy: number;
|
||||
maskedContent: string;
|
||||
maskBlur: number;
|
||||
maskFeather: number;
|
||||
maskExpand: number;
|
||||
cropPadding: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type AssetProvenance = AssetGenerationProvenance;
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Size } from "./geometry";
|
||||
import type { AssetId } from "./id";
|
||||
import type { AssetProvenance } from "./asset-provenance";
|
||||
|
||||
export type Asset = {
|
||||
id: AssetId;
|
||||
@@ -7,4 +8,5 @@ export type Asset = {
|
||||
mimeType: string;
|
||||
source: string;
|
||||
intrinsicSize: Size;
|
||||
provenance?: AssetProvenance;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Transform } from "./geometry";
|
||||
import type { LayerId } from "./id";
|
||||
import type { LayerMask } from "./layer-mask";
|
||||
|
||||
export type LayerClippingMask = {
|
||||
maskLayerId: LayerId;
|
||||
@@ -12,5 +13,7 @@ export type BaseLayer = {
|
||||
locked: boolean;
|
||||
opacity: number;
|
||||
transform: Transform;
|
||||
layerMask?: LayerMask;
|
||||
/** @deprecated Use layerMask. Kept readable for older documents. */
|
||||
clippingMask?: LayerClippingMask;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type { Artboard } from "./artboard";
|
||||
export type { Asset } from "./asset";
|
||||
export type { AssetGenerationProvenance, AssetProvenance, GeneratedAssetAcceptance, GeneratedAssetMode } from "./asset-provenance";
|
||||
export type { BaseLayer, LayerClippingMask } from "./base-layer";
|
||||
export type { ImageDocument } from "./document";
|
||||
export type {
|
||||
@@ -15,5 +16,7 @@ export type {
|
||||
export type { ArtboardId, AssetId, DocumentId, LayerId } from "./id";
|
||||
export type { ImageLayer } from "./image-layer";
|
||||
export type { Layer } from "./layer";
|
||||
export type { LayerMask } from "./layer-mask";
|
||||
export { getLayerMask, hasLayerMask } from "./layer-mask-utils";
|
||||
export type { LayerGroup } from "./layer-group";
|
||||
export type { RasterLayer } from "./raster-layer";
|
||||
|
||||
18
core/layer-mask-utils.ts
Normal file
18
core/layer-mask-utils.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Layer } from "./layer";
|
||||
import type { LayerMask } from "./layer-mask";
|
||||
|
||||
export function getLayerMask(layer: Layer | undefined): LayerMask | undefined {
|
||||
if (layer.layerMask) return layer.layerMask;
|
||||
if (!layer.clippingMask) return undefined;
|
||||
|
||||
return {
|
||||
kind: "raster",
|
||||
maskLayerId: layer.clippingMask.maskLayerId,
|
||||
enabled: true,
|
||||
inverted: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasLayerMask(layer: Layer): boolean {
|
||||
return Boolean(getLayerMask(layer));
|
||||
}
|
||||
8
core/layer-mask.ts
Normal file
8
core/layer-mask.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { LayerId } from "./id";
|
||||
|
||||
export type LayerMask = {
|
||||
kind: "raster";
|
||||
maskLayerId: LayerId;
|
||||
enabled: boolean;
|
||||
inverted: boolean;
|
||||
};
|
||||
@@ -22,7 +22,10 @@ const document: ImageDocument = {
|
||||
locked: false,
|
||||
layers: [
|
||||
raster("mask", "Mask", "asset-mask"),
|
||||
{ ...raster("target", "Target", "asset-target", { x: 10, y: 20 }, { x: 0.5, y: 0.5 }), clippingMask: { maskLayerId: "mask" } },
|
||||
{
|
||||
...raster("target", "Target", "asset-target", { x: 10, y: 20 }, { x: 0.5, y: 0.5 }),
|
||||
layerMask: { kind: "raster", maskLayerId: "mask", enabled: true, inverted: false },
|
||||
},
|
||||
group("group", "Group", [
|
||||
raster("nested-mask", "Nested Mask", "asset-mask"),
|
||||
{ ...raster("nested-target", "Nested Target", "asset-nested", { x: 80, y: 10 }, { x: 2, y: 3 }), clippingMask: { maskLayerId: "nested-mask" } },
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ImageDocument } from "@core/document";
|
||||
import type { Rect } from "@core/geometry";
|
||||
import type { ArtboardId, AssetId, LayerId } from "@core/id";
|
||||
import type { Layer } from "@core/layer";
|
||||
import { getLayerMask } from "@core/layer-mask-utils";
|
||||
|
||||
export type IndexedLayerInfo = {
|
||||
artboardId: ArtboardId;
|
||||
@@ -113,9 +114,10 @@ function indexLayerTree(options: {
|
||||
index,
|
||||
});
|
||||
|
||||
if (layer.clippingMask) {
|
||||
options.documentMaskLayerIds.add(layer.clippingMask.maskLayerId);
|
||||
layerListMaskLayerIds.add(layer.clippingMask.maskLayerId);
|
||||
const layerMask = getLayerMask(layer);
|
||||
if (layerMask) {
|
||||
options.documentMaskLayerIds.add(layerMask.maskLayerId);
|
||||
layerListMaskLayerIds.add(layerMask.maskLayerId);
|
||||
}
|
||||
|
||||
if (layer.type === "group") {
|
||||
|
||||
@@ -15,6 +15,7 @@ export const initialEditorState: EditorState = {
|
||||
generation: {
|
||||
candidates: [],
|
||||
selectedCandidateId: undefined,
|
||||
compareMode: "result",
|
||||
},
|
||||
transformSession: undefined,
|
||||
maskEdit: undefined,
|
||||
|
||||
@@ -79,9 +79,12 @@ export type GenerationCandidate = {
|
||||
};
|
||||
};
|
||||
|
||||
export type GenerationCompareMode = "result" | "before" | "split";
|
||||
|
||||
export type GenerationState = {
|
||||
candidates: GenerationCandidate[];
|
||||
selectedCandidateId?: string;
|
||||
compareMode: GenerationCompareMode;
|
||||
};
|
||||
|
||||
export type EditorState = {
|
||||
|
||||
@@ -44,6 +44,10 @@ describe("transform targets", () => {
|
||||
expect(resolveTransformTargetBounds(document, { type: "layer", id: "l1" })).toEqual({ x: 10, y: 20, w: 100, h: 200 });
|
||||
});
|
||||
|
||||
test("resolves group bounds from descendant layers", () => {
|
||||
expect(resolveTransformTargetBounds(groupDocument(), { type: "layer", id: "g1" })).toEqual({ x: 10, y: 20, w: 300, h: 200 });
|
||||
});
|
||||
|
||||
test("applies image layer bounds to transform", () => {
|
||||
const next = applyTransformTargetBounds(document, { type: "layer", id: "l1" }, { x: 30, y: 40, w: 400, h: 50 });
|
||||
const layer = next.artboards[0]?.layers[0];
|
||||
@@ -81,8 +85,49 @@ describe("transform targets", () => {
|
||||
expect(next.artboards[0]?.layers[1]?.transform).toEqual({ position: { x: 30, y: 40 }, scale: { x: 2, y: 0.5 }, rotation: 0 });
|
||||
});
|
||||
|
||||
test("applies group bounds to descendant layer transforms", () => {
|
||||
const next = applyTransformTargetBounds(groupDocument(), { type: "layer", id: "g1" }, { x: 20, y: 40, w: 600, h: 100 });
|
||||
const group = next.artboards[0]?.layers[0];
|
||||
const first = group?.type === "group" ? group.children[0] : undefined;
|
||||
const second = group?.type === "group" ? group.children[1] : undefined;
|
||||
|
||||
expect(first?.transform).toEqual({ position: { x: 20, y: 40 }, scale: { x: 1, y: 1 }, rotation: 0 });
|
||||
expect(second?.transform).toEqual({ position: { x: 420, y: 90 }, scale: { x: 1, y: 0.5 }, rotation: 0 });
|
||||
});
|
||||
|
||||
test("applies artboard bounds", () => {
|
||||
const next = applyTransformTargetBounds(document, { type: "artboard", id: "a1" }, { x: 10, y: 20, w: 200, h: 160 });
|
||||
expect(next.artboards[0]?.bounds).toEqual({ x: 10, y: 20, w: 200, h: 160 });
|
||||
});
|
||||
});
|
||||
|
||||
function groupDocument(): ImageDocument {
|
||||
return {
|
||||
...document,
|
||||
artboards: [
|
||||
{
|
||||
...document.artboards[0]!,
|
||||
layers: [
|
||||
{
|
||||
id: "g1",
|
||||
type: "group",
|
||||
name: "Group",
|
||||
visible: true,
|
||||
locked: false,
|
||||
opacity: 1,
|
||||
transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 },
|
||||
children: [
|
||||
document.artboards[0]!.layers[0]!,
|
||||
{
|
||||
...document.artboards[0]!.layers[0]!,
|
||||
id: "l2",
|
||||
name: "Second Layer",
|
||||
transform: { position: { x: 210, y: 120 }, scale: { x: 0.5, y: 1 }, rotation: 0 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { Rect } from "@core/geometry";
|
||||
import type { Layer } from "@core/layer";
|
||||
import { getLayerMask } from "@core/layer-mask-utils";
|
||||
import type { ArtboardId, LayerId } from "@core/id";
|
||||
import type { TransformTarget } from "./transform";
|
||||
|
||||
@@ -35,7 +36,11 @@ export function selectedTransformTarget(document: ImageDocument, selection: { ar
|
||||
|
||||
function applyLayerBounds(document: ImageDocument, layerId: LayerId, bounds: Rect): ImageDocument {
|
||||
const layer = findLayer(document, layerId);
|
||||
const targetLayerIds = layer?.clippingMask ? [layerId, layer.clippingMask.maskLayerId] : [layerId];
|
||||
if (!layer) return document;
|
||||
if (layer.type === "group") return applyGroupLayerBounds(document, layer.id, bounds);
|
||||
|
||||
const layerMask = getLayerMask(layer);
|
||||
const targetLayerIds = layerMask ? [layerId, layerMask.maskLayerId] : [layerId];
|
||||
|
||||
return targetLayerIds.reduce(
|
||||
(nextDocument, targetLayerId) => ({
|
||||
@@ -49,6 +54,67 @@ function applyLayerBounds(document: ImageDocument, layerId: LayerId, bounds: Rec
|
||||
);
|
||||
}
|
||||
|
||||
function applyGroupLayerBounds(document: ImageDocument, groupId: LayerId, bounds: Rect): ImageDocument {
|
||||
const group = findLayer(document, groupId);
|
||||
if (!group || group.type !== "group") return document;
|
||||
|
||||
const initialBounds = resolveLayerBounds(document, group);
|
||||
if (!initialBounds || initialBounds.w === 0 || initialBounds.h === 0) return document;
|
||||
|
||||
const scale = {
|
||||
x: bounds.w / initialBounds.w,
|
||||
y: bounds.h / initialBounds.h,
|
||||
};
|
||||
|
||||
return {
|
||||
...document,
|
||||
artboards: document.artboards.map((artboard) => ({
|
||||
...artboard,
|
||||
layers: applyGroupLayerBoundsInTree(document, artboard.layers, groupId, initialBounds, bounds, scale),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function applyGroupLayerBoundsInTree(document: ImageDocument, layers: Layer[], groupId: LayerId, initialBounds: Rect, bounds: Rect, scale: { x: number; y: number }): Layer[] {
|
||||
return layers.map((layer) => {
|
||||
if (layer.type === "group" && layer.id === groupId) {
|
||||
return {
|
||||
...layer,
|
||||
children: layer.children.map((child) => scaleLayerSubtree(document, child, initialBounds, bounds, scale)),
|
||||
};
|
||||
}
|
||||
if (layer.type === "group") return { ...layer, children: applyGroupLayerBoundsInTree(document, layer.children, groupId, initialBounds, bounds, scale) };
|
||||
return layer;
|
||||
});
|
||||
}
|
||||
|
||||
function scaleLayerSubtree(document: ImageDocument, layer: Layer, initialBounds: Rect, bounds: Rect, scale: { x: number; y: number }): Layer {
|
||||
if (layer.type === "group") {
|
||||
return {
|
||||
...layer,
|
||||
children: layer.children.map((child) => scaleLayerSubtree(document, child, initialBounds, bounds, scale)),
|
||||
};
|
||||
}
|
||||
|
||||
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||
if (!asset) return layer;
|
||||
|
||||
return {
|
||||
...layer,
|
||||
transform: {
|
||||
...layer.transform,
|
||||
position: {
|
||||
x: bounds.x + (layer.transform.position.x - initialBounds.x) * scale.x,
|
||||
y: bounds.y + (layer.transform.position.y - initialBounds.y) * scale.y,
|
||||
},
|
||||
scale: {
|
||||
x: layer.transform.scale.x * scale.x,
|
||||
y: layer.transform.scale.y * scale.y,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function applyLayerBoundsInTree(document: ImageDocument, layers: Layer[], layerId: LayerId, bounds: Rect): Layer[] {
|
||||
return layers.map((layer) => {
|
||||
if (layer.id === layerId && (layer.type === "image" || layer.type === "raster")) {
|
||||
|
||||
@@ -41,6 +41,10 @@ describe("layers panel input", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("does not resolve dropping a group into one of its descendants", () => {
|
||||
expect(resolveLayerDrop({ document, sourceLayerId: "g", target: { artboardId: "a1", layer: group("c") }, verticalRatio: 0.5 })).toBeUndefined();
|
||||
});
|
||||
|
||||
test("dispatches delete commands for selected layers", () => {
|
||||
const dispatched: unknown[] = [];
|
||||
const consumed = handleDeleteSelectionKey({
|
||||
|
||||
@@ -41,6 +41,7 @@ export function resolveLayerDrop(options: {
|
||||
const targetInfo = findLayerInfoInDocument(options.document, options.target.layer.id);
|
||||
const sourceInfo = findLayerInfoInDocument(options.document, options.sourceLayerId);
|
||||
if (!targetInfo || !sourceInfo || options.sourceLayerId === options.target.layer.id) return undefined;
|
||||
if (isDescendantLayer(sourceInfo.layer, options.target.layer.id)) return undefined;
|
||||
|
||||
const verticalRatio = Math.max(0, Math.min(1, options.verticalRatio));
|
||||
const dropIntoGroup = options.target.layer.type === "group" && verticalRatio >= 0.33 && verticalRatio <= 0.66;
|
||||
@@ -97,3 +98,11 @@ function findLayerInfo(layers: Layer[], layerId: LayerId, artboardId: ArtboardId
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isDescendantLayer(layer: Layer, descendantLayerId: LayerId): boolean {
|
||||
if (layer.type !== "group") return false;
|
||||
for (const child of layer.children) {
|
||||
if (child.id === descendantLayerId || isDescendantLayer(child, descendantLayerId)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { commandIds } from "@commands/ids";
|
||||
import type { Dispatch } from "@commands/dispatcher";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { Layer } from "@core/layer";
|
||||
import { getLayerMask } from "@core/layer-mask-utils";
|
||||
import { resolveTransformTargetBounds, viewportPointToDocumentPoint, type InputViewportState } from "./document-geometry";
|
||||
import type { PointerInputEvent } from "./pointer";
|
||||
|
||||
@@ -65,9 +66,9 @@ function findTopmostLayerInTreeAtPoint(document: ImageDocument, layers: Layer[],
|
||||
|
||||
function collectMaskLayerIds(layers: readonly Layer[], ids = new Set<string>()): Set<string> {
|
||||
for (const layer of layers) {
|
||||
if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId);
|
||||
const layerMask = getLayerMask(layer);
|
||||
if (layerMask) ids.add(layerMask.maskLayerId);
|
||||
if (layer.type === "group") collectMaskLayerIds(layer.children, ids);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@ export type MaskVisualizationMode = "blackWhite" | "alpha" | "hiddenOverlay";
|
||||
|
||||
export type ImageTextureRenderer = {
|
||||
syncAssets(assets: readonly Asset[]): void;
|
||||
render(asset: Asset, rect: ScreenRect, clipRect?: ScreenRect): boolean;
|
||||
renderMasked(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, clipRect?: ScreenRect): boolean;
|
||||
renderMaskRevealPreview(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, opacity: number, clipRect?: ScreenRect): boolean;
|
||||
render(asset: Asset, rect: ScreenRect, clipRect?: ScreenRect, opacity?: number): boolean;
|
||||
renderMasked(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, clipRect?: ScreenRect, opacity?: number): boolean;
|
||||
renderMaskRevealPreview(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, opacity: number, clipRect?: ScreenRect, layerOpacity?: number): boolean;
|
||||
renderMaskVisualization(maskAsset: Asset, maskRect: ScreenRect, mode: MaskVisualizationMode, color?: RgbaColor, clipRect?: ScreenRect): boolean;
|
||||
renderTinted(asset: Asset, rect: ScreenRect, color: RgbaColor, clipRect?: ScreenRect): boolean;
|
||||
renderTinted(asset: Asset, rect: ScreenRect, color: RgbaColor, clipRect?: ScreenRect, opacity?: number): boolean;
|
||||
dispose(): void;
|
||||
};
|
||||
|
||||
@@ -39,12 +39,14 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
||||
const positionLocation = gl.getAttribLocation(program, "a_position");
|
||||
const texCoordLocation = gl.getAttribLocation(program, "a_texCoord");
|
||||
const samplerLocation = gl.getUniformLocation(program, "u_image");
|
||||
const opacityLocation = gl.getUniformLocation(program, "u_opacity");
|
||||
const maskedProgram = createMaskedProgram(gl);
|
||||
const maskedPositionLocation = gl.getAttribLocation(maskedProgram, "a_position");
|
||||
const maskedTexCoordLocation = gl.getAttribLocation(maskedProgram, "a_texCoord");
|
||||
const maskedMaskTexCoordLocation = gl.getAttribLocation(maskedProgram, "a_maskTexCoord");
|
||||
const maskedSamplerLocation = gl.getUniformLocation(maskedProgram, "u_image");
|
||||
const maskedMaskSamplerLocation = gl.getUniformLocation(maskedProgram, "u_mask");
|
||||
const maskedOpacityLocation = gl.getUniformLocation(maskedProgram, "u_opacity");
|
||||
const maskRevealPreviewProgram = createMaskRevealPreviewProgram(gl);
|
||||
const maskRevealPreviewPositionLocation = gl.getAttribLocation(maskRevealPreviewProgram, "a_position");
|
||||
const maskRevealPreviewTexCoordLocation = gl.getAttribLocation(maskRevealPreviewProgram, "a_texCoord");
|
||||
@@ -69,8 +71,10 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
||||
!texCoordBuffer ||
|
||||
!maskTexCoordBuffer ||
|
||||
!samplerLocation ||
|
||||
!opacityLocation ||
|
||||
!maskedSamplerLocation ||
|
||||
!maskedMaskSamplerLocation ||
|
||||
!maskedOpacityLocation ||
|
||||
!maskRevealPreviewSamplerLocation ||
|
||||
!maskRevealPreviewMaskSamplerLocation ||
|
||||
!maskRevealPreviewOpacityLocation ||
|
||||
@@ -91,7 +95,9 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
||||
}
|
||||
}
|
||||
},
|
||||
render(asset, rect, clipRect) {
|
||||
render(asset, rect, clipRect, opacity = 1) {
|
||||
const clampedOpacity = clampOpacity(opacity);
|
||||
if (clampedOpacity <= 0) return true;
|
||||
const drawRect = clipRect ? intersectScreenRects(rect, clipRect) : rect;
|
||||
if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true;
|
||||
|
||||
@@ -107,6 +113,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.uniform1i(samplerLocation, 0);
|
||||
gl.uniform1f(opacityLocation, clampedOpacity);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, rect), gl.DYNAMIC_DRAW);
|
||||
@@ -122,7 +129,9 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
||||
gl.disable(gl.BLEND);
|
||||
return true;
|
||||
},
|
||||
renderMasked(asset, rect, maskAsset, maskRect, clipRect) {
|
||||
renderMasked(asset, rect, maskAsset, maskRect, clipRect, opacity = 1) {
|
||||
const clampedOpacity = clampOpacity(opacity);
|
||||
if (clampedOpacity <= 0) return true;
|
||||
const clippedRect = clipRect ? intersectScreenRects(rect, clipRect) : rect;
|
||||
const drawRect = clippedRect ? intersectScreenRects(clippedRect, maskRect) : undefined;
|
||||
if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true;
|
||||
@@ -144,6 +153,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
||||
gl.activeTexture(gl.TEXTURE1);
|
||||
gl.bindTexture(gl.TEXTURE_2D, maskTexture);
|
||||
gl.uniform1i(maskedMaskSamplerLocation, 1);
|
||||
gl.uniform1f(maskedOpacityLocation, clampedOpacity);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, drawRect), gl.DYNAMIC_DRAW);
|
||||
@@ -164,8 +174,8 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
||||
gl.disable(gl.BLEND);
|
||||
return true;
|
||||
},
|
||||
renderMaskRevealPreview(asset, rect, maskAsset, maskRect, opacity, clipRect) {
|
||||
const clampedOpacity = Math.max(0, Math.min(1, opacity));
|
||||
renderMaskRevealPreview(asset, rect, maskAsset, maskRect, opacity, clipRect, layerOpacity = 1) {
|
||||
const clampedOpacity = clampOpacity(opacity) * clampOpacity(layerOpacity);
|
||||
if (clampedOpacity <= 0) return true;
|
||||
|
||||
const clippedRect = clipRect ? intersectScreenRects(rect, clipRect) : rect;
|
||||
@@ -248,7 +258,9 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
||||
gl.disable(gl.BLEND);
|
||||
return true;
|
||||
},
|
||||
renderTinted(asset, rect, color, clipRect) {
|
||||
renderTinted(asset, rect, color, clipRect, opacity = 1) {
|
||||
const clampedOpacity = clampOpacity(opacity);
|
||||
if (clampedOpacity <= 0) return true;
|
||||
const drawRect = clipRect ? intersectScreenRects(rect, clipRect) : rect;
|
||||
if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true;
|
||||
|
||||
@@ -264,7 +276,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.uniform1i(tintedSamplerLocation, 0);
|
||||
gl.uniform4fv(tintedColorLocation, color);
|
||||
gl.uniform4fv(tintedColorLocation, [color[0], color[1], color[2], color[3] * clampedOpacity]);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, rect), gl.DYNAMIC_DRAW);
|
||||
@@ -401,6 +413,10 @@ function enablePremultipliedAlphaBlending(gl: WebGL2RenderingContext) {
|
||||
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
|
||||
}
|
||||
|
||||
function clampOpacity(opacity: number) {
|
||||
return Math.max(0, Math.min(1, opacity));
|
||||
}
|
||||
|
||||
function fullTexCoords() {
|
||||
return new Float32Array([0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1]);
|
||||
}
|
||||
@@ -442,10 +458,11 @@ function createProgram(gl: WebGL2RenderingContext) {
|
||||
`#version 300 es
|
||||
precision mediump float;
|
||||
uniform sampler2D u_image;
|
||||
uniform float u_opacity;
|
||||
in vec2 v_texCoord;
|
||||
out vec4 outColor;
|
||||
void main() {
|
||||
outColor = texture(u_image, v_texCoord);
|
||||
outColor = texture(u_image, v_texCoord) * u_opacity;
|
||||
}`,
|
||||
);
|
||||
const program = gl.createProgram();
|
||||
@@ -691,6 +708,7 @@ function createMaskedProgram(gl: WebGL2RenderingContext) {
|
||||
precision mediump float;
|
||||
uniform sampler2D u_image;
|
||||
uniform sampler2D u_mask;
|
||||
uniform float u_opacity;
|
||||
in vec2 v_texCoord;
|
||||
in vec2 v_maskTexCoord;
|
||||
out vec4 outColor;
|
||||
@@ -699,7 +717,7 @@ function createMaskedProgram(gl: WebGL2RenderingContext) {
|
||||
vec4 maskColor = texture(u_mask, v_maskTexCoord);
|
||||
float maskAlpha = maskColor.a * dot(maskColor.rgb, vec3(0.2126, 0.7152, 0.0722));
|
||||
float alpha = color.a * maskAlpha;
|
||||
outColor = vec4(color.rgb * maskAlpha, alpha);
|
||||
outColor = vec4(color.rgb * maskAlpha, alpha) * u_opacity;
|
||||
}`,
|
||||
);
|
||||
const program = gl.createProgram();
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Asset } from "@core/asset";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { Rect } from "@core/geometry";
|
||||
import type { Layer } from "@core/layer";
|
||||
import { getLayerMask } from "@core/layer-mask-utils";
|
||||
import { createDocumentReadIndex, forEachLayerBackToFront, resolveIndexedLayerBounds, type DocumentReadIndex } from "@editor/document-indexes";
|
||||
import type { EditorState, GenerationCandidate, MaskViewMode, ViewportState } from "@editor/state";
|
||||
import { clearScreenRect } from "./clear-rect";
|
||||
@@ -12,6 +13,7 @@ import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types";
|
||||
const imageLayerColor: RgbaColor = [0.38, 0.42, 0.5, 1];
|
||||
const imageLayerInsetColor: RgbaColor = [0.48, 0.54, 0.64, 1];
|
||||
const hiddenMaskOverlayColor: RgbaColor = [1, 0.08, 0.08, 0.45];
|
||||
const comparisonDividerColor: RgbaColor = [1, 1, 1, 0.9];
|
||||
const maskRevealPreviewOpacity = 0.28;
|
||||
|
||||
export function renderLayers(context: WebGlRendererContext, document: ImageDocument, editor: EditorState, imageTextureRenderer: ImageTextureRenderer) {
|
||||
@@ -22,7 +24,7 @@ export function renderLayers(context: WebGlRendererContext, document: ImageDocum
|
||||
if (!artboard.visible) continue;
|
||||
const clipRect = documentRectToScreenRect(context.canvas, artboard.bounds, editor.viewport);
|
||||
const maskLayerIds = documentIndex.maskLayerIdsByArtboardId.get(artboard.id) ?? emptyLayerIds;
|
||||
forEachLayerBackToFront(artboard.layers, (layer) => renderLayer(context, documentIndex, editor, layer, imageTextureRenderer, clipRect, maskLayerIds));
|
||||
forEachLayerBackToFront(artboard.layers, (layer) => renderLayer(context, documentIndex, editor, layer, imageTextureRenderer, clipRect, maskLayerIds, 1));
|
||||
if (generationCandidate?.placement.artboardId === artboard.id) renderGenerationCandidatePreview(context, editor, generationCandidate, imageTextureRenderer, clipRect);
|
||||
}
|
||||
}
|
||||
@@ -39,17 +41,20 @@ function renderLayer(
|
||||
imageTextureRenderer: ImageTextureRenderer,
|
||||
clipRect: ScreenRect,
|
||||
maskLayerIds: ReadonlySet<string>,
|
||||
inheritedOpacity: number,
|
||||
) {
|
||||
const editingMaskLayer = editor.maskEdit?.maskLayerId === layer.id;
|
||||
const maskViewMode = editor.maskEdit?.viewMode ?? "composite";
|
||||
const isolatedMaskView = isIsolatedMaskView(maskViewMode);
|
||||
if (!layer.visible || maskLayerIds.has(layer.id)) return;
|
||||
const effectiveOpacity = inheritedOpacity * layer.opacity;
|
||||
if (effectiveOpacity <= 0) return;
|
||||
|
||||
const effectiveClipRect = resolveLayerClipRect(context, documentIndex, editor.viewport, layer, clipRect);
|
||||
if (!effectiveClipRect) return;
|
||||
|
||||
if (layer.type === "group") {
|
||||
forEachLayerBackToFront(layer.children, (child) => renderLayer(context, documentIndex, editor, child, imageTextureRenderer, effectiveClipRect, maskLayerIds));
|
||||
forEachLayerBackToFront(layer.children, (child) => renderLayer(context, documentIndex, editor, child, imageTextureRenderer, effectiveClipRect, maskLayerIds, effectiveOpacity));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -60,33 +65,34 @@ function renderLayer(
|
||||
|
||||
const rect = documentRectToScreenRect(context.canvas, bounds, editor.viewport);
|
||||
const asset = assetWithBrushStrokePreview(documentIndex.assetById.get(layer.assetId), editor);
|
||||
const maskLayer = !editingMaskLayer && layer.clippingMask ? documentIndex.layerById.get(layer.clippingMask.maskLayerId) : undefined;
|
||||
const layerMask = getLayerMask(layer);
|
||||
const maskLayer = !editingMaskLayer && layerMask?.enabled ? documentIndex.layerById.get(layerMask.maskLayerId) : undefined;
|
||||
const maskAsset = assetWithBrushStrokePreview(maskLayer && maskLayer.type !== "group" ? documentIndex.assetById.get(maskLayer.assetId) : undefined, editor);
|
||||
const maskBounds = maskLayer ? resolveIndexedLayerBounds(documentIndex, maskLayer) : undefined;
|
||||
const maskRect = maskBounds ? documentRectToScreenRect(context.canvas, maskBounds, editor.viewport) : undefined;
|
||||
const activeMaskTarget = Boolean(editor.maskEdit?.targetLayerId === layer.id && editor.maskEdit.maskLayerId === layer.clippingMask?.maskLayerId);
|
||||
const activeMaskTarget = Boolean(editor.maskEdit?.targetLayerId === layer.id && editor.maskEdit.maskLayerId === layerMask?.maskLayerId);
|
||||
const showMaskRevealPreview = editor.tools.activeTool === "brush" && activeMaskTarget && maskViewMode === "composite";
|
||||
|
||||
if (asset && maskAsset && maskRect && activeMaskTarget) {
|
||||
if (maskViewMode === "blackWhite" && imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "blackWhite", undefined, effectiveClipRect)) return;
|
||||
if (maskViewMode === "alpha" && imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "alpha", undefined, effectiveClipRect)) return;
|
||||
if (maskViewMode === "overlay" && imageTextureRenderer.render(asset, rect, effectiveClipRect)) {
|
||||
if (maskViewMode === "overlay" && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity)) {
|
||||
imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "hiddenOverlay", hiddenMaskOverlayColor, effectiveClipRect);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (asset && maskAsset && maskRect && imageTextureRenderer.renderMasked(asset, rect, maskAsset, maskRect, effectiveClipRect)) {
|
||||
if (showMaskRevealPreview) imageTextureRenderer.renderMaskRevealPreview(asset, rect, maskAsset, maskRect, maskRevealPreviewOpacity, effectiveClipRect);
|
||||
if (asset && maskAsset && maskRect && imageTextureRenderer.renderMasked(asset, rect, maskAsset, maskRect, effectiveClipRect, effectiveOpacity)) {
|
||||
if (showMaskRevealPreview) imageTextureRenderer.renderMaskRevealPreview(asset, rect, maskAsset, maskRect, maskRevealPreviewOpacity, effectiveClipRect, effectiveOpacity);
|
||||
return;
|
||||
}
|
||||
if (asset && imageTextureRenderer.render(asset, rect, effectiveClipRect)) return;
|
||||
if (asset && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity)) return;
|
||||
|
||||
const fallbackRect = intersectScreenRects(rect, effectiveClipRect);
|
||||
if (!fallbackRect) return;
|
||||
clearScreenRect(context, fallbackRect, imageLayerColor);
|
||||
clearScreenRect(context, fallbackRect, withOpacity(imageLayerColor, effectiveOpacity));
|
||||
const insetRect = intersectScreenRects({ x: rect.x + 4, y: rect.y + 4, w: Math.max(0, rect.w - 8), h: Math.max(0, rect.h - 8) }, effectiveClipRect);
|
||||
if (insetRect) clearScreenRect(context, insetRect, imageLayerInsetColor);
|
||||
if (insetRect) clearScreenRect(context, insetRect, withOpacity(imageLayerInsetColor, effectiveOpacity));
|
||||
}
|
||||
|
||||
const emptyLayerIds = new Set<string>();
|
||||
@@ -98,9 +104,10 @@ function resolveLayerClipRect(
|
||||
layer: Layer,
|
||||
clipRect: ScreenRect,
|
||||
): ScreenRect | undefined {
|
||||
if (!layer.clippingMask) return clipRect;
|
||||
const layerMask = getLayerMask(layer);
|
||||
if (!layerMask?.enabled) return clipRect;
|
||||
|
||||
const maskBounds = resolveIndexedLayerBounds(documentIndex, layer.clippingMask.maskLayerId);
|
||||
const maskBounds = resolveIndexedLayerBounds(documentIndex, layerMask.maskLayerId);
|
||||
if (!maskBounds) return clipRect;
|
||||
|
||||
return intersectScreenRects(clipRect, documentRectToScreenRect(context.canvas, maskBounds, viewport));
|
||||
@@ -118,6 +125,29 @@ function renderGenerationCandidatePreview(
|
||||
clipRect: ScreenRect,
|
||||
) {
|
||||
const rect = documentRectToScreenRect(context.canvas, generationCandidateBounds(candidate), editor.viewport);
|
||||
const compareMode = editor.generation.compareMode ?? "result";
|
||||
if (compareMode === "before") return;
|
||||
|
||||
if (compareMode === "split") {
|
||||
const splitClipRect = intersectScreenRects(clipRect, {
|
||||
x: rect.x + rect.w / 2,
|
||||
y: rect.y,
|
||||
w: rect.w / 2,
|
||||
h: rect.h,
|
||||
});
|
||||
if (!splitClipRect) return;
|
||||
imageTextureRenderer.render(generationCandidateAsset(candidate), rect, splitClipRect);
|
||||
|
||||
const divider = intersectScreenRects(clipRect, {
|
||||
x: rect.x + rect.w / 2 - 1,
|
||||
y: rect.y,
|
||||
w: 2,
|
||||
h: rect.h,
|
||||
});
|
||||
if (divider) clearScreenRect(context, divider, comparisonDividerColor);
|
||||
return;
|
||||
}
|
||||
|
||||
imageTextureRenderer.render(generationCandidateAsset(candidate), rect, clipRect);
|
||||
}
|
||||
|
||||
@@ -162,3 +192,7 @@ function intersectScreenRects(a: ScreenRect, b: ScreenRect): ScreenRect | undefi
|
||||
|
||||
return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 };
|
||||
}
|
||||
|
||||
function withOpacity(color: RgbaColor, opacity: number): RgbaColor {
|
||||
return [color[0], color[1], color[2], color[3] * opacity];
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { commandIds } from "@commands/ids";
|
||||
import type { Asset } from "@core/asset";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { Layer } from "@core/layer";
|
||||
import { getLayerMask } from "@core/layer-mask-utils";
|
||||
import type { ArtboardId } from "@core/id";
|
||||
import { createDocumentReadIndex, resolveIndexedLayerBounds, type DocumentReadIndex, type IndexedLayerInfo } from "@editor/document-indexes";
|
||||
import type { MaskEditState, SelectionState } from "@editor/state";
|
||||
@@ -220,10 +221,11 @@ function LayerRow({
|
||||
|
||||
const selected = selectedLayerIds.includes(layer.id);
|
||||
const layerInfo = documentIndex.layerInfoById.get(layer.id);
|
||||
const maskLayer = layer.clippingMask ? documentIndex.layerById.get(layer.clippingMask.maskLayerId) : undefined;
|
||||
const layerMask = getLayerMask(layer);
|
||||
const maskLayer = layerMask ? documentIndex.layerById.get(layerMask.maskLayerId) : undefined;
|
||||
const maskAsset = maskLayer && maskLayer.type !== "group" ? documentIndex.assetById.get(maskLayer.assetId) : undefined;
|
||||
const canAddMask = Boolean(layerInfo && layer.type !== "group" && !layer.clippingMask);
|
||||
const editingMask = Boolean(maskEdit && layer.clippingMask && maskEdit.targetLayerId === layer.id && maskEdit.maskLayerId === layer.clippingMask.maskLayerId);
|
||||
const canAddMask = Boolean(layerInfo && layer.type !== "group" && !layerMask);
|
||||
const editingMask = Boolean(maskEdit && layerMask && maskEdit.targetLayerId === layer.id && maskEdit.maskLayerId === layerMask.maskLayerId);
|
||||
const rowPadding = 12 + depth * 16;
|
||||
|
||||
return (
|
||||
@@ -273,7 +275,7 @@ function LayerRow({
|
||||
{layer.name}
|
||||
</button>
|
||||
)}
|
||||
{layer.clippingMask ? (
|
||||
{layerMask ? (
|
||||
<span className={`inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs ${editingMask || selected ? "bg-black/10 text-black/65" : "bg-sky-400/10 text-sky-100/70"}`}>
|
||||
<Stack size={13} weight="fill" /> Mask
|
||||
</span>
|
||||
@@ -290,7 +292,7 @@ function LayerRow({
|
||||
{layer.locked ? <Lock size={24} weight="regular" /> : <LockOpen size={24} weight="regular" />}
|
||||
</button>
|
||||
</div>
|
||||
{layer.clippingMask ? (
|
||||
{layerMask ? (
|
||||
<div className="mt-2 flex min-h-14 flex-wrap items-center gap-2 rounded-[1.5rem] py-2 pl-4 pr-2 text-sm text-sky-100/70 hover:bg-white/[0.04]">
|
||||
<Stack size={24} weight="fill" className="shrink-0" />
|
||||
<span className="min-w-28 flex-1 truncate">{maskLayer ? "Layer mask" : "Layer mask missing"}</span>
|
||||
@@ -303,7 +305,7 @@ function LayerRow({
|
||||
onClick={() =>
|
||||
editingMask
|
||||
? dispatch(commandIds.toolExitMaskEdit, undefined)
|
||||
: dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: layer.id, maskLayerId: layer.clippingMask!.maskLayerId })
|
||||
: dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: layer.id, maskLayerId: layerMask.maskLayerId })
|
||||
}
|
||||
>
|
||||
{editingMask ? "Done" : "Edit"}
|
||||
@@ -313,7 +315,7 @@ function LayerRow({
|
||||
className={maskActionButtonClass()}
|
||||
title="Paint reveal"
|
||||
onClick={() => {
|
||||
dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: layer.id, maskLayerId: layer.clippingMask!.maskLayerId });
|
||||
dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: layer.id, maskLayerId: layerMask.maskLayerId });
|
||||
dispatch(commandIds.toolSetActive, { tool: "brush" });
|
||||
}}
|
||||
>
|
||||
@@ -324,7 +326,7 @@ function LayerRow({
|
||||
className={maskActionButtonClass()}
|
||||
title="Paint hide"
|
||||
onClick={() => {
|
||||
dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: layer.id, maskLayerId: layer.clippingMask!.maskLayerId });
|
||||
dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: layer.id, maskLayerId: layerMask.maskLayerId });
|
||||
dispatch(commandIds.toolSetActive, { tool: "eraser" });
|
||||
}}
|
||||
>
|
||||
@@ -592,7 +594,8 @@ function moveLayer(documentIndex: DocumentReadIndex, info: IndexedLayerInfo, dir
|
||||
const blocks = siblings.flatMap((layer, index) => {
|
||||
if (maskLayerIds.has(layer.id)) return [];
|
||||
|
||||
const maskIndex = layer.clippingMask ? siblings.findIndex((candidate) => candidate.id === layer.clippingMask?.maskLayerId) : -1;
|
||||
const layerMask = getLayerMask(layer);
|
||||
const maskIndex = layerMask ? siblings.findIndex((candidate) => candidate.id === layerMask.maskLayerId) : -1;
|
||||
const start = maskIndex >= 0 ? Math.min(maskIndex, index) : index;
|
||||
const end = maskIndex >= 0 ? Math.max(maskIndex, index) : index;
|
||||
return [{ layerId: layer.id, start, end, size: end - start + 1 }];
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DropHalf } from "@phosphor-icons/react";
|
||||
import { commandIds } from "@commands/ids";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { Layer } from "@core/layer";
|
||||
import { getLayerMask } from "@core/layer-mask-utils";
|
||||
import { resolveTransformTargetBounds } from "@editor/transform-targets";
|
||||
import type { AppStore } from "@editor/store";
|
||||
import type { ChromaKeySettings } from "@editor/tools";
|
||||
@@ -160,7 +161,8 @@ function resolveChromaKeyTarget(document: ImageDocument, selection: SelectionSta
|
||||
if (!layer || layer.type === "group") return undefined;
|
||||
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||
const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id });
|
||||
const maskLayer = layer.clippingMask ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layer.clippingMask.maskLayerId) : undefined;
|
||||
const layerMask = getLayerMask(layer);
|
||||
const maskLayer = layerMask?.enabled ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerMask.maskLayerId) : undefined;
|
||||
const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
|
||||
return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset } : undefined;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { commandIds } from "@commands/ids";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { GenerationCandidate, GenerationState, SelectionState, ViewportState } from "@editor/state";
|
||||
import type { GenerationCandidate, GenerationCompareMode, GenerationState, SelectionState, ViewportState } from "@editor/state";
|
||||
import type { GenerateSettings } from "@editor/tools";
|
||||
import type { AppStore } from "@editor/store";
|
||||
import { createMaskedPixelReplacementSource } from "../generate/candidateActions";
|
||||
@@ -62,6 +62,7 @@ export function GenerateActionControls({ document, selection, viewport, settings
|
||||
<CandidateControls
|
||||
document={document}
|
||||
candidate={candidate}
|
||||
compareMode={generation.compareMode ?? "result"}
|
||||
settings={settings}
|
||||
busy={busy}
|
||||
setBusy={setBusy}
|
||||
@@ -100,6 +101,7 @@ function CandidatePicker({ generation, dispatch }: { generation: GenerationState
|
||||
function CandidateControls({
|
||||
document,
|
||||
candidate,
|
||||
compareMode,
|
||||
settings,
|
||||
busy,
|
||||
setBusy,
|
||||
@@ -108,6 +110,7 @@ function CandidateControls({
|
||||
}: {
|
||||
document: ImageDocument;
|
||||
candidate: GenerationCandidate;
|
||||
compareMode: GenerationCompareMode;
|
||||
settings: GenerateSettings;
|
||||
busy?: string;
|
||||
setBusy: (busy: string | undefined) => void;
|
||||
@@ -128,6 +131,7 @@ function CandidateControls({
|
||||
<div className="flex flex-wrap items-center justify-center gap-1 rounded-full bg-white/[0.04] px-2 py-1 ring-1 ring-white/[0.05]">
|
||||
<CandidatePreview candidate={candidate} />
|
||||
<span className="px-2 text-xs font-medium text-white/55">Seed {candidate.seed}</span>
|
||||
<CandidateCompareControls compareMode={compareMode} disabled={disabled} dispatch={dispatch} />
|
||||
<CandidateButton disabled={disabled} label="Regenerate" title="Regenerate same mask and crop" busy={busy === "Regenerate"} onClick={() => rerun("Regenerate", candidate.settings)} />
|
||||
<CandidateButton
|
||||
disabled={disabled}
|
||||
@@ -138,11 +142,11 @@ function CandidateControls({
|
||||
/>
|
||||
<CandidateButton disabled={disabled} label="Reuse seed" title="Regenerate with the same seed" busy={busy === "Reuse seed"} onClick={() => rerun("Reuse seed", { ...candidate.settings, seed: candidate.seed })} />
|
||||
<CandidateButton disabled={disabled} label="New seed" title="Regenerate with a new seed" busy={busy === "New seed"} onClick={() => rerun("New seed", { ...candidate.settings, seed: -1 })} />
|
||||
<CandidateButton disabled={disabled} label="Apply layer" title="Apply candidate as a normal layer" onClick={() => applyCandidateAsLayer(candidate, false, dispatch)} />
|
||||
<CandidateButton disabled={disabled} label="Accept layer" title="Accept candidate as a normal layer" onClick={() => applyCandidateAsLayer(candidate, false, dispatch)} />
|
||||
<CandidateButton
|
||||
disabled={disabled}
|
||||
label="Apply + refine"
|
||||
title="Apply candidate as a layer with a fresh refinement mask"
|
||||
label="Accept + mask"
|
||||
title="Accept candidate as a layer with a fresh refinement mask"
|
||||
busy={busy === "Refine"}
|
||||
onClick={() => {
|
||||
setBusy("Refine");
|
||||
@@ -152,10 +156,10 @@ function CandidateControls({
|
||||
.finally(() => setBusy(undefined));
|
||||
}}
|
||||
/>
|
||||
<CandidateButton disabled={disabled} label="Stack variant" title="Stack candidate as another variant layer" onClick={() => applyCandidateAsLayer(candidate, true, dispatch)} />
|
||||
<CandidateButton disabled={disabled} label="Accept variant" title="Stack candidate as another variant layer" onClick={() => applyCandidateAsLayer(candidate, true, dispatch)} />
|
||||
<CandidateButton
|
||||
disabled={disabled || !candidate.inpaint}
|
||||
label="Replace"
|
||||
label="Accept replace"
|
||||
title={candidate.inpaint ? "Replace masked pixels and preserve unmasked pixels" : "Only inpaint candidates can replace masked pixels"}
|
||||
busy={busy === "Replace"}
|
||||
onClick={() => {
|
||||
@@ -183,6 +187,36 @@ function CandidateControls({
|
||||
);
|
||||
}
|
||||
|
||||
function CandidateCompareControls({ compareMode, disabled, dispatch }: { compareMode: GenerationCompareMode; disabled: boolean; dispatch: AppStore["dispatch"] }) {
|
||||
return (
|
||||
<span className="flex items-center gap-1 rounded-full bg-black/20 p-1" aria-label="Compare candidate">
|
||||
{generationCompareOptions.map((option) => {
|
||||
const active = compareMode === option.mode;
|
||||
return (
|
||||
<button
|
||||
key={option.mode}
|
||||
type="button"
|
||||
className={`h-7 rounded-full px-2 text-[0.7rem] font-semibold transition ${
|
||||
active ? "bg-white text-black" : "text-white/55 hover:bg-white/10 hover:text-white"
|
||||
} disabled:pointer-events-none disabled:opacity-35`}
|
||||
disabled={disabled}
|
||||
title={option.title}
|
||||
onClick={() => dispatch(commandIds.generationSetCompareMode, { mode: option.mode })}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const generationCompareOptions: Array<{ mode: GenerationCompareMode; label: string; title: string }> = [
|
||||
{ mode: "result", label: "After", title: "Show the generated result over the document" },
|
||||
{ mode: "before", label: "Before", title: "Hide the generated result and show the source document" },
|
||||
{ mode: "split", label: "Split", title: "Compare source on the left with result on the right" },
|
||||
];
|
||||
|
||||
function CandidatePreview({ candidate }: { candidate: GenerationCandidate }) {
|
||||
if (!candidate.inputImage) {
|
||||
return <img src={candidate.source} alt="" className="h-10 w-10 rounded-full bg-black/25 object-cover ring-1 ring-white/10" />;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { commandIds } from "@commands/ids";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { Vec2D } from "@core/geometry";
|
||||
import type { Layer } from "@core/layer";
|
||||
import { getLayerMask } from "@core/layer-mask-utils";
|
||||
import { resolveTransformTargetBounds } from "@editor/transform-targets";
|
||||
import type { AppStore } from "@editor/store";
|
||||
import type { EditorState } from "@editor/state";
|
||||
@@ -41,7 +42,8 @@ function resolveTarget(document: ImageDocument, editor: EditorState) {
|
||||
if (!layer || layer.type === "group") return undefined;
|
||||
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||
const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id });
|
||||
const maskLayer = layer.clippingMask ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layer.clippingMask.maskLayerId) : undefined;
|
||||
const layerMask = getLayerMask(layer);
|
||||
const maskLayer = layerMask?.enabled ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerMask.maskLayerId) : undefined;
|
||||
const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
|
||||
return asset && bounds ? { layer, asset, bounds, maskLayer, maskAsset } : undefined;
|
||||
}
|
||||
|
||||
@@ -177,6 +177,7 @@ const visualEditorChanges: Array<[string, (state: AppState) => AppState]> = [
|
||||
},
|
||||
],
|
||||
selectedCandidateId: "candidate",
|
||||
compareMode: "result",
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Artboard } from "@core/artboard";
|
||||
import type { Asset } from "@core/asset";
|
||||
import type { Rect } from "@core/geometry";
|
||||
import type { Layer } from "@core/layer";
|
||||
import { getLayerMask } from "@core/layer-mask-utils";
|
||||
|
||||
export async function downloadArtboardPng(artboard: Artboard, assets: readonly Asset[]) {
|
||||
const width = Math.max(1, Math.round(artboard.bounds.w));
|
||||
@@ -41,8 +42,9 @@ async function drawLayer(
|
||||
) {
|
||||
if (!layer.visible || (!options.ignoreOwnMask && options.maskLayerIds.has(layer.id))) return;
|
||||
|
||||
if (!options.ignoreOwnMask && layer.clippingMask) {
|
||||
const maskLayer = findLayer(layerTree, layer.clippingMask.maskLayerId);
|
||||
const layerMask = getLayerMask(layer);
|
||||
if (!options.ignoreOwnMask && layerMask?.enabled) {
|
||||
const maskLayer = findLayer(layerTree, layerMask.maskLayerId);
|
||||
if (!maskLayer) return;
|
||||
await drawMaskedLayer(context, layer, maskLayer, layerTree, assets, artboardBounds);
|
||||
return;
|
||||
@@ -122,7 +124,8 @@ function translatedContext(canvas: HTMLCanvasElement, bounds: Rect) {
|
||||
|
||||
function collectMaskLayerIds(layers: readonly Layer[], ids = new Set<string>()) {
|
||||
for (const layer of layers) {
|
||||
if (layer.clippingMask) ids.add(layer.clippingMask.maskLayerId);
|
||||
const layerMask = getLayerMask(layer);
|
||||
if (layerMask) ids.add(layerMask.maskLayerId);
|
||||
if (layer.type === "group") collectMaskLayerIds(layer.children, ids);
|
||||
}
|
||||
return ids;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Asset } from "@core/asset";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { Rect } from "@core/geometry";
|
||||
import type { Layer } from "@core/layer";
|
||||
import { getLayerMask } from "@core/layer-mask-utils";
|
||||
import type { SelectionState } from "@editor/state";
|
||||
import type { GenerateSettings } from "@editor/tools";
|
||||
import { createDocumentReadIndex, resolveIndexedLayerBounds } from "@editor/document-indexes";
|
||||
@@ -137,9 +138,10 @@ function resolveInpaintTarget(document: ImageDocument, selection: SelectionState
|
||||
|
||||
const asset = documentIndex.assetById.get(layerInfo.layer.assetId);
|
||||
if (!asset) throw new Error("The selected layer is missing its source image.");
|
||||
if (!layerInfo.layer.clippingMask) throw new Error("Add a layer mask before running inpaint.");
|
||||
const layerMask = getLayerMask(layerInfo.layer);
|
||||
if (!layerMask?.enabled) throw new Error("Add a layer mask before running inpaint.");
|
||||
|
||||
const maskLayer = documentIndex.layerById.get(layerInfo.layer.clippingMask.maskLayerId);
|
||||
const maskLayer = documentIndex.layerById.get(layerMask.maskLayerId);
|
||||
if (!maskLayer || maskLayer.type === "group") throw new Error("The selected layer mask is missing.");
|
||||
|
||||
const maskAsset = documentIndex.assetById.get(maskLayer.assetId);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { commandIds } from "@commands/ids";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { Transform } from "@core/geometry";
|
||||
import type { Layer } from "@core/layer";
|
||||
import { getLayerMask } from "@core/layer-mask-utils";
|
||||
import type { AppStore } from "@editor/store";
|
||||
import type { GenerationCandidate, SelectionState, ViewportState } from "@editor/state";
|
||||
import type { GenerateSettings } from "@editor/tools";
|
||||
@@ -224,7 +225,8 @@ function resolveSelectedImage(document: ImageDocument, selection: SelectionState
|
||||
const layer = findLayer(document.artboards.find((artboard) => artboard.id === selection.artboardId)?.layers ?? [], layerId);
|
||||
if (!layer || layer.type === "group") return undefined;
|
||||
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||
const maskLayer = layer.clippingMask ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layer.clippingMask.maskLayerId) : undefined;
|
||||
const layerMask = getLayerMask(layer);
|
||||
const maskLayer = layerMask?.enabled ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerMask.maskLayerId) : undefined;
|
||||
const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined;
|
||||
return asset ? { layer, asset, maskAsset } : undefined;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user