Browse Source

feat: add 3x2 grid layout option to split grid node

Change layout selector from count-based to layout-based, allowing both
2x3 and 3x2 (portrait) layouts that produce 6 images. Now shows 7
layout options (2x2, 1x5, 2x3, 3x2, 2x4, 3x3, 2x5) with RxC labels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
adc86d5440
  1. 59
      src/components/SplitGridSettingsModal.tsx
  2. 66
      src/components/__tests__/SplitGridSettingsModal.test.tsx

59
src/components/SplitGridSettingsModal.tsx

@ -10,7 +10,15 @@ interface SplitGridSettingsModalProps {
onClose: () => void; onClose: () => void;
} }
const TARGET_COUNT_OPTIONS = [4, 5, 6, 8, 9, 10] as const; const LAYOUT_OPTIONS = [
{ rows: 2, cols: 2 },
{ rows: 1, cols: 5 },
{ rows: 2, cols: 3 },
{ rows: 3, cols: 2 },
{ rows: 2, cols: 4 },
{ rows: 3, cols: 3 },
{ rows: 2, cols: 5 },
] as const;
const ASPECT_RATIOS: AspectRatio[] = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"]; const ASPECT_RATIOS: AspectRatio[] = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"];
const RESOLUTIONS: Resolution[] = ["1K", "2K", "4K"]; const RESOLUTIONS: Resolution[] = ["1K", "2K", "4K"];
@ -19,17 +27,9 @@ const MODELS: { value: ModelType; label: string }[] = [
{ value: "nano-banana-pro", label: "Nano Banana Pro" }, { value: "nano-banana-pro", label: "Nano Banana Pro" },
]; ];
// Calculate grid dimensions from target count const findLayoutIndex = (rows: number, cols: number): number => {
const getGridDimensions = (count: number): { rows: number; cols: number } => { const idx = LAYOUT_OPTIONS.findIndex(l => l.rows === rows && l.cols === cols);
const grids: Record<number, { rows: number; cols: number }> = { return idx >= 0 ? idx : 2; // default to 2x3
4: { rows: 2, cols: 2 },
5: { rows: 1, cols: 5 }, // 1x5 vertical strip (e.g., for A+ content)
6: { rows: 2, cols: 3 },
8: { rows: 2, cols: 4 },
9: { rows: 3, cols: 3 },
10: { rows: 2, cols: 5 },
};
return grids[count] || { rows: 2, cols: 3 };
}; };
export function SplitGridSettingsModal({ export function SplitGridSettingsModal({
@ -39,14 +39,17 @@ export function SplitGridSettingsModal({
}: SplitGridSettingsModalProps) { }: SplitGridSettingsModalProps) {
const { updateNodeData, addNode, onConnect, addEdgeWithType, getNodeById } = useWorkflowStore(); const { updateNodeData, addNode, onConnect, addEdgeWithType, getNodeById } = useWorkflowStore();
const [targetCount, setTargetCount] = useState(nodeData.targetCount); const [selectedLayoutIndex, setSelectedLayoutIndex] = useState(
findLayoutIndex(nodeData.gridRows, nodeData.gridCols)
);
const [defaultPrompt, setDefaultPrompt] = useState(nodeData.defaultPrompt); const [defaultPrompt, setDefaultPrompt] = useState(nodeData.defaultPrompt);
const [aspectRatio, setAspectRatio] = useState(nodeData.generateSettings.aspectRatio); const [aspectRatio, setAspectRatio] = useState(nodeData.generateSettings.aspectRatio);
const [resolution, setResolution] = useState(nodeData.generateSettings.resolution); const [resolution, setResolution] = useState(nodeData.generateSettings.resolution);
const [model, setModel] = useState(nodeData.generateSettings.model); const [model, setModel] = useState(nodeData.generateSettings.model);
const [useGoogleSearch, setUseGoogleSearch] = useState(nodeData.generateSettings.useGoogleSearch); const [useGoogleSearch, setUseGoogleSearch] = useState(nodeData.generateSettings.useGoogleSearch);
const { rows, cols } = getGridDimensions(targetCount); const { rows, cols } = LAYOUT_OPTIONS[selectedLayoutIndex];
const targetCount = rows * cols;
const isNanoBananaPro = model === "nano-banana-pro"; const isNanoBananaPro = model === "nano-banana-pro";
const handleCreate = useCallback(() => { const handleCreate = useCallback(() => {
@ -162,8 +165,8 @@ export function SplitGridSettingsModal({
onClose(); onClose();
}, [ }, [
nodeId, targetCount, defaultPrompt, aspectRatio, resolution, nodeId, targetCount, defaultPrompt, aspectRatio, resolution,
model, useGoogleSearch, rows, cols, getNodeById, addNode, model, useGoogleSearch, rows, cols, selectedLayoutIndex, getNodeById,
updateNodeData, onConnect, addEdgeWithType, onClose addNode, updateNodeData, onConnect, addEdgeWithType, onClose
]); ]);
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
@ -183,20 +186,21 @@ export function SplitGridSettingsModal({
</h2> </h2>
<div className="space-y-4"> <div className="space-y-4">
{/* Target count selector with visual preview */} {/* Layout selector with visual preview */}
<div> <div>
<label className="block text-sm text-neutral-400 mb-2"> <label className="block text-sm text-neutral-400 mb-2">
Number of Images Grid Layout
</label> </label>
<div className="flex gap-2"> <div className="flex gap-2">
{TARGET_COUNT_OPTIONS.map((count) => { {LAYOUT_OPTIONS.map((layout, index) => {
const { rows: r, cols: c } = getGridDimensions(count); const count = layout.rows * layout.cols;
const isSelected = selectedLayoutIndex === index;
return ( return (
<button <button
key={count} key={`${layout.rows}x${layout.cols}`}
onClick={() => setTargetCount(count)} onClick={() => setSelectedLayoutIndex(index)}
className={`flex-1 p-2 rounded border transition-colors ${ className={`flex-1 p-2 rounded border transition-colors ${
targetCount === count isSelected
? "border-blue-500 bg-blue-500/20" ? "border-blue-500 bg-blue-500/20"
: "border-neutral-600 hover:border-neutral-500" : "border-neutral-600 hover:border-neutral-500"
}`} }`}
@ -204,20 +208,21 @@ export function SplitGridSettingsModal({
<div <div
className="aspect-video mx-auto w-12 grid gap-0.5" className="aspect-video mx-auto w-12 grid gap-0.5"
style={{ style={{
gridTemplateColumns: `repeat(${c}, 1fr)`, gridTemplateColumns: `repeat(${layout.cols}, 1fr)`,
gridTemplateRows: `repeat(${r}, 1fr)`, gridTemplateRows: `repeat(${layout.rows}, 1fr)`,
}} }}
> >
{Array.from({ length: count }).map((_, i) => ( {Array.from({ length: count }).map((_, i) => (
<div <div
key={i} key={i}
className={`rounded-sm ${ className={`rounded-sm ${
targetCount === count ? "bg-blue-400" : "bg-neutral-500" isSelected ? "bg-blue-400" : "bg-neutral-500"
}`} }`}
/> />
))} ))}
</div> </div>
<div className="text-xs text-neutral-300 mt-1 text-center">{count}</div> <div className="text-xs text-neutral-300 mt-1 text-center">{layout.rows}x{layout.cols}</div>
<div className="text-[10px] text-neutral-500 text-center">{count}</div>
</button> </button>
); );
})} })}

66
src/components/__tests__/SplitGridSettingsModal.test.tsx

@ -68,8 +68,8 @@ describe("SplitGridSettingsModal", () => {
}); });
}); });
describe("Number of Images Selection", () => { describe("Grid Layout Selection", () => {
it("should render target count options (4, 6, 8, 9, 10)", () => { it("should render all layout options", () => {
render( render(
<SplitGridSettingsModal <SplitGridSettingsModal
nodeId="test-node" nodeId="test-node"
@ -78,14 +78,16 @@ describe("SplitGridSettingsModal", () => {
/> />
); );
expect(screen.getByText("4")).toBeInTheDocument(); expect(screen.getByText("2x2")).toBeInTheDocument();
expect(screen.getByText("6")).toBeInTheDocument(); expect(screen.getByText("1x5")).toBeInTheDocument();
expect(screen.getByText("8")).toBeInTheDocument(); expect(screen.getByText("2x3")).toBeInTheDocument();
expect(screen.getByText("9")).toBeInTheDocument(); expect(screen.getByText("3x2")).toBeInTheDocument();
expect(screen.getByText("10")).toBeInTheDocument(); expect(screen.getByText("2x4")).toBeInTheDocument();
expect(screen.getByText("3x3")).toBeInTheDocument();
expect(screen.getByText("2x5")).toBeInTheDocument();
}); });
it("should highlight selected target count", () => { it("should highlight selected layout", () => {
render( render(
<SplitGridSettingsModal <SplitGridSettingsModal
nodeId="test-node" nodeId="test-node"
@ -94,13 +96,13 @@ describe("SplitGridSettingsModal", () => {
/> />
); );
// Default is 6, check its button has the selected styling // Default is 2x3, check its button has the selected styling
const buttons = screen.getAllByRole("button"); const buttons = screen.getAllByRole("button");
const sixButton = buttons.find(btn => btn.textContent?.includes("6")); const selectedButton = buttons.find(btn => btn.textContent?.includes("2x3"));
expect(sixButton).toHaveClass("border-blue-500"); expect(selectedButton).toHaveClass("border-blue-500");
}); });
it("should update target count when option is clicked", () => { it("should update layout when option is clicked", () => {
render( render(
<SplitGridSettingsModal <SplitGridSettingsModal
nodeId="test-node" nodeId="test-node"
@ -109,10 +111,10 @@ describe("SplitGridSettingsModal", () => {
/> />
); );
// Click on 9 // Click on 3x3
const buttons = screen.getAllByRole("button"); const buttons = screen.getAllByRole("button");
const nineButton = buttons.find(btn => btn.textContent?.includes("9")); const threeByThreeButton = buttons.find(btn => btn.textContent?.includes("3x3"));
fireEvent.click(nineButton!); fireEvent.click(threeByThreeButton!);
// The grid description should update // The grid description should update
expect(screen.getByText(/3x3 = 9 images/)).toBeInTheDocument(); expect(screen.getByText(/3x3 = 9 images/)).toBeInTheDocument();
@ -127,9 +129,27 @@ describe("SplitGridSettingsModal", () => {
/> />
); );
// Default is 6, which is 2x3 // Default is 2x3
expect(screen.getByText(/2x3 = 6 images/)).toBeInTheDocument(); expect(screen.getByText(/2x3 = 6 images/)).toBeInTheDocument();
}); });
it("should allow selecting 3x2 layout (6 images, portrait orientation)", () => {
render(
<SplitGridSettingsModal
nodeId="test-node"
nodeData={createDefaultNodeData()}
onClose={vi.fn()}
/>
);
// Click on 3x2
const buttons = screen.getAllByRole("button");
const threeByTwoButton = buttons.find(btn => btn.textContent?.includes("3x2"));
fireEvent.click(threeByTwoButton!);
// Should show 3x2 = 6 images
expect(screen.getByText(/3x2 = 6 images/)).toBeInTheDocument();
});
}); });
describe("Default Prompt", () => { describe("Default Prompt", () => {
@ -344,7 +364,7 @@ describe("SplitGridSettingsModal", () => {
expect(screen.getByText("Create 6 Generate Sets")).toBeInTheDocument(); expect(screen.getByText("Create 6 Generate Sets")).toBeInTheDocument();
}); });
it("should update Create button text when target count changes", () => { it("should update Create button text when layout changes", () => {
render( render(
<SplitGridSettingsModal <SplitGridSettingsModal
nodeId="test-node" nodeId="test-node"
@ -353,10 +373,10 @@ describe("SplitGridSettingsModal", () => {
/> />
); );
// Click on 9 // Click on 3x3
const buttons = screen.getAllByRole("button"); const buttons = screen.getAllByRole("button");
const nineButton = buttons.find(btn => btn.textContent?.includes("9") && !btn.textContent?.includes("Create")); const threeByThreeButton = buttons.find(btn => btn.textContent?.includes("3x3") && !btn.textContent?.includes("Create"));
fireEvent.click(nineButton!); fireEvent.click(threeByThreeButton!);
expect(screen.getByText("Create 9 Generate Sets")).toBeInTheDocument(); expect(screen.getByText("Create 9 Generate Sets")).toBeInTheDocument();
}); });
@ -431,9 +451,9 @@ describe("SplitGridSettingsModal", () => {
/> />
); );
// Each target count button should have a grid preview // Each layout button should have a grid preview
const gridPreviews = container.querySelectorAll(".aspect-video"); const gridPreviews = container.querySelectorAll(".aspect-video");
expect(gridPreviews.length).toBe(6); // 6 options: 4, 5, 6, 8, 9, 10 expect(gridPreviews.length).toBe(7); // 7 layout options
}); });
}); });
@ -441,6 +461,8 @@ describe("SplitGridSettingsModal", () => {
it("should use node data values as initial state", () => { it("should use node data values as initial state", () => {
const nodeData = createDefaultNodeData(); const nodeData = createDefaultNodeData();
nodeData.targetCount = 9; nodeData.targetCount = 9;
nodeData.gridRows = 3;
nodeData.gridCols = 3;
nodeData.defaultPrompt = "Existing prompt"; nodeData.defaultPrompt = "Existing prompt";
nodeData.generateSettings = { nodeData.generateSettings = {
aspectRatio: "16:9", aspectRatio: "16:9",

Loading…
Cancel
Save