Skip to content

Commit 0f179b2

Browse files
AlgusDarklucaspin
authored andcommitted
feat(canvas): add 'c' keyboard shortcut to open the add-component panel (superplanehq#4322)
## Summary Closes: superplanehq#1613 Adds a keyboard-first path to add a new component to the canvas: - Pressing **c** opens the **Add component** panel (same side-effect as clicking the `+` button). - The filter input inside the panel is auto-focused, so the user can start typing immediately. - Pressing **Enter** drops the first matching component at the viewport center. Before (mouse only): 1. Aim for the `+` button. 2. Click. 3. Click search box. 4. Type. 5. Click the result. After (keyboard): 1. Press `c`. 2. Type. 3. Press `Enter`. ## Design notes - **Active only in editor mode.** The shortcut mirrors the visibility rules of the `+` button: disabled when `readOnly`, in `version-live` mode, when `hideAddControls` is true, or when the component inspector (right-side sidebar) is already open. We did _not_ fold these into a single \"is add-button visible\" prop on purpose — the shortcut and the button can reasonably diverge (e.g. we want `c` off while inspecting a node, but the `+` button is still rendered there). - **No visible failure indicator**: empty filter, no-match filter, or an already-open panel are all silent no-ops. - **Safe around text input.** The global listener ignores events coming from `<input>`, `<textarea>`, `<select>`, `[contenteditable=\"true\"]`, or any `.monaco-editor` descendant, so `c` never eats typed characters in forms, YAML editors, etc. - **Modifier-free.** `Cmd/Ctrl/Alt + c` is passed through untouched (so browser copy still works). - **No tooltip/keycap yet.** That's worth its own PR, it needs a reusable `<Kbd />` and a keyboard-shortcut discovery convention. ## Manual verification - [x] Press \`c\` in editor mode → Add component panel opens and filter input is focused. - [x] Type a few chars → press \`Enter\` → first matching block drops at viewport center. - [x] Panel already open → \`c\` does nothing. - [x] Focus an \`<input>\` / textarea / YAML editor → \`c\` types the literal character. - [x] \`Cmd+c\` still copies; \`Ctrl+c\` still works. - [x] In version-live / read-only → \`c\` does nothing. - [x] Component inspector open → \`c\` does nothing (fixed in commit 6). --------- Signed-off-by: Algus Dark <algus.dark@gmail.com> Co-authored-by: Lucas Pinheiro <lucas@superplane.com> Signed-off-by: WashingtonKK <washingtonkigan@gmail.com>
1 parent 6d02401 commit 0f179b2

10 files changed

Lines changed: 705 additions & 123 deletions

File tree

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { describe, expect, it } from "vitest";
2+
import { findFreePositionInViewport } from "./find-free-position-in-viewport";
3+
4+
const DEFAULT_VIEWPORT = { x: 0, y: 0, zoom: 1 };
5+
const DEFAULT_CANVAS = { width: 1000, height: 800 };
6+
const NOTE_SIZE = { width: 320, height: 160 };
7+
const PADDING = 16;
8+
9+
interface Rect {
10+
minX: number;
11+
minY: number;
12+
maxX: number;
13+
maxY: number;
14+
}
15+
16+
function rectFor(position: { x: number; y: number }, size: { width: number; height: number }): Rect {
17+
return {
18+
minX: position.x,
19+
minY: position.y,
20+
maxX: position.x + size.width,
21+
maxY: position.y + size.height,
22+
};
23+
}
24+
25+
function overlaps(a: Rect, b: Rect): boolean {
26+
return a.minX < b.maxX && a.maxX > b.minX && a.minY < b.maxY && a.maxY > b.minY;
27+
}
28+
29+
function visibleBoundsFor(
30+
viewport: { x: number; y: number; zoom: number },
31+
canvas: { width: number; height: number },
32+
): Rect {
33+
return {
34+
minX: -viewport.x / viewport.zoom,
35+
minY: -viewport.y / viewport.zoom,
36+
maxX: (canvas.width - viewport.x) / viewport.zoom,
37+
maxY: (canvas.height - viewport.y) / viewport.zoom,
38+
};
39+
}
40+
41+
describe("findFreePositionInViewport", () => {
42+
it("places the node inside the viewport when the canvas is empty", () => {
43+
const result = findFreePositionInViewport({
44+
viewport: DEFAULT_VIEWPORT,
45+
canvasRect: DEFAULT_CANVAS,
46+
nodes: [],
47+
nodeSize: NOTE_SIZE,
48+
});
49+
50+
const visible = visibleBoundsFor(DEFAULT_VIEWPORT, DEFAULT_CANVAS);
51+
const placed = rectFor(result, NOTE_SIZE);
52+
expect(placed.minX).toBeGreaterThanOrEqual(visible.minX + PADDING);
53+
expect(placed.minY).toBeGreaterThanOrEqual(visible.minY + PADDING);
54+
expect(placed.maxX).toBeLessThanOrEqual(visible.maxX - PADDING);
55+
expect(placed.maxY).toBeLessThanOrEqual(visible.maxY - PADDING);
56+
});
57+
58+
it("returns a position that does not overlap any existing node", () => {
59+
// Pack a cluster of nodes tightly around the viewport center so the
60+
// fan-out has to do real work.
61+
const centerX = DEFAULT_CANVAS.width / 2 - NOTE_SIZE.width / 2;
62+
const centerY = DEFAULT_CANVAS.height / 2 - NOTE_SIZE.height / 2;
63+
const existing = [
64+
{ position: { x: centerX, y: centerY }, width: NOTE_SIZE.width, height: NOTE_SIZE.height },
65+
{ position: { x: centerX - 80, y: centerY }, width: NOTE_SIZE.width, height: NOTE_SIZE.height },
66+
{ position: { x: centerX + 80, y: centerY }, width: NOTE_SIZE.width, height: NOTE_SIZE.height },
67+
{ position: { x: centerX, y: centerY - 60 }, width: NOTE_SIZE.width, height: NOTE_SIZE.height },
68+
{ position: { x: centerX, y: centerY + 60 }, width: NOTE_SIZE.width, height: NOTE_SIZE.height },
69+
];
70+
71+
const result = findFreePositionInViewport({
72+
viewport: DEFAULT_VIEWPORT,
73+
canvasRect: DEFAULT_CANVAS,
74+
nodes: existing,
75+
nodeSize: NOTE_SIZE,
76+
});
77+
78+
const placed = rectFor(result, NOTE_SIZE);
79+
for (const node of existing) {
80+
expect(overlaps(placed, rectFor(node.position, { width: node.width, height: node.height }))).toBe(false);
81+
}
82+
});
83+
84+
it("stays inside the visible viewport after pan and zoom", () => {
85+
const viewport = { x: -500, y: -200, zoom: 0.5 };
86+
87+
const result = findFreePositionInViewport({
88+
viewport,
89+
canvasRect: DEFAULT_CANVAS,
90+
nodes: [],
91+
nodeSize: NOTE_SIZE,
92+
});
93+
94+
const visible = visibleBoundsFor(viewport, DEFAULT_CANVAS);
95+
const placed = rectFor(result, NOTE_SIZE);
96+
expect(placed.minX).toBeGreaterThanOrEqual(visible.minX + PADDING);
97+
expect(placed.minY).toBeGreaterThanOrEqual(visible.minY + PADDING);
98+
expect(placed.maxX).toBeLessThanOrEqual(visible.maxX - PADDING);
99+
expect(placed.maxY).toBeLessThanOrEqual(visible.maxY - PADDING);
100+
});
101+
102+
it("falls back to fallbackCanvasSize when canvasRect is missing", () => {
103+
// Covers the real-world case where the canvas element hasn't been measured
104+
// yet (no ResizeObserver tick) — the helper must still return a usable
105+
// position inside the fallback bounds instead of NaN / 0-size.
106+
const fallback = { width: 400, height: 300 };
107+
108+
const result = findFreePositionInViewport({
109+
viewport: DEFAULT_VIEWPORT,
110+
canvasRect: null,
111+
nodes: [],
112+
nodeSize: NOTE_SIZE,
113+
fallbackCanvasSize: fallback,
114+
});
115+
116+
const visible = visibleBoundsFor(DEFAULT_VIEWPORT, fallback);
117+
const placed = rectFor(result, NOTE_SIZE);
118+
expect(Number.isFinite(placed.minX)).toBe(true);
119+
expect(Number.isFinite(placed.minY)).toBe(true);
120+
expect(placed.minX).toBeGreaterThanOrEqual(visible.minX + PADDING);
121+
expect(placed.minY).toBeGreaterThanOrEqual(visible.minY + PADDING);
122+
});
123+
});
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
export interface ViewportLike {
2+
x: number;
3+
y: number;
4+
zoom: number;
5+
}
6+
7+
export interface CanvasRectLike {
8+
width: number;
9+
height: number;
10+
}
11+
12+
export interface PositionedNodeLike {
13+
position: { x: number; y: number };
14+
width?: number | null;
15+
height?: number | null;
16+
}
17+
18+
export interface FindFreePositionInViewportInput {
19+
viewport: ViewportLike;
20+
canvasRect: CanvasRectLike | null | undefined;
21+
nodes: PositionedNodeLike[];
22+
nodeSize: { width: number; height: number };
23+
fallbackCanvasSize?: { width: number; height: number };
24+
}
25+
26+
const PADDING = 16;
27+
const STEP = 40;
28+
const MAX_RINGS = 8;
29+
const DEFAULT_NODE_WIDTH = 240;
30+
const DEFAULT_NODE_HEIGHT = 120;
31+
32+
/**
33+
* Places a new node inside the currently visible part of the canvas (flow coords),
34+
* starting from the viewport center and fanning out in rings until a non-overlapping
35+
* spot is found. Used by the "Add Note" button and the keyboard-drop shortcut so
36+
* both entry points behave identically.
37+
*
38+
* Purely functional — all inputs are passed in so this is easy to test and to
39+
* call from both mouse and keyboard code paths.
40+
*/
41+
export function findFreePositionInViewport(input: FindFreePositionInViewportInput): { x: number; y: number } {
42+
const { viewport, canvasRect, nodes, nodeSize, fallbackCanvasSize = { width: 0, height: 0 } } = input;
43+
44+
const visibleWidth = canvasRect?.width ?? fallbackCanvasSize.width;
45+
const visibleHeight = canvasRect?.height ?? fallbackCanvasSize.height;
46+
const zoom = viewport.zoom || 1;
47+
48+
const visibleBounds = {
49+
minX: (0 - viewport.x) / zoom,
50+
minY: (0 - viewport.y) / zoom,
51+
maxX: (visibleWidth - viewport.x) / zoom,
52+
maxY: (visibleHeight - viewport.y) / zoom,
53+
};
54+
55+
const basePosition = {
56+
x: (visibleWidth / 2 - viewport.x) / zoom - nodeSize.width / 2,
57+
y: (visibleHeight / 2 - viewport.y) / zoom - nodeSize.height / 2,
58+
};
59+
60+
const intersects = (pos: { x: number; y: number }) => {
61+
const bounds = {
62+
minX: pos.x - PADDING,
63+
minY: pos.y - PADDING,
64+
maxX: pos.x + nodeSize.width + PADDING,
65+
maxY: pos.y + nodeSize.height + PADDING,
66+
};
67+
return nodes.some((node) => {
68+
const width = node.width ?? DEFAULT_NODE_WIDTH;
69+
const height = node.height ?? DEFAULT_NODE_HEIGHT;
70+
const nodeBounds = {
71+
minX: node.position.x,
72+
minY: node.position.y,
73+
maxX: node.position.x + width,
74+
maxY: node.position.y + height,
75+
};
76+
return !(
77+
bounds.maxX < nodeBounds.minX ||
78+
bounds.minX > nodeBounds.maxX ||
79+
bounds.maxY < nodeBounds.minY ||
80+
bounds.minY > nodeBounds.maxY
81+
);
82+
});
83+
};
84+
85+
const clampToVisible = (pos: { x: number; y: number }) => {
86+
const minX = visibleBounds.minX + PADDING;
87+
const minY = visibleBounds.minY + PADDING;
88+
const maxX = visibleBounds.maxX - nodeSize.width - PADDING;
89+
const maxY = visibleBounds.maxY - nodeSize.height - PADDING;
90+
return {
91+
x: Math.min(Math.max(pos.x, minX), maxX),
92+
y: Math.min(Math.max(pos.y, minY), maxY),
93+
};
94+
};
95+
96+
const basePositionClamped = clampToVisible(basePosition);
97+
if (!intersects(basePositionClamped)) {
98+
return basePositionClamped;
99+
}
100+
101+
for (let ring = 1; ring <= MAX_RINGS; ring += 1) {
102+
for (let dx = -ring; dx <= ring; dx += 1) {
103+
for (let dy = -ring; dy <= ring; dy += 1) {
104+
// Only walk the perimeter of the current ring — interior cells were
105+
// already tested by smaller rings.
106+
if (Math.abs(dx) !== ring && Math.abs(dy) !== ring) continue;
107+
const candidate = clampToVisible({
108+
x: basePosition.x + dx * STEP,
109+
y: basePosition.y + dy * STEP,
110+
});
111+
if (!intersects(candidate)) {
112+
return candidate;
113+
}
114+
}
115+
}
116+
}
117+
118+
return basePositionClamped;
119+
}

web_src/src/ui/BuildingBlocksSidebar/CategorySection.tsx

Lines changed: 6 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ChevronRight, GripVerticalIcon, Plug } from "lucide-react";
55
import { useState } from "react";
66
import { toTestId } from "../../lib/testID";
77
import { getHeaderIconSrc, getIntegrationIconSrc } from "../componentSidebar/integrationIcons";
8+
import { filterBlocksInCategory, type TypeFilter } from "./filter";
89
import type { BuildingBlock, BuildingBlockCategory } from "./types";
910

