feat: refine candidate handling in generation commands and update UI interactions

This commit is contained in:
syntaxbullet
2026-07-09 22:00:23 +02:00
parent 317a7bbf5f
commit 41bbd1e16b
5 changed files with 73 additions and 37 deletions

View File

@@ -36,7 +36,15 @@ describe("generation commands", () => {
});
test("applies candidates as top-level layers", () => {
const state = generationAddCandidateCommand.execute({ state: documentWithSourceLayer() }, { candidate: generationCandidate("candidate-1") });
const withRemainingCandidate = generationAddCandidateCommand.execute(
{ state: documentWithSourceLayer() },
{ candidate: generationCandidate("candidate-2") },
);
const withSelectedCandidate = generationAddCandidateCommand.execute(
{ state: withRemainingCandidate },
{ candidate: generationCandidate("candidate-1") },
);
const state = generationSetCompareModeCommand.execute({ state: withSelectedCandidate }, { mode: "split" });
const next = generationApplyCandidateAsLayerCommand.execute(
{ state },
@@ -52,11 +60,23 @@ describe("generation commands", () => {
});
expect(next.document.artboards[0]?.layers[0]?.id).toBe("generated-layer");
expect(next.editor.selection).toEqual({ artboardId: "a1", layerIds: ["generated-layer"] });
expect(next.editor.generation).toEqual({ candidates: [], selectedCandidateId: undefined, compareMode: "result" });
expect(next.editor.generation).toEqual({
candidates: [generationCandidate("candidate-2")],
selectedCandidateId: "candidate-2",
compareMode: "split",
});
});
test("replaces source asset pixels for inpaint candidates", () => {
const state = generationAddCandidateCommand.execute({ state: documentWithSourceLayer() }, { candidate: generationCandidate("candidate-1", true) });
const withRemainingCandidate = generationAddCandidateCommand.execute(
{ state: documentWithSourceLayer() },
{ candidate: generationCandidate("candidate-2", true) },
);
const withSelectedCandidate = generationAddCandidateCommand.execute(
{ state: withRemainingCandidate },
{ candidate: generationCandidate("candidate-1", true) },
);
const state = generationSetCompareModeCommand.execute({ state: withSelectedCandidate }, { mode: "before" });
const next = generationReplaceCandidatePixelsCommand.execute(
{ state },
@@ -75,7 +95,11 @@ describe("generation commands", () => {
},
});
expect(next.editor.selection).toEqual({ artboardId: "a1", layerIds: ["source-layer"] });
expect(next.editor.generation).toEqual({ candidates: [], selectedCandidateId: undefined, compareMode: "result" });
expect(next.editor.generation).toEqual({
candidates: [generationCandidate("candidate-2", true)],
selectedCandidateId: "candidate-2",
compareMode: "before",
});
});
});

View File

@@ -28,7 +28,6 @@ export type GenerationApplyCandidateAsLayerPayload = {
candidateId: string;
assetId: AssetId;
layerId: LayerId;
variant?: boolean;
};
export type GenerationReplaceCandidatePixelsPayload = {
@@ -105,18 +104,13 @@ export const generationRemoveCandidateCommand: Command<GenerationRemoveCandidate
name: "Remove generation candidate",
history: { mode: "ignore" },
execute({ state }, payload) {
const candidates = state.editor.generation.candidates.filter((candidate) => candidate.id !== payload.candidateId);
if (candidates.length === state.editor.generation.candidates.length) return state;
const selectedCandidateId = state.editor.generation.selectedCandidateId === payload.candidateId ? candidates[0]?.id : state.editor.generation.selectedCandidateId;
const generation = removeGenerationCandidate(state.editor.generation, payload.candidateId);
if (generation === state.editor.generation) return state;
return {
...state,
editor: {
...state.editor,
generation: {
candidates,
selectedCandidateId,
compareMode: candidates.length > 0 ? state.editor.generation.compareMode : "result",
},
generation,
},
};
},
@@ -149,16 +143,16 @@ export const generationApplyCandidateAsLayerCommand: Command<GenerationApplyCand
const asset: Asset = {
id: payload.assetId,
name: payload.variant ? `${candidate.placement.layerName} variant` : candidate.placement.layerName,
name: candidate.placement.layerName,
mimeType: candidate.mimeType,
source: candidate.source,
intrinsicSize: { ...candidate.intrinsicSize },
provenance: generationProvenance(candidate, payload.variant ? "variant-layer" : "layer"),
provenance: generationProvenance(candidate, "layer"),
};
const layer: ImageLayer = {
id: payload.layerId,
type: "image",
name: payload.variant ? `${candidate.placement.layerName} variant` : candidate.placement.layerName,
name: candidate.placement.layerName,
visible: true,
locked: false,
opacity: 1,
@@ -175,7 +169,7 @@ export const generationApplyCandidateAsLayerCommand: Command<GenerationApplyCand
document: insertLayerAtTop({ ...state.document, assets: [...state.document.assets, asset] }, candidate.placement.artboardId, layer),
editor: {
...state.editor,
generation: clearCommittedGenerationPreview(state.editor.generation),
generation: removeGenerationCandidate(state.editor.generation, candidate.id),
selection: { artboardId: candidate.placement.artboardId, layerIds: [layer.id] },
},
};
@@ -209,7 +203,7 @@ export const generationReplaceCandidatePixelsCommand: Command<GenerationReplaceC
},
editor: {
...state.editor,
generation: clearCommittedGenerationPreview(state.editor.generation),
generation: removeGenerationCandidate(state.editor.generation, candidate.id),
selection: { artboardId: targetLayerLocation.artboardId, layerIds: [candidate.inpaint.targetLayerId] },
},
};
@@ -257,9 +251,23 @@ function findLayerInTree(layers: readonly Layer[], layerId: LayerId): Layer | un
return undefined;
}
function clearCommittedGenerationPreview(generation: GenerationState): GenerationState {
if (generation.candidates.length === 0 && !generation.selectedCandidateId && generation.compareMode === "result") return generation;
return { candidates: [], selectedCandidateId: undefined, compareMode: "result" };
function removeGenerationCandidate(generation: GenerationState, candidateId: string): GenerationState {
const removedIndex = generation.candidates.findIndex((candidate) => candidate.id === candidateId);
if (removedIndex < 0) return generation;
const candidates = generation.candidates.filter((candidate) => candidate.id !== candidateId);
const selectionStillExists = generation.selectedCandidateId
? candidates.some((candidate) => candidate.id === generation.selectedCandidateId)
: false;
const selectedCandidateId = selectionStillExists
? generation.selectedCandidateId
: candidates[Math.min(removedIndex, candidates.length - 1)]?.id;
return {
candidates,
selectedCandidateId,
compareMode: candidates.length > 0 ? generation.compareMode : "result",
};
}
function generationProvenance(candidate: GenerationCandidate, acceptance: GeneratedAssetAcceptance): AssetGenerationProvenance {