feat: add inpaint region functionality and related tools

- Enhanced cursor behavior for new tools: semantic select, mask lasso, and mask rectangle.
- Updated mask edit state to include mask asset ID and kind.
- Implemented inpaint region commands for adding, applying, and removing inpaint regions.
- Introduced new operations for lasso and semantic selection tools.
- Created UI components for candidate review and inpaint region management.
- Added tests for inpaint region commands to ensure functionality.
- Updated various components to support new inpaint features and improve user experience.
This commit is contained in:
syntaxbullet
2026-07-11 16:41:22 +02:00
parent f4e13b80e7
commit ff762b8f17
78 changed files with 1632 additions and 301 deletions

View File

@@ -3,6 +3,7 @@ import type { ArtboardId, LayerId } from "@core/id";
import type { Layer } from "@core/layer";
import type { LayerGroup } from "@core/layer-group";
import { getLayerMask } from "@core/layer-mask-utils";
import type { MaskEditState } from "@editor/state";
export type LayerLocation = {
artboardId: ArtboardId;
@@ -242,13 +243,18 @@ export function removeMissingMaskReferences(document: ImageDocument): ImageDocum
});
}
export function isMaskEditFor(maskEdit: { targetLayerId: LayerId; maskLayerId: LayerId } | undefined, targetLayerId: LayerId, maskLayerId: LayerId) {
export function isMaskEditFor(maskEdit: MaskEditState | undefined, targetLayerId: LayerId, maskLayerId: LayerId) {
return maskEdit?.targetLayerId === targetLayerId && maskEdit.maskLayerId === maskLayerId;
}
export function isMaskEditValid(maskEdit: { targetLayerId: LayerId; maskLayerId: LayerId } | undefined, document: ImageDocument) {
export function isMaskEditValid(maskEdit: MaskEditState | undefined, document: ImageDocument) {
if (!maskEdit) return false;
const target = findLayerLocation(document, maskEdit.targetLayerId)?.layer;
if (maskEdit.kind === "inpaintRegion") {
const region = document.inpaintRegions.find((candidate) => candidate.id === maskEdit.inpaintRegionId && candidate.targetLayerId === maskEdit.targetLayerId && candidate.maskAssetId === maskEdit.maskAssetId);
return Boolean(target && region && document.assets.some((asset) => asset.id === region.maskAssetId));
}
if (!maskEdit.maskLayerId) return false;
const mask = findLayerLocation(document, maskEdit.maskLayerId)?.layer;
return Boolean(target && getLayerMask(target)?.maskLayerId === maskEdit.maskLayerId && mask && mask.type !== "group");
}
@@ -300,3 +306,26 @@ export function mapAllLayersInTree(layers: Layer[], mapLayer: (layer: Layer) =>
return mapLayer(mapped);
});
}
export function removeInpaintRegionsForTargets(document: ImageDocument, targetLayerIds: ReadonlySet<LayerId>): ImageDocument {
const removed = document.inpaintRegions.filter((region) => targetLayerIds.has(region.targetLayerId));
if (removed.length === 0) return document;
const inpaintRegions = document.inpaintRegions.filter((region) => !targetLayerIds.has(region.targetLayerId));
const removedMaskAssetIds = new Set(removed.map((region) => region.maskAssetId));
const retainedRegionAssetIds = new Set(inpaintRegions.map((region) => region.maskAssetId));
const layerAssetIds = new Set<string>();
for (const artboard of document.artboards) {
const stack = [...artboard.layers];
while (stack.length > 0) {
const layer = stack.pop();
if (!layer) continue;
if (layer.type === "group") stack.push(...layer.children);
else if (layer.type === "image" || layer.type === "raster") layerAssetIds.add(layer.assetId);
}
}
return {
...document,
inpaintRegions,
assets: document.assets.filter((asset) => !removedMaskAssetIds.has(asset.id) || retainedRegionAssetIds.has(asset.id) || layerAssetIds.has(asset.id)),
};
}

View File

@@ -285,7 +285,7 @@ describe("document commands", () => {
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.maskEdit).toEqual({ kind: "layerMask", targetLayerId: "target", maskLayerId: "mask", maskAssetId: "mask-asset" });
expect(masked.editor.tools.activeTool).toBe("brush");
});

View File