1011
const TYPE_HOVER_BG: Record<string, string> = {
@@ -134,7 +135,7 @@ export interface CategorySectionProps {
134135
showIntegrationSetupStatus: boolean;
135136
canvasZoom: number;
136137
searchTerm?: string;
137-
typeFilter?: "all" | "trigger" | "component";
138+
typeFilter?: TypeFilter;
138139
isDraggingRef: React.RefObject<boolean>;
139140
setHoveredBlock: (block: BuildingBlock | null) => void;
140141
dragPreviewRef: React.RefObject<HTMLDivElement | null>;
@@ -155,35 +156,18 @@ export function CategorySection({
155156
}: CategorySectionProps) {
156157
const normalizeIntegrationName = (value?: string) => (value || "").toLowerCase().replace(/[^a-z0-9]/g, "");
157158

158-
const query = searchTerm.trim().toLowerCase();
159-
const categoryMatches = query ? (category.name || "").toLowerCase().includes(query) : true;
160-
161-
const baseBlocks = categoryMatches
162-
? category.blocks || []
163-
: (category.blocks || []).filter((block) => {
164-
const name = (block.name || "").toLowerCase();
165-
const label = (block.label || "").toLowerCase();
166-
return name.includes(query) || label.includes(query);
167-
});
168-
169-
let allBlocks = baseBlocks;
170-
171-
if (typeFilter !== "all") {
172-
allBlocks = allBlocks.filter((block) => {
173-
return block.type === typeFilter;
174-
});
175-
}
159+
const sortedBlocks = filterBlocksInCategory(category, searchTerm, typeFilter);
176160

177161
const isCoreCategory = category.name === "Core";
178-
const hasSearchTerm = query.length > 0;
162+
const hasSearchTerm = searchTerm.trim().length > 0;
179163
const [isManuallyOpen, setIsManuallyOpen] = useState<boolean | null>(null);
180164
const isOpen = hasSearchTerm || (isManuallyOpen ?? isCoreCategory);
181165

182-
if (allBlocks.length === 0) {
166+
if (sortedBlocks.length === 0) {
183167
return null;
184168
}
185169

186-
const firstBlock = allBlocks[0];
170+
const firstBlock = sortedBlocks[0];
187171
const integrationName = firstBlock?.integrationName || category.name.toLowerCase();
188172
const categoryIconSrc = integrationName === "smtp" ? undefined : getIntegrationIconSrc(integrationName);
189173

@@ -229,28 +213,6 @@ export function CategorySection({
229213
CategoryIcon = resolveIcon("puzzle");
230214
}
231215

232-
let sortedBlocks: BuildingBlock[] = [];
233-
if (isOpen) {
234-
const typeOrder: Record<"trigger" | "component" | "blueprint", number> = {
235-
trigger: 0,
236-
component: 1,
237-
blueprint: 2,
238-
};
239-
240-
sortedBlocks = [...allBlocks].sort((a, b) => {
241-
const aType = a.type;
242-
const bType = b.type;
243-
const typeComparison = typeOrder[aType] - typeOrder[bType];
244-
if (typeComparison !== 0) {
245-
return typeComparison;
246-
}
247-
248-
const aName = (a.label || a.name || "").toLowerCase();
249-
const bName = (b.label || b.name || "").toLowerCase();
250-
return aName.localeCompare(bName);
251-
});
252-
}
253-
254216
return (
255217
<details
256218
className="flex-1 px-5 mb-5 group"

0 commit comments

Comments
 (0)