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

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

@ -68,8 +68,8 @@ describe("SplitGridSettingsModal", () => {
});
});
describe("Number of Images Selection", () => {
it("should render target count options (4, 6, 8, 9, 10)", () => {
describe("Grid Layout Selection", () => {
it("should render all layout options", () => {
render(
<SplitGridSettingsModal
nodeId="test-node"
@ -78,14 +78,16 @@ describe("SplitGridSettingsModal", () => {
/>
);
expect(screen.getByText("4")).toBeInTheDocument();
expect(screen.getByText("6")).toBeInTheDocument();
expect(screen.getByText("8")).toBeInTheDocument();
expect(screen.getByText("9")).toBeInTheDocument();
expect(screen.getByText("10")).toBeInTheDocument();
expect(screen.getByText("2x2")).toBeInTheDocument();
expect(screen.getByText("1x5")).toBeInTheDocument();
expect(screen.getByText("2x3")).toBeInTheDocument();
expect(screen.getByText("3x2")).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(
<SplitGridSettingsModal
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 sixButton = buttons.find(btn => btn.textContent?.includes("6"));
expect(sixButton).toHaveClass("border-blue-500");
const selectedButton = buttons.find(btn => btn.textContent?.includes("2x3"));
expect(selectedButton).toHaveClass("border-blue-500");
});
it("should update target count when option is clicked", () => {
it("should update layout when option is clicked", () => {
render(
<SplitGridSettingsModal
nodeId="test-node"
@ -109,10 +111,10 @@ describe("SplitGridSettingsModal", () => {
/>
);
// Click on 9
// Click on 3x3
const buttons = screen.getAllByRole("button");
const nineButton = buttons.find(btn => btn.textContent?.includes("9"));
fireEvent.click(nineButton!);
const threeByThreeButton = buttons.find(btn => btn.textContent?.includes("3x3"));
fireEvent.click(threeByThreeButton!);
// The grid description should update
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();
});
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", () => {
@ -344,7 +364,7 @@ describe("SplitGridSettingsModal", () => {
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(
<SplitGridSettingsModal
nodeId="test-node"
@ -353,10 +373,10 @@ describe("SplitGridSettingsModal", () => {
/>
);
// Click on 9
// Click on 3x3
const buttons = screen.getAllByRole("button");
const nineButton = buttons.find(btn => btn.textContent?.includes("9") && !btn.textContent?.includes("Create"));
fireEvent.click(nineButton!);
const threeByThreeButton = buttons.find(btn => btn.textContent?.includes("3x3") && !btn.textContent?.includes("Create"));
fireEvent.click(threeByThreeButton!);
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");
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", () => {
const nodeData = createDefaultNodeData();
nodeData.targetCount = 9;
nodeData.gridRows = 3;
nodeData.gridCols = 3;
nodeData.defaultPrompt = "Existing prompt";
nodeData.generateSettings = {
aspectRatio: "16:9",

Loading…
Cancel
Save