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

@@ -194,12 +194,16 @@ export function App({ app }: AppProps) {
activeTool={tools.activeTool}
interactionMode={tools.interactionMode}
panel={workspace.panel}
inpaintMaskEditing={maskEdit?.kind === "inpaintRegion"}
dispatch={app.store.dispatch}
/>
</div>
<GenerateSheet
settings={tools.generate}
document={document}
selection={selection}
resources={generation.resources}
generation={generation}
open={generateOpen}
dispatch={app.store.dispatch}
/>
@@ -216,7 +220,7 @@ export function App({ app }: AppProps) {
document={document}
selection={selection}
viewport={viewport}
visible={generateOpen || chromaKeyOpen || tools.activeTool === "brush" || tools.activeTool === "eraser" || tools.activeTool === "magicWand" || Boolean(transformBounds) || viewportActivityIsland.visible}
visible={generateOpen || chromaKeyOpen || tools.activeTool === "brush" || tools.activeTool === "eraser" || tools.activeTool === "magicWand" || tools.activeTool === "semanticSelect" || tools.activeTool === "maskLasso" || tools.activeTool === "maskRectangle" || Boolean(transformBounds) || viewportActivityIsland.visible}
action={viewportActivityIsland.action}
activeTool={tools.activeTool}
operation={generateOpen ? "generate" : chromaKeyOpen ? "chromaKey" : undefined}
@@ -226,6 +230,7 @@ export function App({ app }: AppProps) {
chromaKeySettings={tools.chromaKey}
magicWandSettings={tools.magicWand}
editingMask={Boolean(maskEdit)}
maskKind={maskEdit?.kind}
maskViewMode={maskEdit?.viewMode ?? "composite"}
brushHint={brushHint}
transformBounds={viewportActivityIsland.visible ? undefined : transformBounds}

View File

@@ -31,6 +31,7 @@ export type BottomControlsIslandProps = {
chromaKeySettings: ChromaKeySettings;
magicWandSettings: MagicWandSettings;
editingMask?: boolean;
maskKind?: "layerMask" | "inpaintRegion";
maskViewMode?: MaskViewMode;
transformBounds?: Rect;
transformTarget?: TransformTarget;
@@ -40,7 +41,7 @@ export type BottomControlsIslandProps = {
documentActions: DocumentActions;
};
export function BottomControlsIsland({ document, selection, viewport, visible, action, activeTool, operation, brushSettings, generateSettings, generation, chromaKeySettings, magicWandSettings, editingMask = false, maskViewMode = "composite", transformBounds, transformTarget, brushHint, dispatch, generationWorkflow, documentActions }: BottomControlsIslandProps) {
export function BottomControlsIsland({ document, selection, viewport, visible, action, activeTool, operation, brushSettings, generateSettings, generation, chromaKeySettings, magicWandSettings, editingMask = false, maskKind, maskViewMode = "composite", transformBounds, transformTarget, brushHint, dispatch, generationWorkflow, documentActions }: BottomControlsIslandProps) {
const documentIndex = useMemo(() => createDocumentReadIndex(document), [document]);
const selectedLayerInfo = selection.layerIds.length === 1 && selection.layerIds[0] ? documentIndex.layerInfoById.get(selection.layerIds[0]) : undefined;
const zoomPercent = Math.round(viewport.zoom * 100);
@@ -61,9 +62,13 @@ export function BottomControlsIsland({ document, selection, viewport, visible, a
) : (activeTool === "brush" || activeTool === "eraser") && brushHint ? (
<BrushHint tool={activeTool} hint={brushHint} />
) : activeTool === "brush" || activeTool === "eraser" ? (
<BrushControls tool={activeTool} settings={brushSettings} editingMask={editingMask} maskViewMode={maskViewMode} dispatch={dispatch} />
<BrushControls tool={activeTool} settings={brushSettings} editingMask={editingMask} maskKind={maskKind} maskViewMode={maskViewMode} dispatch={dispatch} />
) : activeTool === "magicWand" ? (
<MagicWandControls settings={magicWandSettings} dispatch={dispatch} />
) : activeTool === "semanticSelect" ? (
<div className="flex items-center gap-3 px-4 text-sm text-white/75"><strong className="text-white">AI object select</strong><span>Click an object · Shift-click adds · Option-click protects</span></div>
) : activeTool === "maskLasso" || activeTool === "maskRectangle" ? (
<div className="flex items-center gap-3 px-4 text-sm text-white/75"><strong className="text-white">AI region {activeTool === "maskRectangle" ? "rectangle" : "lasso"}</strong><span>Drag to replace · Shift-drag adds · Option-drag protects</span></div>
) : transformBounds && transformTarget ? (
<TransformControls bounds={transformBounds} target={transformTarget} documentIndex={documentIndex} layerInfo={selectedLayerInfo} dispatch={dispatch} />
) : action === "pan" ? (

View File

@@ -105,5 +105,5 @@ function interactionModesEqual(a: InteractionMode, b: InteractionMode): boolean
function maskEditStatesEqual(a: MaskEditState | undefined, b: MaskEditState | undefined): boolean {
if (a === b) return true;
if (!a || !b) return false;
return a.targetLayerId === b.targetLayerId && a.maskLayerId === b.maskLayerId && a.viewMode === b.viewMode;
return a.kind === b.kind && a.targetLayerId === b.targetLayerId && a.maskAssetId === b.maskAssetId && a.maskLayerId === b.maskLayerId && a.inpaintRegionId === b.inpaintRegionId && a.viewMode === b.viewMode;
}

View File

@@ -1,16 +1,22 @@
import type { GenerateSettings } from "@editor/tools";
import type { AppStore } from "@editor/store";
import type { GenerationResourcesState } from "@editor/state";
import type { GenerationResourcesState, GenerationState } from "@editor/state";
import { GenerateControls } from "./bottom-controls/GenerateControls";
import type { ImageDocument } from "@core/document";
import type { SelectionState } from "@editor/state";
import { CandidateReviewPanel } from "./inpaint/CandidateReviewPanel";
export type GenerateSheetProps = {
settings: GenerateSettings;
document: ImageDocument;
selection: SelectionState;
resources: GenerationResourcesState;
generation: GenerationState;
open: boolean;
dispatch: AppStore["dispatch"];
};
export function GenerateSheet({ settings, resources, open, dispatch }: GenerateSheetProps) {
export function GenerateSheet({ settings, document, selection, resources, generation, open, dispatch }: GenerateSheetProps) {
return (
<aside
id="generate-sheet"
@@ -22,7 +28,8 @@ export function GenerateSheet({ settings, resources, open, dispatch }: GenerateS
>
{open ? (
<div className="subtle-scrollbar min-h-0 flex-1 overflow-auto py-4">
<GenerateControls settings={settings} resources={resources} dispatch={dispatch} />
<CandidateReviewPanel generation={generation} dispatch={dispatch} />
<GenerateControls settings={settings} document={document} selection={selection} resources={resources} dispatch={dispatch} />
</div>
) : null}
</aside>

View File

@@ -17,7 +17,7 @@ export function GenerationJobStatus({ generation, compact = false }: { generatio
if (!job) return null;
const elapsed = Math.max(0, Math.floor(((job.finishedAt ?? Date.now()) - job.startedAt) / 1000));
const label = job.status === "running" ? `${job.label} ${formatElapsed(elapsed)}` : job.status === "failed" ? job.error ?? `${job.label} failed` : job.status === "cancelled" ? `${job.label} cancelled` : `${job.label} complete`;
const label = job.status === "running" ? `${job.detail ?? job.label}${job.progress !== undefined ? ` · ${Math.round(job.progress * 100)}%` : ""} ${formatElapsed(elapsed)}` : job.status === "failed" ? job.error ?? `${job.label} failed` : job.status === "cancelled" ? `${job.label} cancelled` : `${job.label} complete`;
const tone = job.status === "failed" ? "bg-red-500/15 text-red-100" : job.status === "running" ? "bg-white/10 text-white/70" : job.status === "cancelled" ? "bg-amber-500/15 text-amber-100" : "bg-emerald-500/15 text-emerald-100";
return <span className={`${compact ? "max-w-48" : "max-w-72"} truncate rounded-md px-2 py-1 text-[0.68rem] font-medium ${tone}`} title={label} aria-live="polite">{label}</span>;

View File

@@ -346,8 +346,11 @@ function LayerRow({
const layerMask = getLayerMask(layer);
const maskLayer = layerMask ? documentIndex.layerById.get(layerMask.maskLayerId) : undefined;
const maskAsset = maskLayer && (maskLayer.type === "image" || maskLayer.type === "raster") ? documentIndex.assetById.get(maskLayer.assetId) : undefined;
const inpaintRegion = document.inpaintRegions.find((region) => region.targetLayerId === layer.id && region.enabled);
const inpaintMaskAsset = inpaintRegion ? documentIndex.assetById.get(inpaintRegion.maskAssetId) : undefined;
const canAddMask = Boolean(layerInfo && (layer.type === "image" || layer.type === "raster") && !layerMask);
const editingMask = Boolean(maskEdit && layerMask && maskEdit.targetLayerId === layer.id && maskEdit.maskLayerId === layerMask.maskLayerId);
const editingInpaintRegion = Boolean(maskEdit?.kind === "inpaintRegion" && inpaintRegion && maskEdit.inpaintRegionId === inpaintRegion.id);
const thumbnail = thumbnailByLayerId.get(layer.id) ?? { kind: "empty" };
const maskThumbnail = maskLayer ? thumbnailByLayerId.get(maskLayer.id) : undefined;
const rowPadding = 12 + depth * 16;
@@ -479,6 +482,16 @@ function LayerRow({
</button>
</div>
) : null}
{inpaintRegion ? (
<div role="group" aria-label={`AI edit region attached to ${layer.name}`} className={`relative mt-1 ml-8 flex min-h-11 flex-wrap items-center gap-1.5 border-l-2 py-1.5 pl-3 pr-1.5 text-xs transition ${editingInpaintRegion ? "border-red-300 text-red-50" : "border-red-300/25 text-red-100/65"}`}>
<span aria-hidden="true" className="absolute -left-2 top-1/2 h-px w-2 bg-red-300/40" />
{inpaintMaskAsset ? <img src={inpaintMaskAsset.source} alt={`${layer.name} AI edit mask preview`} className="h-8 w-11 rounded-md bg-black/30 object-cover ring-1 ring-red-300/30" /> : <Stack size={24} weight="fill" />}
<span className="min-w-28 flex-1 truncate font-medium">{editingInpaintRegion ? "Editing AI region" : inpaintRegion.name}</span>
{inpaintMaskAsset ? <MaskStatus asset={inpaintMaskAsset} purpose="inpaint" /> : null}
<button type="button" className={`h-8 rounded-md px-3 text-xs font-medium ${editingInpaintRegion ? "bg-red-200 text-red-950" : "hover:bg-white/[0.07]"}`} onClick={() => editingInpaintRegion ? dispatch(commandIds.toolExitMaskEdit, undefined) : dispatch(commandIds.toolEnterInpaintRegionEdit, { targetLayerId: layer.id, regionId: inpaintRegion.id })}>{editingInpaintRegion ? "Done" : "Edit"}</button>
<button type="button" className="h-8 rounded-md px-3 text-xs font-medium text-red-100/50 transition hover:bg-red-400/15 hover:text-red-100" onClick={() => dispatch(commandIds.documentRemoveInpaintRegion, { regionId: inpaintRegion.id })}>Remove</button>
</div>
) : null}
{layer.type === "group"
? layer.children.map((child) => (
<LayerRow

View File

@@ -1,4 +1,4 @@
import { Cursor, Eraser, Hand, PaintBrush, DropHalf, MagicWand, Sparkle } from "@phosphor-icons/react";
import { Cursor, Eraser, Hand, PaintBrush, DropHalf, MagicWand, Sparkle, Polygon, Rectangle, Selection } from "@phosphor-icons/react";
import { commandIds } from "@commands/ids";
import type { AppStore } from "@editor/store";
import type { InteractionMode, OperationId, ToolId } from "@editor/tools";
@@ -10,16 +10,17 @@ export type ToolOverlayProps = {
activeTool: ToolId;
interactionMode: InteractionMode;
panel: WorkspacePanel;
inpaintMaskEditing: boolean;
dispatch: AppStore["dispatch"];
};
export function ToolOverlay({ activeTool, interactionMode, panel, dispatch }: ToolOverlayProps) {
export function ToolOverlay({ activeTool, interactionMode, panel, inpaintMaskEditing, dispatch }: ToolOverlayProps) {
return (
<nav
aria-label="Tools"
className="app-surface pointer-events-auto flex w-11 flex-col items-center gap-1 rounded-xl p-1 text-white"
>
{availableToolIds.map((tool) => {
{availableToolIds.filter((tool) => inpaintMaskEditing || (tool !== "semanticSelect" && tool !== "maskLasso" && tool !== "maskRectangle")).map((tool) => {
const active = !isOperationWorkspacePanel(panel) && isToolHighlighted(tool, activeTool, interactionMode);
const Icon = iconForTool(tool);
@@ -67,6 +68,12 @@ function iconForTool(tool: ToolId) {
return Eraser;
case "magicWand":
return MagicWand;
case "semanticSelect":
return Selection;
case "maskLasso":
return Polygon;
case "maskRectangle":
return Rectangle;
case "pan":
return Hand;
case "select":

View File

@@ -13,6 +13,7 @@ export type BrushControlsProps = {
tool: Extract<ToolId, "brush" | "eraser">;
settings: BrushSettings;
editingMask?: boolean;
maskKind?: "layerMask" | "inpaintRegion";
maskViewMode?: MaskViewMode;
dispatch: AppStore["dispatch"];
};
@@ -24,7 +25,8 @@ const maskViewModeOptions = [
{ value: "overlay", label: "Overlay" },
] satisfies readonly BottomControlSelectOption<MaskViewMode>[];
export function BrushControls({ tool, settings, editingMask = false, maskViewMode = "composite", dispatch }: BrushControlsProps) {
export function BrushControls({ tool, settings, editingMask = false, maskKind = "layerMask", maskViewMode = "composite", dispatch }: BrushControlsProps) {
const inpaintRegion = editingMask && maskKind === "inpaintRegion";
return (
<div className={bottomControlMenuClass()}>
<span className={bottomControlIconSlotClass()} title={editingMask ? `Mask ${tool === "eraser" ? "hide" : "reveal"}` : tool === "eraser" ? "Eraser" : "Brush"}>
@@ -33,8 +35,8 @@ export function BrushControls({ tool, settings, editingMask = false, maskViewMod
<BottomControlDivider />
{editingMask ? (
<>
<button type="button" className={maskModeButtonClass(tool === "brush")} aria-pressed={tool === "brush"} onClick={() => dispatch(commandIds.toolSetActive, { tool: "brush" })}>Reveal</button>
<button type="button" className={maskModeButtonClass(tool === "eraser")} aria-pressed={tool === "eraser"} onClick={() => dispatch(commandIds.toolSetActive, { tool: "eraser" })}>Hide</button>
<button type="button" className={maskModeButtonClass(tool === "brush")} aria-pressed={tool === "brush"} onClick={() => dispatch(commandIds.toolSetActive, { tool: "brush" })}>{inpaintRegion ? "Replace" : "Reveal"}</button>
<button type="button" className={maskModeButtonClass(tool === "eraser")} aria-pressed={tool === "eraser"} onClick={() => dispatch(commandIds.toolSetActive, { tool: "eraser" })}>{inpaintRegion ? "Protect" : "Hide"}</button>
<BottomControlDivider />
</>
) : null}
@@ -61,6 +63,13 @@ export function BrushControls({ tool, settings, editingMask = false, maskViewMod
<span className="w-9 text-right text-xs font-medium text-white/85">{Math.round(settings.size)}</span>
</label>
<BottomControlDivider />
<label className={bottomControlFieldClass()}><span className={bottomControlLabelClass()}>Opacity</span><BottomControlSlider min={0} max={100} value={settings.opacity} className="min-w-10 w-[clamp(2.5rem,7vw,6rem)] shrink" aria-label="Brush opacity" onValueChange={(opacity) => dispatch(commandIds.toolSetBrushSettings, { opacity })} /><span className="w-9 text-right text-xs font-medium text-white/85">{Math.round(settings.opacity)}</span></label>
<BottomControlDivider />
<label className={bottomControlFieldClass()}><span className={bottomControlLabelClass()}>Flow</span><BottomControlSlider min={1} max={100} value={settings.flow} className="min-w-10 w-[clamp(2.5rem,7vw,6rem)] shrink" aria-label="Brush flow" onValueChange={(flow) => dispatch(commandIds.toolSetBrushSettings, { flow })} /><span className="w-9 text-right text-xs font-medium text-white/85">{Math.round(settings.flow)}</span></label>
<BottomControlDivider />
<label className={bottomControlFieldClass()}><span className={bottomControlLabelClass()}>Smooth</span><BottomControlSlider min={0} max={100} value={settings.smoothing} className="min-w-10 w-[clamp(2.5rem,7vw,6rem)] shrink" aria-label="Brush smoothing" onValueChange={(smoothing) => dispatch(commandIds.toolSetBrushSettings, { smoothing })} /><span className="w-9 text-right text-xs font-medium text-white/85">{Math.round(settings.smoothing)}</span></label>
<button type="button" className={maskModeButtonClass(settings.pressureSize)} aria-pressed={settings.pressureSize} title="Use pen pressure for brush size" onClick={() => dispatch(commandIds.toolSetBrushSettings, { pressureSize: !settings.pressureSize })}>Pressure</button>
<BottomControlDivider />
<label className={bottomControlFieldClass()}>
<span className={bottomControlLabelClass()}>Hard</span>
<BottomControlSlider

View File

@@ -121,6 +121,7 @@ function CandidateControls({
<span className="px-2 text-xs font-medium text-white/55" title={`${candidate.settings.model} · ${candidate.mode}`}><strong className="block font-semibold text-white/75">Provisional result</strong>Seed {candidate.seed}</span>
<CandidateCompareControls compareMode={compareMode} disabled={disabled} dispatch={dispatch} />
<CandidateButton disabled={disabled} label="Regenerate" title="Regenerate same mask and crop" onClick={() => rerun("Regenerate", candidate.settings)} />
<CandidateButton disabled={disabled || !candidate.inpaint} label="Current mask" title="Rebuild the crop and generate from the current AI edit region" onClick={() => void workflow.rebuildFromCurrentRegion(candidate.id)} />
<CandidateButton
disabled={disabled}
label="Lower"
@@ -159,7 +160,7 @@ function CandidateControls({
onClick={() => {
if (!candidate.inpaint) return;
dispatch(commandIds.selectionSet, { artboardId: candidate.placement.artboardId, layerIds: [candidate.inpaint.targetLayerId] });
dispatch(commandIds.toolEnterMaskEdit, { targetLayerId: candidate.inpaint.targetLayerId, maskLayerId: candidate.inpaint.maskLayerId });
dispatch(commandIds.toolEnterInpaintRegionEdit, { targetLayerId: candidate.inpaint.targetLayerId, regionId: candidate.inpaint.regionId });
}}
/>
<CandidateButton disabled={disabled} label="Dismiss" title="Remove this candidate" onClick={() => dispatch(commandIds.generationRemoveCandidate, { candidateId: candidate.id })} />

View File

@@ -7,6 +7,9 @@ import type { GenerateArchitecture, GenerateIntent, GenerateMode, GenerateSettin
import { resolveGenerationModeOptions, resolveGenerationModelOptions, resolveGenerationStringOptions, resolveGenerationSupportOptions } from "@operations/generation/options";
import { BottomControlSelectMenu, type BottomControlSelectOption } from "./SelectMenu";
import { BottomControlSlider } from "./Slider";
import type { ImageDocument } from "@core/document";
import type { SelectionState } from "@editor/state";
import { InpaintRegionPanel } from "../inpaint/InpaintRegionPanel";
const architectures = [
{ value: "sdxl", label: "SDXL" },
@@ -30,11 +33,6 @@ const sizePresets = [
{ label: "9:16", w: 768, h: 1344 },
] as const;
const inpaintPolarityOptions = [
{ value: "hidden", label: "Hidden / erased" },
{ value: "revealed", label: "Revealed / painted" },
] satisfies readonly BottomControlSelectOption<GenerateSettings["inpaint"]["maskPolarity"]>[];
const inpaintMaskedContentOptions = [
{ value: "neutral", label: "Neutral fill" },
{ value: "original", label: "Original gray" },
@@ -42,13 +40,31 @@ const inpaintMaskedContentOptions = [
{ value: "edges", label: "Edge map" },
] satisfies readonly BottomControlSelectOption<GenerateSettings["inpaint"]["maskedContent"]>[];
const inpaintProfileOptions = [
{ value: "remove", label: "Remove object" },
{ value: "replace", label: "Replace object" },
{ value: "repair", label: "Repair detail" },
{ value: "material", label: "Change material" },
{ value: "reshape", label: "Change shape" },
{ value: "custom", label: "Custom" },
] satisfies readonly BottomControlSelectOption<GenerateSettings["inpaint"]["profile"]>[];
const structureControlOptions = [
{ value: "none", label: "None" },
{ value: "canny", label: "Canny edges" },
{ value: "depth", label: "Depth" },
{ value: "pose", label: "Pose" },
] satisfies readonly BottomControlSelectOption<GenerateSettings["inpaint"]["structureControl"]>[];
export type GenerateControlsProps = {
settings: GenerateSettings;
document: ImageDocument;
selection: SelectionState;
resources: GenerationResourcesState;
dispatch: AppStore["dispatch"];
};
export function GenerateControls({ settings, resources, dispatch }: GenerateControlsProps) {
export function GenerateControls({ settings, document, selection, resources, dispatch }: GenerateControlsProps) {
const comfyOptions = resources.options;
const [advancedOpen, setAdvancedOpen] = useState(false);
const [outpaintOpen, setOutpaintOpen] = useState(false);
@@ -60,6 +76,7 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
const samplerOptions = resolveGenerationStringOptions(comfyOptions?.samplers, settings.sampler);
const schedulerOptions = resolveGenerationStringOptions(comfyOptions?.schedulers, settings.scheduler);
const modeOptions = resolveGenerationModeOptions(settings, comfyOptions, modes);
const availableStructureControls = structureControlOptions.filter((option) => option.value === "none" || option.value === settings.inpaint.structureControl || comfyOptions?.structureControls?.includes(option.value));
useEffect(() => {
if (!sizeOpen) return;
@@ -84,18 +101,14 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
<button
key={intent.value}
type="button"
aria-pressed={settings.mode === intent.mode}
className={`border-l-2 px-2.5 py-1.5 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/50 ${settings.mode === intent.mode ? "border-sky-300 bg-sky-300/10 text-sky-100" : "border-transparent text-white/60 hover:border-white/20 hover:text-white"}`}
aria-pressed={isIntentActive(intent.value, intent.mode, settings)}
className={`border-l-2 px-2.5 py-1.5 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/50 ${isIntentActive(intent.value, intent.mode, settings) ? "border-sky-300 bg-sky-300/10 text-sky-100" : "border-transparent text-white/60 hover:border-white/20 hover:text-white"}`}
onClick={() => dispatch(commandIds.toolChooseGenerateIntent, { intent: intent.value })}
>
<span className="block text-sm font-semibold">{intent.label}</span>
<span className={`mt-1 block text-xs leading-4 ${settings.mode === intent.mode ? "text-sky-100/70" : "text-white/40"}`}>{intent.description}</span>
<span className={`mt-1 block text-xs leading-4 ${isIntentActive(intent.value, intent.mode, settings) ? "text-sky-100/70" : "text-white/40"}`}>{intent.description}</span>
</button>
))}
<button type="button" disabled className="border-l-2 border-transparent px-2.5 py-1.5 text-left text-white/25" title="Dedicated object removal is not supported by the current backend. Use Replace and describe the desired background.">
<span className="block text-sm font-semibold">Remove</span>
<span className="mt-1 block text-xs leading-4">Use Replace for now; describe what should fill the area.</span>
</button>
</div>
</section>
@@ -134,6 +147,9 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
<span className="w-10 text-right text-sm text-white">{Math.round(settings.strength)}</span>
</div>
) : null}
<PanelNumber label="Results" aria-label="Generation result count" value={settings.batchSize} onValueChange={(batchSize) => dispatch(commandIds.toolSetGenerateSettings, { batchSize })} />
<button type="button" className={compactRowButtonClass()} aria-pressed={settings.refinePass} onClick={() => dispatch(commandIds.toolSetGenerateSettings, { refinePass: !settings.refinePass })}><span className={panelLabelClass()}>Detail pass</span><span className="min-w-0 flex-1 text-right text-white">{settings.refinePass ? `${Math.round(settings.refineStrength)}%` : "Off"}</span></button>
{settings.refinePass ? <PanelNumber label="Detail strength" aria-label="Generation detail pass strength" value={settings.refineStrength} onValueChange={(refineStrength) => dispatch(commandIds.toolSetGenerateSettings, { refineStrength })} /> : null}
</section>
<section className={panelSectionClass()}>
@@ -184,6 +200,7 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
</section> : null}
{settings.mode === "inpaint" ? <section className={panelSectionClass()}>
<InpaintRegionPanel document={document} selection={selection} settings={settings} dispatch={dispatch} />
<button type="button" className={sectionToggleClass()} aria-expanded={inpaintOpen} aria-controls="generate-inpaint-controls" onClick={() => setInpaintOpen((open) => !open)}>
<span>
<span className="block text-sm font-semibold text-white/85">Inpaint</span>
@@ -192,13 +209,7 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
{inpaintOpen ? <CaretUp size={18} weight="bold" /> : <CaretDown size={18} weight="bold" />}
</button>
<div id="generate-inpaint-controls" className={`grid gap-2 overflow-hidden transition-all duration-200 ${inpaintOpen ? "max-h-[38rem] pt-2 opacity-100" : "max-h-0 opacity-0"}`}>
<PanelSelect
label="Polarity"
value={settings.inpaint.maskPolarity}
options={inpaintPolarityOptions}
ariaLabel="Inpaint mask polarity"
onValueChange={(maskPolarity) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, maskPolarity } })}
/>
<PanelSelect label="Intent" value={settings.inpaint.profile} options={inpaintProfileOptions} ariaLabel="Inpaint intent" onValueChange={(profile) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { profile } })} />
<PanelSelect
label="Content"
value={settings.inpaint.maskedContent}
@@ -206,10 +217,20 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
ariaLabel="Inpaint masked content"
onValueChange={(maskedContent) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, maskedContent } })}
/>
<PanelSelect label="Structure" value={settings.inpaint.structureControl} options={availableStructureControls} ariaLabel="Inpaint structure control" onValueChange={(structureControl) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { structureControl, profile: "custom" } })} />
{settings.inpaint.structureControl !== "none" ? (
<div className="grid grid-cols-2 gap-2">
<PanelNumber label="Control" aria-label="Structure control strength percent" value={Math.round(settings.inpaint.controlStrength * 100)} onValueChange={(controlStrength) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { controlStrength: controlStrength / 100, profile: "custom" } })} />
<PanelSelect label="Control model" value={settings.inpaint.controlModel} options={resolveGenerationStringOptions(comfyOptions?.controlModels, settings.inpaint.controlModel)} ariaLabel="Inpaint control model" onValueChange={(controlModel) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { controlModel } })} />
</div>
) : null}
<button type="button" className={compactRowButtonClass()} aria-pressed={settings.inpaint.maskedAreaOnly} onClick={() => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, maskedAreaOnly: !settings.inpaint.maskedAreaOnly } })}>
<span className={panelLabelClass()}>Frame</span>
<span className="min-w-0 flex-1 text-right text-white">{settings.inpaint.maskedAreaOnly ? "Mask crop" : "Full layer"}</span>
</button>
<button type="button" className={compactRowButtonClass()} aria-pressed={settings.inpaint.colorMatch} onClick={() => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { colorMatch: !settings.inpaint.colorMatch, profile: "custom" } })}>
<span className={panelLabelClass()}>Seam color</span><span className="min-w-0 flex-1 text-right text-white">{settings.inpaint.colorMatch ? "Match boundary" : "Preserve result"}</span>
</button>
<div className="grid grid-cols-2 gap-2">
<PanelNumber label="Pad" aria-label="Inpaint crop padding" value={settings.inpaint.cropPadding} onValueChange={(cropPadding) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, cropPadding } })} />
<PanelNumber label="Grow" aria-label="Inpaint backend grow mask" value={settings.inpaint.growMaskBy} onValueChange={(growMaskBy) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, growMaskBy } })} />
@@ -227,10 +248,18 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
const intentOptions: ReadonlyArray<{ value: GenerateIntent; mode: GenerateMode; label: string; description: string }> = [
{ value: "create", mode: "text-to-image", label: "Create", description: "Make a new image from your prompt." },
{ value: "replace", mode: "inpaint", label: "Replace", description: "Regenerate the masked part of one layer." },
{ value: "remove", mode: "inpaint", label: "Remove", description: "Erase an object and reconstruct its background." },
{ value: "extend", mode: "outpaint", label: "Extend", description: "Grow one selected image beyond its edges." },
{ value: "variations", mode: "image-to-image", label: "Variations", description: "Explore alternatives based on one image." },
];
function isIntentActive(intent: GenerateIntent, mode: GenerateMode, settings: GenerateSettings) {
if (settings.mode !== mode) return false;
if (intent === "remove") return settings.inpaint.profile === "remove";
if (intent === "replace") return settings.inpaint.profile !== "remove";
return true;
}
function SectionTitle({ title }: { title: string }) {
return <div className="px-1 text-xs font-semibold uppercase tracking-[0.18em] text-white/35">{title}</div>;
}

View File

@@ -8,6 +8,6 @@ export function canvasCursorClass(interactionMode: InteractionMode, isPanning: b
if (!canBrush) return "cursor-not-allowed";
return hasBrushPreview ? "cursor-none" : "cursor-crosshair";
}
if (interactionMode.type === "tool" && interactionMode.tool === "magicWand") return "cursor-crosshair";
if (interactionMode.type === "tool" && (interactionMode.tool === "magicWand" || interactionMode.tool === "semanticSelect" || interactionMode.tool === "maskLasso" || interactionMode.tool === "maskRectangle")) return "cursor-crosshair";
return "cursor-default";
}

View File

@@ -137,7 +137,7 @@ const visualEditorChanges: Array<[string, (state: AppState) => AppState]> = [
...state,
editor: {
...state.editor,
maskEdit: { targetLayerId: "target", maskLayerId: "mask", viewMode: "overlay" },
maskEdit: { kind: "layerMask", targetLayerId: "target", maskLayerId: "mask", maskAssetId: "mask-asset", viewMode: "overlay" },
},
}),
],

View File

@@ -23,6 +23,7 @@ function visualEditorStatesEqual(a: EditorState, b: EditorState): boolean {
maskEditStatesEqual(a.maskEdit, b.maskEdit) &&
brushPreviewStatesEqual(a.brushPreview, b.brushPreview) &&
brushStrokePreviewStatesEqual(a.brushStrokePreview, b.brushStrokePreview) &&
a.maskShapeSession === b.maskShapeSession &&
generationStatesEqual(a.generation, b.generation) &&
visualToolStatesEqual(a.tools, b.tools)
);
@@ -50,7 +51,7 @@ function transformTargetsEqual(a: TransformTarget, b: TransformTarget): boolean
function maskEditStatesEqual(a: MaskEditState | undefined, b: MaskEditState | undefined): boolean {
if (a === b) return true;
if (!a || !b) return false;
return a.targetLayerId === b.targetLayerId && a.maskLayerId === b.maskLayerId && a.viewMode === b.viewMode;
return a.kind === b.kind && a.targetLayerId === b.targetLayerId && a.maskAssetId === b.maskAssetId && a.maskLayerId === b.maskLayerId && a.inpaintRegionId === b.inpaintRegionId && a.viewMode === b.viewMode;
}
function brushPreviewStatesEqual(a: BrushPreviewState | undefined, b: BrushPreviewState | undefined): boolean {
@@ -80,7 +81,7 @@ function interactionModesEqual(a: InteractionMode, b: InteractionMode): boolean
}
function brushSettingsEqual(a: BrushSettings, b: BrushSettings): boolean {
return a.color === b.color && a.size === b.size && a.hardness === b.hardness;
return a.color === b.color && a.size === b.size && a.hardness === b.hardness && a.opacity === b.opacity && a.flow === b.flow && a.smoothing === b.smoothing && a.pressureSize === b.pressureSize;
}
function vec2Equal(a: Vec2D, b: Vec2D): boolean {

View File

@@ -15,6 +15,8 @@ import {
} from "@input/index";
import { beginBrushSession, canPreviewBrush, cancelBrushSession, commitBrushSession, updateBrushSession, type BrushSession } from "@operations/paint/brush";
import { applyMagicWandAt } from "@operations/masks/magic-wand";
import { commitInpaintLasso } from "@operations/masks/lasso";
import { applySemanticSelectionAt } from "@operations/masks/semantic-select";
export type CanvasInputOptions = {
globalKeybindConsumer: GlobalKeybindConsumer;
@@ -122,6 +124,12 @@ export function useCanvasInput(
const state = store.getState();
const documentPoint = viewportPointToDocumentPoint(inputEvent.position, state.editor.viewport);
if (!isOperationWorkspacePanel(state.editor.workspace.panel) && (state.editor.tools.activeTool === "maskLasso" || state.editor.tools.activeTool === "maskRectangle") && state.editor.maskEdit?.kind === "inpaintRegion" && (inputEvent.buttons & 1) === 1) {
store.dispatch(commandIds.toolBeginMaskShape, { point: documentPoint, mode: inputEvent.shiftKey ? "add" : inputEvent.altKey ? "subtract" : "replace" });
canvas.setPointerCapture(event.pointerId);
event.preventDefault();
return;
}
const brush = !isOperationWorkspacePanel(state.editor.workspace.panel) && (inputEvent.buttons & 1) === 1 && !isPanInteractionMode(state.editor.tools.interactionMode)
? beginBrushSession(state.document, state.editor, documentPoint)
: undefined;
@@ -139,6 +147,11 @@ export function useCanvasInput(
event.preventDefault();
return;
}
if (!isOperationWorkspacePanel(state.editor.workspace.panel) && state.editor.tools.activeTool === "semanticSelect") {
void applySemanticSelectionAt(store, documentPoint, inputEvent.shiftKey ? "add" : inputEvent.altKey ? "subtract" : "replace");
event.preventDefault();
return;
}
const currentState = store.getState();
const selectionToolActive = currentState.editor.tools.activeTool === "select";
@@ -153,6 +166,12 @@ export function useCanvasInput(
const handlePointerMove = (event: PointerEvent) => {
const inputEvent = pointerInputEventFromPointerEvent(event);
if (store.getState().editor.maskShapeSession) {
const point = viewportPointToDocumentPoint(inputEvent.position, store.getState().editor.viewport);
store.dispatch(commandIds.toolAppendMaskShape, { point });
event.preventDefault();
return;
}
if (brushSession.current) {
if ((inputEvent.buttons & 1) !== 1 || isPanInteractionMode(store.getState().editor.tools.interactionMode)) {
commitActiveBrushSession(inputEvent.position);
@@ -163,7 +182,7 @@ export function useCanvasInput(
const point = viewportPointToDocumentPoint(inputEvent.position, store.getState().editor.viewport);
store.dispatch(commandIds.toolSetBrushPreview, { position: point });
const settings = store.getState().editor.tools.brush;
brushSession.current = updateBrushSession({ store, session: brushSession.current, point, color: settings.color, size: settings.size, hardness: settings.hardness });
brushSession.current = updateBrushSession({ store, session: brushSession.current, point, color: settings.color, size: settings.size, hardness: settings.hardness, opacity: settings.opacity, flow: settings.flow, smoothing: settings.smoothing, pressure: inputEvent.pressure ?? 1, pressureSize: settings.pressureSize });
event.preventDefault();
return;
}
@@ -187,6 +206,13 @@ export function useCanvasInput(
const handlePointerUp = (event: PointerEvent) => {
const inputEvent = pointerInputEventFromPointerEvent(event);
if (store.getState().editor.maskShapeSession) {
const point = viewportPointToDocumentPoint(inputEvent.position, store.getState().editor.viewport);
store.dispatch(commandIds.toolAppendMaskShape, { point });
void commitInpaintLasso(store);
event.preventDefault();
return;
}
if (brushSession.current) {
commitActiveBrushSession(inputEvent.position);
event.preventDefault();

View File

@@ -0,0 +1,25 @@
import { Star } from "@phosphor-icons/react";
import { commandIds } from "@commands/ids";
import type { GenerationState } from "@editor/state";
import type { AppStore } from "@editor/store";
export function CandidateReviewPanel({ generation, dispatch }: { generation: GenerationState; dispatch: AppStore["dispatch"] }) {
if (generation.candidates.length === 0) return null;
const selectedId = generation.selectedCandidateId ?? generation.candidates[0]?.id;
return (
<section className="grid gap-2 border-b border-white/[0.07] pb-3">
<div className="flex items-center justify-between px-1"><strong className="text-sm text-white/85">Results</strong><span className="text-xs text-white/35">{generation.candidates.length} candidates</span></div>
<div className="grid grid-cols-2 gap-2">
{generation.candidates.map((candidate) => (
<div key={candidate.id} className={`group relative overflow-hidden rounded-lg border ${candidate.id === selectedId ? "border-sky-300" : "border-white/10"}`}>
<button type="button" className="block w-full" onClick={() => dispatch(commandIds.generationSelectCandidate, { candidateId: candidate.id })}>
<img src={candidate.source} alt={`Candidate seed ${candidate.seed}`} className="aspect-square w-full bg-black/25 object-cover" />
<span className="flex items-center justify-between px-2 py-1 text-[0.65rem] text-white/45"><span>Seed {candidate.seed}</span><span>{candidate.settings.inpaint.profile}</span></span>
</button>
<button type="button" aria-label={candidate.favorite ? "Remove favorite" : "Favorite candidate"} aria-pressed={candidate.favorite} className={`absolute right-1.5 top-1.5 rounded-md bg-black/65 p-1.5 ${candidate.favorite ? "text-amber-300" : "text-white/60 opacity-0 group-hover:opacity-100"}`} onClick={() => dispatch(commandIds.generationToggleCandidateFavorite, { candidateId: candidate.id })}><Star size={16} weight={candidate.favorite ? "fill" : "regular"} /></button>
</div>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,86 @@
import { useEffect, useMemo, useState } from "react";
import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document";
import type { SelectionState } from "@editor/state";
import type { AppStore } from "@editor/store";
import type { GenerateSettings } from "@editor/tools";
import { runInpaintRegionOperation } from "@operations/masks/rasterActions";
import { createNormalizedMaskSource } from "@platform/browser/maskRaster";
type ProcessedMasks = { edit: string; noise: string; blend: string; coverage: number; bounds?: { x: number; y: number; w: number; h: number } };
export function InpaintRegionPanel({ document, selection, settings, dispatch }: { document: ImageDocument; selection: SelectionState; settings: GenerateSettings; dispatch: AppStore["dispatch"] }) {
const targetLayerId = selection.layerIds.length === 1 ? selection.layerIds[0] : undefined;
const region = document.inpaintRegions.find((candidate) => candidate.targetLayerId === targetLayerId && candidate.enabled);
const asset = region ? document.assets.find((candidate) => candidate.id === region.maskAssetId) : undefined;
const [processed, setProcessed] = useState<ProcessedMasks>();
const [busy, setBusy] = useState(false);
const processingKey = useMemo(() => asset ? [asset.source, settings.inpaint.maskExpand, settings.inpaint.maskFeather, settings.inpaint.maskBlur, settings.inpaint.maskDespeckle].join(":") : "", [asset, settings.inpaint]);
useEffect(() => {
if (!asset) {
setProcessed(undefined);
return;
}
let cancelled = false;
const width = Math.max(1, Math.round(asset.intrinsicSize.w));
const height = Math.max(1, Math.round(asset.intrinsicSize.h));
void Promise.all([
createNormalizedMaskSource(asset.source, width, height, { polarity: "revealed", despeckle: settings.inpaint.maskDespeckle }),
createNormalizedMaskSource(asset.source, width, height, { polarity: "revealed", expand: settings.inpaint.maskExpand, blur: settings.inpaint.maskBlur, despeckle: settings.inpaint.maskDespeckle }),
createNormalizedMaskSource(asset.source, width, height, { polarity: "revealed", feather: settings.inpaint.maskFeather, despeckle: settings.inpaint.maskDespeckle }),
]).then(([edit, noise, blend]) => {
if (cancelled) return;
const active = edit.values.reduce((sum, value) => sum + value / 255, 0);
setProcessed({ edit: edit.source, noise: noise.source, blend: blend.source, coverage: active / edit.values.length, bounds: edit.bounds });
}).catch(() => !cancelled && setProcessed(undefined));
return () => { cancelled = true; };
}, [processingKey, asset, settings.inpaint.maskDespeckle, settings.inpaint.maskExpand, settings.inpaint.maskBlur, settings.inpaint.maskFeather]);
if (!targetLayerId) return <p className="rounded-md bg-amber-300/[0.07] px-2.5 py-2 text-xs text-amber-100/70">Select one image or raster layer to create an AI edit region.</p>;
if (!region || !asset) return <p className="rounded-md bg-white/[0.04] px-2.5 py-2 text-xs text-white/55">No AI edit region yet. Use <strong className="text-white/80">Add region</strong> beside Generate, then paint where pixels should be replaced.</p>;
const operation = async (type: "invert" | "clear" | "fill") => {
setBusy(true);
try {
await runInpaintRegionOperation(region.id, asset, type === "invert" ? { type: "invert" } : { type: "fill", fill: type === "fill" ? "white" : "black" }, dispatch);
} finally {
setBusy(false);
}
};
return (
<div className="grid gap-2 rounded-lg border border-white/[0.07] bg-white/[0.025] p-2.5">
<div className="flex items-center justify-between gap-2">
<div><strong className="block text-xs text-white/80">AI edit region</strong><span className="text-[0.68rem] text-white/40">Painted pixels are replaced; unpainted pixels are protected.</span></div>
<button type="button" className="rounded-md bg-sky-300 px-2.5 py-1 text-xs font-semibold text-slate-950" onClick={() => dispatch(commandIds.toolEnterInpaintRegionEdit, { targetLayerId, regionId: region.id })}>Edit</button>
</div>
{processed ? (
<div className="grid grid-cols-3 gap-2 text-center text-[0.65rem] text-white/45">
<MaskPreview label="Edit" source={processed.edit} />
<MaskPreview label="Noise" source={processed.noise} />
<MaskPreview label="Blend" source={processed.blend} />
</div>
) : <span className="text-xs text-white/35">Preparing mask previews</span>}
<div className="flex flex-wrap items-center gap-1.5 text-[0.68rem] text-white/45">
{processed ? <span className="mr-auto">Replace {Math.round(processed.coverage * 100)}%{processed.bounds ? ` · ${Math.round(processed.bounds.w)}×${Math.round(processed.bounds.h)} px` : " · empty"}</span> : <span className="mr-auto" />}
<RegionButton disabled={busy} label="Invert" onClick={() => void operation("invert")} />
<RegionButton disabled={busy} label="Color select" onClick={() => { dispatch(commandIds.toolEnterInpaintRegionEdit, { targetLayerId, regionId: region.id }); dispatch(commandIds.toolSetActive, { tool: "magicWand" }); }} />
<RegionButton disabled={busy} label="AI object" onClick={() => { dispatch(commandIds.toolEnterInpaintRegionEdit, { targetLayerId, regionId: region.id }); dispatch(commandIds.toolSetActive, { tool: "semanticSelect" }); }} />
<RegionButton disabled={busy} label="Lasso" onClick={() => { dispatch(commandIds.toolEnterInpaintRegionEdit, { targetLayerId, regionId: region.id }); dispatch(commandIds.toolSetActive, { tool: "maskLasso" }); }} />
<RegionButton disabled={busy} label="Rectangle" onClick={() => { dispatch(commandIds.toolEnterInpaintRegionEdit, { targetLayerId, regionId: region.id }); dispatch(commandIds.toolSetActive, { tool: "maskRectangle" }); }} />
<RegionButton disabled={busy} label="Clear" onClick={() => void operation("clear")} />
<RegionButton disabled={busy} label="Select all" onClick={() => void operation("fill")} />
<RegionButton disabled={busy} label="Remove" danger onClick={() => dispatch(commandIds.documentRemoveInpaintRegion, { regionId: region.id })} />
</div>
</div>
);
}
function MaskPreview({ label, source }: { label: string; source: string }) {
return <div className="grid gap-1"><img src={source} alt={`${label} mask preview`} className="h-14 w-full rounded-md bg-black/30 object-cover ring-1 ring-white/10" /><span>{label}</span></div>;
}
function RegionButton({ label, disabled, danger, onClick }: { label: string; disabled?: boolean; danger?: boolean; onClick: () => void }) {
return <button type="button" disabled={disabled} className={`rounded-md px-2 py-1 font-semibold transition disabled:opacity-35 ${danger ? "bg-red-400/10 text-red-100/70 hover:bg-red-400/20" : "bg-white/[0.05] text-white/55 hover:bg-white/[0.09] hover:text-white"}`} onClick={onClick}>{label}</button>;
}

View File

@@ -3,7 +3,7 @@ import type { Asset } from "@core/asset";
import type { AppStore } from "@editor/store";
import { analyzeMask, runMaskOperation, type MaskAnalysis, type MaskRasterOperation } from "@operations/masks/rasterActions";
export function MaskStatus({ asset }: { asset: Asset }) {
export function MaskStatus({ asset, purpose = "visibility" }: { asset: Asset; purpose?: "visibility" | "inpaint" }) {
const [analysis, setAnalysis] = useState<MaskAnalysis>();
useEffect(() => {
@@ -24,8 +24,7 @@ export function MaskStatus({ asset }: { asset: Asset }) {
return (
<span className="inline-flex min-w-0 items-center gap-1.5 rounded-md bg-white/[0.04] px-1.5 py-1 text-[0.68rem] text-sky-100/60">
<img src={analysis.thumbnail} alt="" className="h-7 w-10 rounded-md bg-black/30 object-cover ring-1 ring-white/10" />
<span className="whitespace-nowrap">Reveal {formatPercent(analysis.coverage)}</span>
<span className="whitespace-nowrap text-sky-100/45">Inpaint {formatPercent(analysis.hiddenCoverage)}</span>
{purpose === "inpaint" ? <span className="whitespace-nowrap">Replace {formatPercent(analysis.coverage)}</span> : <><span className="whitespace-nowrap">Reveal {formatPercent(analysis.coverage)}</span><span className="whitespace-nowrap text-sky-100/45">Hidden {formatPercent(analysis.hiddenCoverage)}</span></>}
</span>
);
}

View File

@@ -36,7 +36,7 @@ describe("layer thumbnail read model", () => {
test("indexes nested group previews once for row lookup", () => {
const child = { ...base("child-group", "Child"), type: "group" as const, children: [raster("nested", "asset-b")] };
const group = { ...base("root", "Root"), type: "group" as const, children: [child] };
const document = { id: "doc", version: 1, name: "Doc", assets: [...assets.values()], artboards: [{ id: "artboard", name: "Artboard", bounds: { x: 0, y: 0, w: 100, h: 100 }, backgroundColor: "#000000", visible: true, locked: false, layers: [group] }] };
const document = { id: "doc", version: 1, name: "Doc", assets: [...assets.values()], inpaintRegions: [], artboards: [{ id: "artboard", name: "Artboard", bounds: { x: 0, y: 0, w: 100, h: 100 }, backgroundColor: "#000000", visible: true, locked: false, layers: [group] }] };
const index = createLayerThumbnailIndex(document, assets);
expect(index.get("nested")?.kind).toBe("raster");
expect(index.get("child-group")).toMatchObject({ kind: "group", previews: [{ source: "blob:b" }] });

View File

@@ -345,6 +345,12 @@ function toolIcon(tool: ToolId) {
return <Eraser size={20} />;
case "magicWand":
return <MagicWand size={20} />;
case "semanticSelect":
return <MagicWand size={20} />;
case "maskLasso":
return <MagicWand size={20} />;
case "maskRectangle":
return <MagicWand size={20} />;
case "pan":
return <Hand size={20} />;
}

View File

@@ -6,6 +6,12 @@ export function labelForTool(tool: ToolId): string {
return "Brush";
case "magicWand":
return "Magic wand";
case "semanticSelect":
return "AI object select";
case "maskLasso":
return "AI region lasso";
case "maskRectangle":
return "AI region rectangle";
case "eraser":
return "Eraser";
case "pan":