@@ -1,4 +1,4 @@
import { findLayerLocation, isReferencedMaskLayer, mapLayerInDocument, insertLayer, replaceLayerListInDocument, replaceSelectedLayersWithGroup, removeLayerFromDocument, ungroupLayerInDocument, findGroup, removeLayerMaskReference, withLayerMask, removeUnreferencedMaskLayer, removeMissingMaskReferences, isMaskEditFor, isMaskEditValid, collectLayerIds, collectClippingMaskIds, collectAttachedMaskIds } from "./document-tree";
import { findLayerLocation, isReferencedMaskLayer, mapLayerInDocument, insertLayer, replaceLayerListInDocument, replaceSelectedLayersWithGroup, removeLayerFromDocument, ungroupLayerInDocument, findGroup, removeLayerMaskReference, withLayerMask, removeUnreferencedMaskLayer, removeMissingMaskReferences, isMaskEditFor, isMaskEditValid, collectLayerIds, collectClippingMaskIds, collectAttachedMaskIds, removeInpaintRegionsForTargets } from "./document-tree";
import type { Asset } from "@core/asset";
import type { ImageDocument } from "@core/document";
import type { Rect } from "@core/geometry";
@@ -235,10 +235,14 @@ export const documentRemoveArtboardCommand: Command<DocumentRemoveArtboardPayloa
name: "Remove artboard",
execute({ state }, payload) {
const removedSelectedArtboard = state.editor.selection.artboardId === payload.id;
const document = {
const removedArtboard = state.document.artboards.find((artboard) => artboard.id === payload.id);
const withoutArtboard = {
...state.document,
artboards: state.document.artboards.filter((artboard) => artboard.id !== payload.id),
};
const removedLayerIds = new Set<LayerId>();
for (const layer of removedArtboard?.layers ?? []) collectLayerIds(layer, removedLayerIds);
const document = removeInpaintRegionsForTargets(withoutArtboard, removedLayerIds);
return {
...state,
@@ -630,14 +634,15 @@ export const documentAddLayerMaskCommand: Command<DocumentAddLayerMaskPayload> =
const existingMaskId = getLayerMask(targetLocation.layer)?.maskLayerId;
if (existingMaskId) {
const existingMaskLocation = findLayerLocation(state.document, existingMaskId);
if (existingMaskLocation?.layer.type === "group") return state;
if (existingMaskLocation) {
const existingMaskLayer = existingMaskLocation?.layer;
if (existingMaskLayer && existingMaskLayer.type !== "image" && existingMaskLayer.type !== "raster") return state;
if (existingMaskLocation && existingMaskLayer) {
return {
...state,
editor: {
...state.editor,
selection: { artboardId: targetLocation.artboardId, layerIds: [payload.layerId] },
maskEdit: { targetLayerId: payload.layerId, maskLayerId: existingMaskId },
maskEdit: { kind: "layerMask", targetLayerId: payload.layerId, maskLayerId: existingMaskId, maskAssetId: existingMaskLayer.assetId },
tools: {
...state.editor.tools,
activeTool: "brush",
@@ -670,7 +675,7 @@ export const documentAddLayerMaskCommand: Command<DocumentAddLayerMaskPayload> =
editor: {
...state.editor,
selection: { artboardId: targetLocation.artboardId, layerIds: [payload.layerId] },
maskEdit: { targetLayerId: payload.layerId, maskLayerId: maskLayer.id },
maskEdit: { kind: "layerMask", targetLayerId: payload.layerId, maskLayerId: maskLayer.id, maskAssetId: payload.asset.id },
tools: {
...state.editor.tools,
activeTool: "brush",
@@ -749,7 +754,8 @@ export const documentRemoveLayerCommand: Command<DocumentRemoveLayerPayload> = {
const removedLayerIds = collectLayerIds(removed.layer);
const removedMaskLayerIds = collectClippingMaskIds([removed.layer]);
const cleanedReferences = removeMissingMaskReferences(removed.document);
const document = [...removedMaskLayerIds].reduce((nextDocument, maskLayerId) => removeUnreferencedMaskLayer(nextDocument, maskLayerId), cleanedReferences);
const withoutMasks = [...removedMaskLayerIds].reduce((nextDocument, maskLayerId) => removeUnreferencedMaskLayer(nextDocument, maskLayerId), cleanedReferences);
const document = removeInpaintRegionsForTargets(withoutMasks, removedLayerIds);
const selection = {
...state.editor.selection,
layerIds: state.editor.selection.layerIds.filter((id) => !removedLayerIds.has(id)),

View File

@@ -231,11 +231,14 @@ function generationCandidate(id: string, inpaint = false): GenerationCandidate {
maskImage: "mask",
inpaint: {
targetLayerId: "source-layer",
maskLayerId: "mask-layer",
regionId: "region",
sourceAssetId: "source-asset",
maskAssetId: "mask-asset",
inputImage: "input",
maskImage: "mask",
editMaskImage: "edit-mask",
blendMaskImage: "blend-mask",
revision: { source: "source-revision", mask: "mask-revision" },
crop: {
assetBounds: { x: 0, y: 0, w: 64, h: 64 },
documentBounds: { x: 0, y: 0, w: 64, h: 64 },

View File

@@ -23,6 +23,7 @@ export type GenerationSetCompareModePayload = {
export type GenerationRemoveCandidatePayload = {
candidateId: GenerationCandidateId;
};
export type GenerationToggleCandidateFavoritePayload = { candidateId: GenerationCandidateId };
export type GenerationReuseCandidateSettingsPayload = {
candidateId: GenerationCandidateId;
@@ -41,6 +42,7 @@ export type GenerationReplaceCandidatePixelsPayload = {
};
export type GenerationStartJobPayload = { jobId: GenerationJobId; kind: GenerationJobKind; label: string; startedAt: number };
export type GenerationUpdateJobPayload = { jobId: GenerationJobId; progress: number; detail: string };
export type GenerationSucceedJobPayload = { jobId: GenerationJobId; finishedAt: number };
export type GenerationFailJobPayload = { jobId: GenerationJobId; finishedAt: number; error: string };
export type GenerationCancelJobPayload = { jobId: GenerationJobId; finishedAt: number };
@@ -57,7 +59,8 @@ export const generationAddCandidateCommand: Command<GenerationAddCandidatePayloa
name: "Add generation candidate",
history: { mode: "ignore" },
execute({ state }, payload) {
const candidates = retainCandidateBudget([payload.candidate, ...state.editor.generation.candidates.filter((candidate) => candidate.id !== payload.candidate.id)]);
const existing = state.editor.generation.candidates.filter((candidate) => candidate.id !== payload.candidate.id);
const candidates = retainCandidateBudget([payload.candidate, ...existing.filter((candidate) => candidate.favorite), ...existing.filter((candidate) => !candidate.favorite)]);
return {
...state,
editor: {
@@ -130,6 +133,25 @@ export const generationRemoveCandidateCommand: Command<GenerationRemoveCandidate
},
};
export const generationToggleCandidateFavoriteCommand: Command<GenerationToggleCandidateFavoritePayload> = {
id: commandIds.generationToggleCandidateFavorite,
name: "Favorite generation candidate",
history: { mode: "ignore" },
execute({ state }, payload) {
if (!state.editor.generation.candidates.some((candidate) => candidate.id === payload.candidateId)) return state;
return {
...state,
editor: {
...state.editor,
generation: {
...state.editor.generation,
candidates: state.editor.generation.candidates.map((candidate) => candidate.id === payload.candidateId ? { ...candidate, favorite: !candidate.favorite } : candidate),
},
},
};
},
};
export const generationClearCandidatesCommand: Command = {
id: commandIds.generationClearCandidates,
name: "Clear generation candidates",
@@ -260,6 +282,17 @@ export const generationStartJobCommand: Command<GenerationStartJobPayload> = {
},
};
export const generationUpdateJobCommand: Command<GenerationUpdateJobPayload> = {
id: commandIds.generationUpdateJob,
name: "Update generation progress",
history: { mode: "ignore" },
execute({ state }, payload) {
const job = state.editor.generation.jobs.find((candidate) => candidate.id === payload.jobId);
if (!job || job.status !== "running" || !Number.isFinite(payload.progress) || !payload.detail.trim()) return state;
return updateJobs(state, state.editor.generation.jobs.map((candidate) => candidate.id === payload.jobId ? { ...candidate, progress: Math.max(0, Math.min(1, payload.progress)), detail: payload.detail.trim() } : candidate));
},
};
export const generationSucceedJobCommand: Command<GenerationSucceedJobPayload> = {
id: commandIds.generationSucceedJob,
name: "Complete generation job",
@@ -322,11 +355,13 @@ export const generationCommands = [
generationSelectCandidateCommand,
generationSetCompareModeCommand,
generationRemoveCandidateCommand,
generationToggleCandidateFavoriteCommand,
generationClearCandidatesCommand,
generationReuseCandidateSettingsCommand,
generationApplyCandidateAsLayerCommand,
generationReplaceCandidatePixelsCommand,
generationStartJobCommand,
generationUpdateJobCommand,
generationSucceedJobCommand,
generationFailJobCommand,
generationCancelJobCommand,
@@ -364,7 +399,7 @@ function retainCandidateBudget(candidates: GenerationCandidate[]): GenerationCan
}
function candidateRetainedBytes(candidate: GenerationCandidate): number {
return [candidate.source, candidate.inputImage, candidate.maskImage, candidate.inpaint?.inputImage, candidate.inpaint?.maskImage]
return [candidate.source, candidate.inputImage, candidate.maskImage, candidate.blendMaskImage, candidate.inpaint?.inputImage, candidate.inpaint?.maskImage, candidate.inpaint?.editMaskImage, candidate.inpaint?.blendMaskImage]
.reduce((total, source) => total + (source?.length ?? 0) * 2, 0);
}
@@ -441,11 +476,14 @@ function generationProvenance(candidate: GenerationCandidate, acceptance: Genera
scheduler: candidate.settings.scheduler,
width: candidate.settings.width,
height: candidate.settings.height,
batchSize: candidate.settings.batchSize,
refinePass: candidate.settings.refinePass,
refineStrength: candidate.settings.refineStrength,
},
inpaint: candidate.inpaint
? {
targetLayerId: candidate.inpaint.targetLayerId,
maskLayerId: candidate.inpaint.maskLayerId,
regionId: candidate.inpaint.regionId,
sourceAssetId: candidate.inpaint.sourceAssetId,
maskAssetId: candidate.inpaint.maskAssetId,
crop: {
@@ -465,6 +503,11 @@ function generationProvenance(candidate: GenerationCandidate, acceptance: Genera
maskFeather: candidate.inpaint.backend.maskFeather,
maskExpand: candidate.inpaint.backend.maskExpand,
cropPadding: candidate.inpaint.backend.cropPadding,
profile: candidate.settings.inpaint.profile,
structureControl: candidate.settings.inpaint.structureControl,
controlStrength: candidate.settings.inpaint.controlStrength,
controlModel: candidate.settings.inpaint.controlModel,
colorMatch: candidate.settings.inpaint.colorMatch,
},
}
: undefined,

View File

@@ -30,6 +30,9 @@ export const commandIds = {
documentAddLayerMask: "document.addLayerMask",
documentApplyLayerMaskOperation: "document.applyLayerMaskOperation",
documentRemoveLayerMask: "document.removeLayerMask",
documentAddInpaintRegion: "document.addInpaintRegion",
documentApplyInpaintRegionMaskOperation: "document.applyInpaintRegionMaskOperation",
documentRemoveInpaintRegion: "document.removeInpaintRegion",
selectionSet: "selection.set",
selectionClear: "selection.clear",
selectionAddLayer: "selection.addLayer",
@@ -41,8 +44,12 @@ export const commandIds = {
toolSetMagicWandSettings: "tool.setMagicWandSettings",
toolSetBrushPreview: "tool.setBrushPreview",
toolSetBrushStrokePreview: "tool.setBrushStrokePreview",
toolBeginMaskShape: "tool.beginMaskShape",
toolAppendMaskShape: "tool.appendMaskShape",
toolClearMaskShape: "tool.clearMaskShape",
toolSetMaskViewMode: "tool.setMaskViewMode",
toolEnterMaskEdit: "tool.enterMaskEdit",
toolEnterInpaintRegionEdit: "tool.enterInpaintRegionEdit",
toolExitMaskEdit: "tool.exitMaskEdit",
toolEnterTemporaryPan: "tool.enterTemporaryPan",
toolExitTemporaryPan: "tool.exitTemporaryPan",
@@ -50,11 +57,13 @@ export const commandIds = {
generationSelectCandidate: "generation.selectCandidate",
generationSetCompareMode: "generation.setCompareMode",
generationRemoveCandidate: "generation.removeCandidate",
generationToggleCandidateFavorite: "generation.toggleCandidateFavorite",
generationClearCandidates: "generation.clearCandidates",
generationReuseCandidateSettings: "generation.reuseCandidateSettings",
generationApplyCandidateAsLayer: "generation.applyCandidateAsLayer",
generationReplaceCandidatePixels: "generation.replaceCandidatePixels",
generationStartJob: "generation.startJob",
generationUpdateJob: "generation.updateJob",
generationSucceedJob: "generation.succeedJob",
generationFailJob: "generation.failJob",
generationCancelJob: "generation.cancelJob",

View File

@@ -101,10 +101,12 @@ export type { CommandRegistry } from "./registry";
export { createCommandRegistry } from "./registry";
export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection";
export type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
export { toolChooseGenerateIntentCommand, toolCommands, toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetChromaKeySettingsCommand, toolSetGenerateSettingsCommand, toolSetMagicWandSettingsCommand, toolSetMaskViewModeCommand } from "./tool";
export { toolChooseGenerateIntentCommand, toolCommands, toolEnterInpaintRegionEditCommand, toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetChromaKeySettingsCommand, toolSetGenerateSettingsCommand, toolSetMagicWandSettingsCommand, toolSetMaskViewModeCommand } from "./tool";
export { transformBeginCommand, transformCommands, transformEndCommand, transformSetBoundsCommand, transformSetRotationCommand, transformUpdateCommand } from "./transform";
export type { TransformBeginPayload, TransformSetBoundsPayload, TransformSetRotationPayload, TransformUpdatePayload } from "./transform";
export type { ToolChooseGenerateIntentPayload, ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
export type { ToolChooseGenerateIntentPayload, ToolEnterInpaintRegionEditPayload, ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
export { inpaintRegionCommands } from "./inpaint-region";
export type { DocumentAddInpaintRegionPayload, DocumentApplyInpaintRegionMaskOperationPayload, DocumentRemoveInpaintRegionPayload } from "./inpaint-region";
export {
viewportCommands,
viewportPanCommand,

View File

@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test";
import { createInitialAppState } from "@editor/initial-state";
import { documentAddInpaintRegionCommand, documentApplyInpaintRegionMaskOperationCommand, documentRemoveInpaintRegionCommand } from "./inpaint-region";
describe("inpaint region commands", () => {
test("persists an AI edit mask independently from layer visibility", () => {
const state = stateWithTarget();
const added = documentAddInpaintRegionCommand.execute({ state }, {
region: { id: "region", name: "Target AI edit", targetLayerId: "target", maskAssetId: "mask", enabled: true },
maskAsset: { id: "mask", name: "Mask", mimeType: "image/png", source: "black", intrinsicSize: { w: 64, h: 32 } },
});
expect(added.document.inpaintRegions).toHaveLength(1);
expect(added.document.artboards[0]?.layers[0]).not.toHaveProperty("layerMask");
expect(added.document.artboards[0]?.layers[0]?.visible).toBe(true);
const painted = documentApplyInpaintRegionMaskOperationCommand.execute({ state: added }, { regionId: "region", source: "painted", operation: { type: "paint" } });
expect(painted.document.assets.find((asset) => asset.id === "mask")?.source).toBe("painted");
const removed = documentRemoveInpaintRegionCommand.execute({ state: painted }, { regionId: "region" });
expect(removed.document.inpaintRegions).toHaveLength(0);
expect(removed.document.assets.some((asset) => asset.id === "mask")).toBe(false);
});
});
function stateWithTarget() {
const state = createInitialAppState("Test");
state.document.assets.push({ id: "source", name: "Source", mimeType: "image/png", source: "pixels", intrinsicSize: { w: 64, h: 32 } });
state.document.artboards.push({ id: "board", name: "Board", bounds: { x: 0, y: 0, w: 64, h: 32 }, backgroundColor: "transparent", visible: true, locked: false, layers: [
{ id: "target", type: "raster", name: "Target", visible: true, locked: false, opacity: 1, assetId: "source", transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 } },
] });
return state;
}

100
commands/inpaint-region.ts Normal file
View File

@@ -0,0 +1,100 @@
import type { Asset } from "@core/asset";
import type { InpaintRegion } from "@core/inpaint-region";
import type { InpaintRegionId } from "@core/id";
import type { Command } from "./command";
import { commandIds } from "./ids";
import type { LayerMaskOperation } from "./document";
import { findLayerLocation } from "./document-tree";
export type DocumentAddInpaintRegionPayload = {
region: InpaintRegion;
maskAsset: Asset;
};
export type DocumentApplyInpaintRegionMaskOperationPayload = {
regionId: InpaintRegionId;
source: string;
mimeType?: string;
operation: LayerMaskOperation;
};
export type DocumentRemoveInpaintRegionPayload = {
regionId: InpaintRegionId;
};
export const documentAddInpaintRegionCommand: Command<DocumentAddInpaintRegionPayload> = {
id: commandIds.documentAddInpaintRegion,
name: "Add inpaint region",
execute({ state }, payload) {
const target = findLayerLocation(state.document, payload.region.targetLayerId)?.layer;
if (!target || (target.type !== "image" && target.type !== "raster")) return state;
const targetAsset = state.document.assets.find((asset) => asset.id === target.assetId);
if (!targetAsset || state.document.inpaintRegions.some((region) => region.id === payload.region.id)) return state;
if (state.document.assets.some((asset) => asset.id === payload.maskAsset.id)) return state;
if (payload.region.maskAssetId !== payload.maskAsset.id || !payload.region.name.trim()) return state;
if (Math.round(payload.maskAsset.intrinsicSize.w) !== Math.round(targetAsset.intrinsicSize.w) || Math.round(payload.maskAsset.intrinsicSize.h) !== Math.round(targetAsset.intrinsicSize.h)) return state;
return {
...state,
document: {
...state.document,
assets: [...state.document.assets, payload.maskAsset],
inpaintRegions: [...state.document.inpaintRegions, { ...payload.region, name: payload.region.name.trim() }],
},
};
},
};
export const documentApplyInpaintRegionMaskOperationCommand: Command<DocumentApplyInpaintRegionMaskOperationPayload> = {
id: commandIds.documentApplyInpaintRegionMaskOperation,
name: "Apply inpaint region mask operation",
execute({ state }, payload) {
const region = state.document.inpaintRegions.find((candidate) => candidate.id === payload.regionId);
if (!region || !payload.source.trim()) return state;
return {
...state,
document: {
...state.document,
assets: state.document.assets.map((asset) => asset.id === region.maskAssetId ? { ...asset, source: payload.source, mimeType: payload.mimeType ?? asset.mimeType } : asset),
},
editor: {
...state.editor,
brushStrokePreview: state.editor.brushStrokePreview?.assetId === region.maskAssetId ? undefined : state.editor.brushStrokePreview,
},
};
},
};
export const documentRemoveInpaintRegionCommand: Command<DocumentRemoveInpaintRegionPayload> = {
id: commandIds.documentRemoveInpaintRegion,
name: "Remove inpaint region",
execute({ state }, payload) {
const region = state.document.inpaintRegions.find((candidate) => candidate.id === payload.regionId);
if (!region) return state;
const inpaintRegions = state.document.inpaintRegions.filter((candidate) => candidate.id !== payload.regionId);
const maskStillUsed = inpaintRegions.some((candidate) => candidate.maskAssetId === region.maskAssetId);
const exitingEdit = state.editor.maskEdit?.inpaintRegionId === region.id;
const contextualTool = state.editor.tools.activeTool === "semanticSelect" || state.editor.tools.activeTool === "maskLasso" || state.editor.tools.activeTool === "maskRectangle";
return {
...state,
document: {
...state.document,
inpaintRegions,
assets: maskStillUsed ? state.document.assets : state.document.assets.filter((asset) => asset.id !== region.maskAssetId),
},
editor: {
...state.editor,
maskEdit: exitingEdit ? undefined : state.editor.maskEdit,
brushStrokePreview: state.editor.brushStrokePreview?.assetId === region.maskAssetId ? undefined : state.editor.brushStrokePreview,
maskShapeSession: exitingEdit ? undefined : state.editor.maskShapeSession,
tools: exitingEdit && contextualTool ? { ...state.editor.tools, activeTool: "select", interactionMode: { type: "tool", tool: "select" } } : state.editor.tools,
},
};
},
};
export const inpaintRegionCommands = [
documentAddInpaintRegionCommand,
documentApplyInpaintRegionMaskOperationCommand,
documentRemoveInpaintRegionCommand,
] satisfies Command<unknown>[];

View File

@@ -35,11 +35,13 @@ import type {
GenerationAddCandidatePayload,
GenerationApplyCandidateAsLayerPayload,
GenerationRemoveCandidatePayload,
GenerationToggleCandidateFavoritePayload,
GenerationReuseCandidateSettingsPayload,
GenerationReplaceCandidatePixelsPayload,
GenerationSelectCandidatePayload,
GenerationSetCompareModePayload,
GenerationStartJobPayload,
GenerationUpdateJobPayload,
GenerationSucceedJobPayload,
GenerationFailJobPayload,
GenerationCancelJobPayload,
@@ -52,11 +54,12 @@ import type {
CommandPaletteSetSelectedIndexPayload,
} from "./palette";
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
import type { ToolChooseGenerateIntentPayload, ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
import type { ToolAppendMaskShapePayload, ToolBeginMaskShapePayload, ToolChooseGenerateIntentPayload, ToolEnterInpaintRegionEditPayload, ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
import type { TransformBeginPayload, TransformSetBoundsPayload, TransformSetRotationPayload, TransformUpdatePayload } from "./transform";
import type { WorkspaceSetPanelPayload } from "./workspace";
import type { EditorSetPointerSessionPayload } from "./editor";
import type { ProjectOpenPayload } from "./project";
import type { DocumentAddInpaintRegionPayload, DocumentApplyInpaintRegionMaskOperationPayload, DocumentRemoveInpaintRegionPayload } from "./inpaint-region";
import type {
ViewportFitArtboardPayload,
ViewportPanPayload,
@@ -86,6 +89,9 @@ export type CommandPayloads = {
[commandIds.documentAddLayerMask]: DocumentAddLayerMaskPayload;
[commandIds.documentApplyLayerMaskOperation]: DocumentApplyLayerMaskOperationPayload;
[commandIds.documentRemoveLayerMask]: DocumentRemoveLayerMaskPayload;
[commandIds.documentAddInpaintRegion]: DocumentAddInpaintRegionPayload;
[commandIds.documentApplyInpaintRegionMaskOperation]: DocumentApplyInpaintRegionMaskOperationPayload;
[commandIds.documentRemoveInpaintRegion]: DocumentRemoveInpaintRegionPayload;
[commandIds.documentMoveLayer]: DocumentMoveLayerPayload;
[commandIds.documentGroupLayers]: DocumentGroupLayersPayload;
[commandIds.documentUngroupLayer]: DocumentUngroupLayerPayload;
@@ -108,8 +114,12 @@ export type CommandPayloads = {
[commandIds.toolSetMagicWandSettings]: ToolSetMagicWandSettingsPayload;
[commandIds.toolSetBrushPreview]: ToolSetBrushPreviewPayload;
[commandIds.toolSetBrushStrokePreview]: ToolSetBrushStrokePreviewPayload;
[commandIds.toolBeginMaskShape]: ToolBeginMaskShapePayload;
[commandIds.toolAppendMaskShape]: ToolAppendMaskShapePayload;
[commandIds.toolClearMaskShape]: void;
[commandIds.toolSetMaskViewMode]: ToolSetMaskViewModePayload;
[commandIds.toolEnterMaskEdit]: ToolEnterMaskEditPayload;
[commandIds.toolEnterInpaintRegionEdit]: ToolEnterInpaintRegionEditPayload;
[commandIds.toolExitMaskEdit]: void;
[commandIds.toolEnterTemporaryPan]: void;
[commandIds.toolExitTemporaryPan]: void;
@@ -117,11 +127,13 @@ export type CommandPayloads = {
[commandIds.generationSelectCandidate]: GenerationSelectCandidatePayload;
[commandIds.generationSetCompareMode]: GenerationSetCompareModePayload;
[commandIds.generationRemoveCandidate]: GenerationRemoveCandidatePayload;
[commandIds.generationToggleCandidateFavorite]: GenerationToggleCandidateFavoritePayload;
[commandIds.generationClearCandidates]: void;
[commandIds.generationReuseCandidateSettings]: GenerationReuseCandidateSettingsPayload;
[commandIds.generationApplyCandidateAsLayer]: GenerationApplyCandidateAsLayerPayload;
[commandIds.generationReplaceCandidatePixels]: GenerationReplaceCandidatePixelsPayload;
[commandIds.generationStartJob]: GenerationStartJobPayload;
[commandIds.generationUpdateJob]: GenerationUpdateJobPayload;
[commandIds.generationSucceedJob]: GenerationSucceedJobPayload;
[commandIds.generationFailJob]: GenerationFailJobPayload;
[commandIds.generationCancelJob]: GenerationCancelJobPayload;

View File

@@ -1,11 +1,12 @@
import { describe, expect, test } from "bun:test";
import { createInitialAppState } from "@editor/initial-state";
import { toolChooseGenerateIntentCommand, toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetChromaKeySettingsCommand, toolSetGenerateSettingsCommand, toolSetMaskViewModeCommand } from "./tool";
import { initialToolState } from "@editor/tools";
import { toolAppendMaskShapeCommand, toolBeginMaskShapeCommand, toolChooseGenerateIntentCommand, toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetChromaKeySettingsCommand, toolSetGenerateSettingsCommand, toolSetMaskViewModeCommand } from "./tool";
const defaultBrush = { color: "#111827", size: 8, hardness: 100 };
const defaultBrush = { color: "#111827", size: 8, hardness: 100, opacity: 100, flow: 100, smoothing: 20, pressureSize: true };
const defaultChromaKey = { color: "#00ff00", tolerance: 32, softness: 24, feather: 0, choke: 0, despeckle: 0, spill: 50 };
const defaultMagicWand = { tolerance: 32, feather: 0, choke: 0, despeckle: 0, contiguous: true, mode: "replace" as const };
const defaultGenerate = { architecture: "sdxl" as const, mode: "text-to-image" as const, model: "auto" as const, textEncoder: "auto", vae: "auto", prompt: "", negativePrompt: "", strength: 75, steps: 30, cfg: 7, seed: -1, sampler: "euler", scheduler: "normal", width: 1024, height: 1024, outpaint: { left: 128, top: 128, right: 128, bottom: 128, feathering: 32 }, inpaint: { maskedAreaOnly: true, cropPadding: 96, maskPolarity: "hidden" as const, maskedContent: "neutral" as const, growMaskBy: 6, maskExpand: 0, maskFeather: 0, maskBlur: 0, maskDespeckle: 0 } };
const defaultGenerate = initialToolState.generate;
describe("tool commands", () => {
test("sets active tool", () => {
@@ -24,7 +25,7 @@ describe("tool commands", () => {
test("sets brush settings", () => {
const next = toolSetBrushSettingsCommand.execute({ state: createInitialAppState("Test") }, { color: "#ff0000", size: 24, hardness: 50 });
expect(next.editor.tools.brush).toEqual({ color: "#ff0000", size: 24, hardness: 50 });
expect(next.editor.tools.brush).toEqual({ ...defaultBrush, color: "#ff0000", size: 24, hardness: 50 });
});
test("sets chroma key settings", () => {
@@ -43,6 +44,11 @@ describe("tool commands", () => {
expect(next.editor.tools.generate.inpaint).toEqual({ ...defaultGenerate.inpaint, cropPadding: 2048, growMaskBy: 0, maskExpand: -256, maskBlur: 256, maskPolarity: "revealed", maskedContent: "original" });
});
test("applies outcome-oriented inpaint profile defaults", () => {
const next = toolSetGenerateSettingsCommand.execute({ state: createInitialAppState("Test") }, { mode: "inpaint", inpaint: { profile: "material" } });
expect(next.editor.tools.generate).toMatchObject({ strength: 65, inpaint: { profile: "material", maskedContent: "edges", structureControl: "canny", controlStrength: 0.55, colorMatch: false } });
});
test("applies architecture defaults and filters unsupported modes", () => {
const initial = toolSetGenerateSettingsCommand.execute({ state: createInitialAppState("Test") }, { mode: "inpaint" });
const next = toolSetGenerateSettingsCommand.execute({ state: initial }, { architecture: "z-image-turbo" });
@@ -69,6 +75,11 @@ describe("tool commands", () => {
expect(variations.editor.tools.generate).toMatchObject({ architecture: "sdxl", mode: "image-to-image" });
});
test("maps object removal to the dedicated inpaint profile", () => {
const removed = toolChooseGenerateIntentCommand.execute({ state: createInitialAppState("Test") }, { intent: "remove" });
expect(removed.editor.tools.generate).toMatchObject({ architecture: "sdxl", mode: "inpaint", strength: 100, inpaint: { profile: "remove", maskedContent: "neutral", maskExpand: 12 } });
});
test("sets and clears brush preview", () => {
const showing = toolSetBrushPreviewCommand.execute({ state: createInitialAppState("Test") }, { position: { x: 10, y: 20 } });
const cleared = toolSetBrushPreviewCommand.execute({ state: showing }, undefined);
@@ -85,11 +96,25 @@ describe("tool commands", () => {
expect(cleared.editor.brushStrokePreview).toBeUndefined();
});
test("keeps rectangle mask gestures as two command-owned corner points", () => {
const state = createInitialAppState("Test");
state.editor = {
...state.editor,
tools: { ...state.editor.tools, activeTool: "maskRectangle", interactionMode: { type: "tool", tool: "maskRectangle" } },
maskEdit: { kind: "inpaintRegion", targetLayerId: "target", inpaintRegionId: "region", maskAssetId: "mask" },
};
const begun = toolBeginMaskShapeCommand.execute({ state }, { point: { x: 10, y: 20 }, mode: "add" });
const moved = toolAppendMaskShapeCommand.execute({ state: begun }, { point: { x: 40, y: 60 } });
const movedAgain = toolAppendMaskShapeCommand.execute({ state: moved }, { point: { x: 50, y: 70 } });
expect(movedAgain.editor.maskShapeSession).toEqual({ shape: "rectangle", mode: "add", points: [{ x: 10, y: 20 }, { x: 50, y: 70 }] });
});
test("enters and exits mask edit", () => {
const editing = toolEnterMaskEditCommand.execute({ state: stateWithMask() }, { targetLayerId: "target", maskLayerId: "mask" });
const exited = toolExitMaskEditCommand.execute({ state: editing }, undefined);
expect(editing.editor.maskEdit).toEqual({ targetLayerId: "target", maskLayerId: "mask" });
expect(editing.editor.maskEdit).toEqual({ kind: "layerMask", targetLayerId: "target", maskLayerId: "mask", maskAssetId: "mask-asset" });
expect(editing.editor.selection).toEqual({ artboardId: "a1", layerIds: ["target"] });
expect(editing.editor.tools.activeTool).toBe("brush");
expect(exited.editor.maskEdit).toBeUndefined();
@@ -100,7 +125,7 @@ describe("tool commands", () => {
const alpha = toolSetMaskViewModeCommand.execute({ state: editing }, { mode: "alpha" });
const ignored = toolSetMaskViewModeCommand.execute({ state: createInitialAppState("Test") }, { mode: "blackWhite" });
expect(alpha.editor.maskEdit).toEqual({ targetLayerId: "target", maskLayerId: "mask", viewMode: "alpha" });
expect(alpha.editor.maskEdit).toEqual({ kind: "layerMask", targetLayerId: "target", maskLayerId: "mask", maskAssetId: "mask-asset", viewMode: "alpha" });
expect(ignored.editor.maskEdit).toBeUndefined();
});

View File

@@ -1,10 +1,10 @@
import type { ImageDocument } from "@core/document";
import type { Vec2D } from "@core/geometry";
import type { LayerId, ArtboardId, AssetId } from "@core/id";
import type { LayerId, ArtboardId, AssetId, InpaintRegionId } 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 { generateArchitectureDefaults, inpaintProfileDefaults } from "@editor/tools";
import type { BrushSettings, ChromaKeySettings, GenerateIntent, GenerateSettings, MagicWandSettings, ToolId } from "@editor/tools";
import type { Command } from "./command";
import { commandIds } from "./ids";
@@ -15,7 +15,10 @@ export type ToolSetActivePayload = {
export type ToolSetBrushSettingsPayload = Partial<BrushSettings>;
export type ToolSetGenerateSettingsPayload = Partial<GenerateSettings>;
export type ToolSetGenerateSettingsPayload = Omit<Partial<GenerateSettings>, "inpaint" | "outpaint"> & {
inpaint?: Partial<GenerateSettings["inpaint"]>;
outpaint?: Partial<GenerateSettings["outpaint"]>;
};
export type ToolChooseGenerateIntentPayload = { intent: GenerateIntent };
export type ToolSetChromaKeySettingsPayload = Partial<ChromaKeySettings>;
@@ -35,12 +38,19 @@ export type ToolSetBrushStrokePreviewPayload =
export type ToolSetMaskViewModePayload = {
mode: MaskViewMode;
};
export type ToolBeginMaskShapePayload = { point: Vec2D; mode: "replace" | "add" | "subtract" };
export type ToolAppendMaskShapePayload = { point: Vec2D };
export type ToolEnterMaskEditPayload = {
targetLayerId: LayerId;
maskLayerId: LayerId;
};
export type ToolEnterInpaintRegionEditPayload = {
targetLayerId: LayerId;
regionId: InpaintRegionId;
};
export const toolSetActiveCommand: Command<ToolSetActivePayload> = {
id: commandIds.toolSetActive,
name: "Set active tool",
@@ -56,6 +66,7 @@ export const toolSetActiveCommand: Command<ToolSetActivePayload> = {
},
brushPreview: undefined,
brushStrokePreview: undefined,
maskShapeSession: undefined,
workspace: state.editor.workspace.panel === "generate" || state.editor.workspace.panel === "chromaKey"
? { panel: "none" }
: state.editor.workspace,
@@ -74,6 +85,8 @@ export const toolSetGenerateSettingsCommand: Command<ToolSetGenerateSettingsPayl
const defaults = generateArchitectureDefaults[architecture];
const requestedMode = payload.mode ?? current.mode;
const mode = defaults.supportedModes.includes(requestedMode) ? requestedMode : defaults.supportedModes[0] ?? "text-to-image";
const requestedProfile = payload.inpaint?.profile ?? current.inpaint.profile;
const profileDefaults = requestedProfile !== current.inpaint.profile && requestedProfile !== "custom" ? inpaintProfileDefaults[requestedProfile] : undefined;
return {
...state,
editor: {
@@ -88,7 +101,7 @@ export const toolSetGenerateSettingsCommand: Command<ToolSetGenerateSettingsPayl
vae: payload.vae ?? (architectureChanged ? defaults.vae : current.vae),
prompt: payload.prompt ?? current.prompt,
negativePrompt: payload.negativePrompt ?? current.negativePrompt,
strength: clampNumber(payload.strength ?? current.strength, 0, 100),
strength: clampNumber(payload.strength ?? profileDefaults?.strength ?? current.strength, 0, 100),
steps: Math.round(clampNumber(payload.steps ?? (architectureChanged ? defaults.steps : current.steps), 1, 150)),
cfg: clampNumber(payload.cfg ?? (architectureChanged ? defaults.cfg : current.cfg), 0, 30),
seed: Math.round(clampNumber(payload.seed ?? current.seed, -1, Number.MAX_SAFE_INTEGER)),
@@ -96,6 +109,9 @@ export const toolSetGenerateSettingsCommand: Command<ToolSetGenerateSettingsPayl
scheduler: payload.scheduler ?? (architectureChanged ? defaults.scheduler : current.scheduler),
width: Math.round(clampNumber(payload.width ?? current.width, 64, 4096)),
height: Math.round(clampNumber(payload.height ?? current.height, 64, 4096)),
batchSize: Math.round(clampNumber(payload.batchSize ?? current.batchSize, 1, 8)),
refinePass: payload.refinePass ?? profileDefaults?.refinePass ?? current.refinePass,
refineStrength: clampNumber(payload.refineStrength ?? profileDefaults?.refineStrength ?? current.refineStrength, 0, 100),
outpaint: {
left: Math.round(clampNumber(payload.outpaint?.left ?? current.outpaint.left, 0, 2048)),
top: Math.round(clampNumber(payload.outpaint?.top ?? current.outpaint.top, 0, 2048)),
@@ -104,15 +120,20 @@ export const toolSetGenerateSettingsCommand: Command<ToolSetGenerateSettingsPayl
feathering: Math.round(clampNumber(payload.outpaint?.feathering ?? current.outpaint.feathering, 0, 512)),
},
inpaint: {
profile: requestedProfile,
maskedAreaOnly: payload.inpaint?.maskedAreaOnly ?? current.inpaint.maskedAreaOnly,
cropPadding: Math.round(clampNumber(payload.inpaint?.cropPadding ?? current.inpaint.cropPadding, 0, 2048)),
cropPadding: Math.round(clampNumber(payload.inpaint?.cropPadding ?? profileDefaults?.inpaint.cropPadding ?? current.inpaint.cropPadding, 0, 2048)),
maskPolarity: payload.inpaint?.maskPolarity ?? current.inpaint.maskPolarity,
maskedContent: payload.inpaint?.maskedContent ?? current.inpaint.maskedContent,
maskedContent: payload.inpaint?.maskedContent ?? profileDefaults?.inpaint.maskedContent ?? current.inpaint.maskedContent,
growMaskBy: Math.round(clampNumber(payload.inpaint?.growMaskBy ?? current.inpaint.growMaskBy, 0, 256)),
maskExpand: Math.round(clampNumber(payload.inpaint?.maskExpand ?? current.inpaint.maskExpand, -256, 256)),
maskFeather: Math.round(clampNumber(payload.inpaint?.maskFeather ?? current.inpaint.maskFeather, 0, 256)),
maskExpand: Math.round(clampNumber(payload.inpaint?.maskExpand ?? profileDefaults?.inpaint.maskExpand ?? current.inpaint.maskExpand, -256, 256)),
maskFeather: Math.round(clampNumber(payload.inpaint?.maskFeather ?? profileDefaults?.inpaint.maskFeather ?? current.inpaint.maskFeather, 0, 256)),
maskBlur: Math.round(clampNumber(payload.inpaint?.maskBlur ?? current.inpaint.maskBlur, 0, 256)),
maskDespeckle: Math.round(clampNumber(payload.inpaint?.maskDespeckle ?? current.inpaint.maskDespeckle, 0, 64)),
structureControl: payload.inpaint?.structureControl ?? profileDefaults?.inpaint.structureControl ?? current.inpaint.structureControl,
controlStrength: clampNumber(payload.inpaint?.controlStrength ?? profileDefaults?.inpaint.controlStrength ?? current.inpaint.controlStrength, 0, 1),
controlModel: payload.inpaint?.controlModel ?? current.inpaint.controlModel,
colorMatch: payload.inpaint?.colorMatch ?? profileDefaults?.inpaint.colorMatch ?? current.inpaint.colorMatch,
},
},
},
@@ -124,6 +145,7 @@ export const toolSetGenerateSettingsCommand: Command<ToolSetGenerateSettingsPayl
const modeByGenerateIntent: Record<GenerateIntent, GenerateSettings["mode"]> = {
create: "text-to-image",
replace: "inpaint",
remove: "inpaint",
extend: "outpaint",
variations: "image-to-image",
};
@@ -136,7 +158,7 @@ export const toolChooseGenerateIntentCommand: Command<ToolChooseGenerateIntentPa
const architecture = generateArchitectureDefaults[state.editor.tools.generate.architecture].supportedModes.includes(mode)
? state.editor.tools.generate.architecture
: "sdxl";
return toolSetGenerateSettingsCommand.execute({ state }, { architecture, mode });
return toolSetGenerateSettingsCommand.execute({ state }, { architecture, mode, inpaint: payload.intent === "remove" ? { profile: "remove" } : payload.intent === "replace" && state.editor.tools.generate.inpaint.profile === "remove" ? { profile: "replace" } : undefined });
},
};
@@ -154,6 +176,10 @@ export const toolSetBrushSettingsCommand: Command<ToolSetBrushSettingsPayload> =
color: payload.color ?? state.editor.tools.brush.color,
size: clampNumber(payload.size ?? state.editor.tools.brush.size, 1, 200),
hardness: clampNumber(payload.hardness ?? state.editor.tools.brush.hardness, 0, 100),
opacity: clampNumber(payload.opacity ?? state.editor.tools.brush.opacity, 0, 100),
flow: clampNumber(payload.flow ?? state.editor.tools.brush.flow, 1, 100),
smoothing: clampNumber(payload.smoothing ?? state.editor.tools.brush.smoothing, 0, 100),
pressureSize: payload.pressureSize ?? state.editor.tools.brush.pressureSize,
},
},
},
@@ -270,6 +296,43 @@ export const toolSetMaskViewModeCommand: Command<ToolSetMaskViewModePayload> = {
},
};
export const toolBeginMaskShapeCommand: Command<ToolBeginMaskShapePayload> = {
id: commandIds.toolBeginMaskShape,
name: "Begin mask lasso",
execute({ state }, payload) {
const tool = state.editor.tools.activeTool;
if ((tool !== "maskLasso" && tool !== "maskRectangle") || state.editor.maskEdit?.kind !== "inpaintRegion") return state;
return { ...state, editor: { ...state.editor, maskShapeSession: { shape: tool === "maskRectangle" ? "rectangle" : "lasso", points: [{ ...payload.point }], mode: payload.mode } } };
},
};
export const toolAppendMaskShapeCommand: Command<ToolAppendMaskShapePayload> = {
id: commandIds.toolAppendMaskShape,
name: "Append mask lasso point",
history: { mode: "ignore" },
execute({ state }, payload) {
const session = state.editor.maskShapeSession;
if (!session) return state;
if (session.shape === "rectangle") {
const start = session.points[0];
if (!start) return state;
return { ...state, editor: { ...state.editor, maskShapeSession: { ...session, points: [start, { ...payload.point }] } } };
}
const previous = session.points[session.points.length - 1];
if (previous && Math.hypot(payload.point.x - previous.x, payload.point.y - previous.y) < 1) return state;
return { ...state, editor: { ...state.editor, maskShapeSession: { ...session, points: [...session.points, { ...payload.point }] } } };
},
};
export const toolClearMaskShapeCommand: Command = {
id: commandIds.toolClearMaskShape,
name: "Clear mask lasso",
history: { mode: "ignore" },
execute({ state }) {
return state.editor.maskShapeSession ? { ...state, editor: { ...state.editor, maskShapeSession: undefined } } : state;
},
};
export const toolEnterMaskEditCommand: Command<ToolEnterMaskEditPayload> = {
id: commandIds.toolEnterMaskEdit,
name: "Enter mask edit",
@@ -278,14 +341,41 @@ export const toolEnterMaskEditCommand: Command<ToolEnterMaskEditPayload> = {
const maskLocation = findLayerLocation(state.document, payload.maskLayerId);
if (!targetLocation || !maskLocation) return state;
if (getLayerMask(targetLocation.layer)?.maskLayerId !== payload.maskLayerId) return state;
if (maskLocation.layer.type === "group") return state;
if (maskLocation.layer.type !== "image" && maskLocation.layer.type !== "raster") return state;
return {
...state,
editor: {
...state.editor,
selection: { artboardId: targetLocation.artboardId, layerIds: [payload.targetLayerId] },
maskEdit: { targetLayerId: payload.targetLayerId, maskLayerId: payload.maskLayerId },
maskEdit: { kind: "layerMask", targetLayerId: payload.targetLayerId, maskLayerId: payload.maskLayerId, maskAssetId: maskLocation.layer.assetId },
brushPreview: undefined,
brushStrokePreview: undefined,
tools: {
...state.editor.tools,
activeTool: "brush",
interactionMode: { type: "tool", tool: "brush" },
},
},
};
},
};
export const toolEnterInpaintRegionEditCommand: Command<ToolEnterInpaintRegionEditPayload> = {
id: commandIds.toolEnterInpaintRegionEdit,
name: "Enter inpaint region edit",
execute({ state }, payload) {
const targetLocation = findLayerLocation(state.document, payload.targetLayerId);
const region = state.document.inpaintRegions.find((candidate) => candidate.id === payload.regionId && candidate.targetLayerId === payload.targetLayerId);
const maskAsset = region ? state.document.assets.find((asset) => asset.id === region.maskAssetId) : undefined;
if (!targetLocation || !region || !maskAsset) return state;
return {
...state,
editor: {
...state.editor,
selection: { artboardId: targetLocation.artboardId, layerIds: [payload.targetLayerId] },
maskEdit: { kind: "inpaintRegion", targetLayerId: payload.targetLayerId, inpaintRegionId: region.id, maskAssetId: region.maskAssetId, viewMode: "overlay" },
brushPreview: undefined,
brushStrokePreview: undefined,
tools: {
@@ -303,6 +393,7 @@ export const toolExitMaskEditCommand: Command = {
name: "Exit mask edit",
execute({ state }) {
if (!state.editor.maskEdit) return state;
const contextualTool = state.editor.tools.activeTool === "semanticSelect" || state.editor.tools.activeTool === "maskLasso" || state.editor.tools.activeTool === "maskRectangle";
return {
...state,
@@ -311,6 +402,8 @@ export const toolExitMaskEditCommand: Command = {
maskEdit: undefined,
brushPreview: undefined,
brushStrokePreview: undefined,
maskShapeSession: undefined,
tools: contextualTool ? { ...state.editor.tools, activeTool: "select", interactionMode: { type: "tool", tool: "select" } } : state.editor.tools,
},
};
},
@@ -366,7 +459,11 @@ export const toolCommands = [
toolSetBrushPreviewCommand,
toolSetBrushStrokePreviewCommand,
toolSetMaskViewModeCommand,
toolBeginMaskShapeCommand,
toolAppendMaskShapeCommand,
toolClearMaskShapeCommand,
toolEnterMaskEditCommand,
toolEnterInpaintRegionEditCommand,
toolExitMaskEditCommand,
toolEnterTemporaryPanCommand,
toolExitTemporaryPanCommand,