Skip to content

Commit 61f8653

Browse files
committed
feat: using SVG in place of clip path for hexagon, cylinder and diamond shape. added shape Ghost preview on draggin time.
1 parent a2e8bde commit 61f8653

4 files changed

Lines changed: 430 additions & 92 deletions

File tree

components/editor/canvas-node.tsx

Lines changed: 220 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,162 @@ function NodeHandles() {
4040
}
4141

4242
// ---------------------------------------------------------------------------
43-
// Shape visual styles
43+
// SVG shape renderers — diamond, hexagon, cylinder
4444
// ---------------------------------------------------------------------------
4545

46-
function getShapeStyle(shape: CanvasShape | undefined, selected: boolean): React.CSSProperties {
46+
interface SvgShapeProps {
47+
width: number;
48+
height: number;
49+
selected: boolean;
50+
label: string;
51+
shape: CanvasShape;
52+
}
53+
54+
const STROKE_REST = "var(--border-default)";
55+
const STROKE_SELECTED = "var(--accent-primary)";
56+
const FILL_COLOR = "var(--bg-surface)";
57+
const STROKE_WIDTH = 1.5;
58+
59+
function DiamondSvg({ width, height, selected, label }: SvgShapeProps) {
60+
const stroke = selected ? STROKE_SELECTED : STROKE_REST;
61+
const mid = { x: width / 2, y: height / 2 };
62+
const points = `${mid.x},0 ${width},${mid.y} ${mid.x},${height} 0,${mid.y}`;
63+
return (
64+
<svg
65+
width={width}
66+
height={height}
67+
viewBox={`0 0 ${width} ${height}`}
68+
style={{ display: "block", transition: "stroke 0.15s ease", overflow: "visible" }}
69+
>
70+
<polygon
71+
points={points}
72+
fill={FILL_COLOR}
73+
stroke={stroke}
74+
strokeWidth={STROKE_WIDTH}
75+
/>
76+
<text
77+
x={mid.x}
78+
y={mid.y}
79+
textAnchor="middle"
80+
dominantBaseline="central"
81+
style={{
82+
fontSize: 12,
83+
fontFamily: "var(--font-sans)",
84+
fill: label ? "var(--text-primary)" : "var(--text-muted)",
85+
fontStyle: label ? "normal" : "italic",
86+
pointerEvents: "none",
87+
}}
88+
>
89+
{label || "diamond"}
90+
</text>
91+
</svg>
92+
);
93+
}
94+
95+
function HexagonSvg({ width, height, selected, label }: SvgShapeProps) {
96+
const stroke = selected ? STROKE_SELECTED : STROKE_REST;
97+
const mid = { x: width / 2, y: height / 2 };
98+
const qx = width * 0.25;
99+
const qx3 = width * 0.75;
100+
const points = `${qx},0 ${qx3},0 ${width},${mid.y} ${qx3},${height} ${qx},${height} 0,${mid.y}`;
101+
return (
102+
<svg
103+
width={width}
104+
height={height}
105+
viewBox={`0 0 ${width} ${height}`}
106+
style={{ display: "block", transition: "stroke 0.15s ease", overflow: "visible" }}
107+
>
108+
<polygon
109+
points={points}
110+
fill={FILL_COLOR}
111+
stroke={stroke}
112+
strokeWidth={STROKE_WIDTH}
113+
/>
114+
<text
115+
x={mid.x}
116+
y={mid.y}
117+
textAnchor="middle"
118+
dominantBaseline="central"
119+
style={{
120+
fontSize: 12,
121+
fontFamily: "var(--font-sans)",
122+
fill: label ? "var(--text-primary)" : "var(--text-muted)",
123+
fontStyle: label ? "normal" : "italic",
124+
pointerEvents: "none",
125+
}}
126+
>
127+
{label || "hexagon"}
128+
</text>
129+
</svg>
130+
);
131+
}
132+
133+
function CylinderSvg({ width, height, selected, label }: SvgShapeProps) {
134+
const stroke = selected ? STROKE_SELECTED : STROKE_REST;
135+
// Cap ellipse radius along Y axis — proportional to width
136+
const ry = Math.max(6, width * 0.12);
137+
const mid = { x: width / 2, y: height / 2 + ry / 2 };
138+
return (
139+
<svg
140+
width={width}
141+
height={height}
142+
viewBox={`0 0 ${width} ${height}`}
143+
style={{ display: "block", transition: "stroke 0.15s ease", overflow: "visible" }}
144+
>
145+
{/* Body rectangle */}
146+
<rect
147+
x={0}
148+
y={ry}
149+
width={width}
150+
height={height - ry}
151+
fill={FILL_COLOR}
152+
stroke={stroke}
153+
strokeWidth={STROKE_WIDTH}
154+
/>
155+
{/* Bottom cap ellipse */}
156+
<ellipse
157+
cx={width / 2}
158+
cy={height}
159+
rx={width / 2}
160+
ry={ry}
161+
fill={FILL_COLOR}
162+
stroke={stroke}
163+
strokeWidth={STROKE_WIDTH}
164+
/>
165+
{/* Top cap ellipse */}
166+
<ellipse
167+
cx={width / 2}
168+
cy={ry}
169+
rx={width / 2}
170+
ry={ry}
171+
fill={FILL_COLOR}
172+
stroke={stroke}
173+
strokeWidth={STROKE_WIDTH}
174+
/>
175+
<text
176+
x={mid.x}
177+
y={mid.y}
178+
textAnchor="middle"
179+
dominantBaseline="central"
180+
style={{
181+
fontSize: 12,
182+
fontFamily: "var(--font-sans)",
183+
fill: label ? "var(--text-primary)" : "var(--text-muted)",
184+
fontStyle: label ? "normal" : "italic",
185+
pointerEvents: "none",
186+
}}
187+
>
188+
{label || "cylinder"}
189+
</text>
190+
</svg>
191+
);
192+
}
193+
194+
// ---------------------------------------------------------------------------
195+
// CSS shape styles — rectangle, circle, pill
196+
// ---------------------------------------------------------------------------
197+
198+
function getCssShapeStyle(shape: CanvasShape | undefined, selected: boolean): React.CSSProperties {
47199
const base: React.CSSProperties = {
48200
width: "100%",
49201
height: "100%",
@@ -52,56 +204,23 @@ function getShapeStyle(shape: CanvasShape | undefined, selected: boolean): React
52204
justifyContent: "center",
53205
boxSizing: "border-box",
54206
background: "var(--bg-surface)",
55-
border: `1.5px solid ${selected ? "var(--accent-primary)" : "var(--border-default)"}`,
207+
border: `${STROKE_WIDTH}px solid ${selected ? STROKE_SELECTED : STROKE_REST}`,
56208
transition: "border-color 0.15s ease",
57209
overflow: "hidden",
58210
position: "relative",
59211
};
60212

61213
switch (shape) {
62-
case "circle": return { ...base, borderRadius: "50%" };
63-
case "pill": return { ...base, borderRadius: 9999 };
64-
case "cylinder": return { ...base, borderRadius: "50% / 15%" };
65-
case "diamond":
66-
return {
67-
...base,
68-
background: "transparent",
69-
border: "none",
70-
clipPath: "polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)",
71-
};
72-
case "hexagon":
73-
return {
74-
...base,
75-
background: "transparent",
76-
border: "none",
77-
clipPath: "polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%)",
78-
};
214+
case "circle":
215+
return { ...base, borderRadius: "50%" };
216+
case "pill":
217+
return { ...base, borderRadius: 9999 };
79218
case "rectangle":
80219
default:
81220
return { ...base, borderRadius: 6 };
82221
}
83222
}
84223

85-
// Diamond and hexagon need a filled inner layer since the outer uses clip-path (border gets clipped).
86-
function ClipFill({ shape, selected }: { shape: CanvasShape; selected: boolean }) {
87-
const clipPath =
88-
shape === "diamond"
89-
? "polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)"
90-
: "polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%)";
91-
return (
92-
<div
93-
style={{
94-
position: "absolute",
95-
inset: 0,
96-
background: "var(--bg-surface)",
97-
border: `1.5px solid ${selected ? "var(--accent-primary)" : "var(--border-default)"}`,
98-
clipPath,
99-
transition: "border-color 0.15s ease",
100-
}}
101-
/>
102-
);
103-
}
104-
105224
// ---------------------------------------------------------------------------
106225
// Dimension input — appears on node hover
107226
// ---------------------------------------------------------------------------
@@ -158,6 +277,8 @@ function DimInput({ axis, value, onCommit }: DimInputProps) {
158277
// Custom node component
159278
// ---------------------------------------------------------------------------
160279

280+
const SVG_SHAPES = new Set<CanvasShape>(["diamond", "hexagon", "cylinder"]);
281+
161282
export const CanvasNodeComponent = memo(function CanvasNodeComponent({
162283
data,
163284
selected,
@@ -184,7 +305,7 @@ export const CanvasNodeComponent = memo(function CanvasNodeComponent({
184305
[id, setNodes],
185306
);
186307

187-
const needsClipFill = shape === "diamond" || shape === "hexagon";
308+
const isSvgShape = shape !== undefined && SVG_SHAPES.has(shape);
188309

189310
return (
190311
<div
@@ -202,31 +323,68 @@ export const CanvasNodeComponent = memo(function CanvasNodeComponent({
202323

203324
<NodeHandles />
204325

205-
{/* Shape visual — inner div so clip-path doesn't swallow the handles */}
206-
<div style={getShapeStyle(shape, !!selected)}>
207-
{needsClipFill && <ClipFill shape={shape!} selected={!!selected} />}
208-
<span
326+
{isSvgShape ? (
327+
/* SVG-based shapes — diamond, hexagon, cylinder */
328+
<div
209329
style={{
210-
fontSize: 12,
211-
fontFamily: "var(--font-sans)",
212-
color: "var(--text-primary)",
213-
textAlign: "center",
214-
lineHeight: 1.4,
215-
overflow: "hidden",
216-
textOverflow: "ellipsis",
217-
whiteSpace: "nowrap",
218-
maxWidth: "80%",
219-
position: "relative",
220-
zIndex: 1,
330+
width: "100%",
331+
height: "100%",
332+
display: "flex",
333+
alignItems: "center",
334+
justifyContent: "center",
221335
}}
222336
>
223-
{data.label || (
224-
<span style={{ color: "var(--text-muted)", fontStyle: "italic" }}>
225-
{shape ?? "node"}
226-
</span>
337+
{shape === "diamond" && (
338+
<DiamondSvg
339+
width={width}
340+
height={height}
341+
selected={!!selected}
342+
label={data.label}
343+
shape={shape}
344+
/>
345+
)}
346+
{shape === "hexagon" && (
347+
<HexagonSvg
348+
width={width}
349+
height={height}
350+
selected={!!selected}
351+
label={data.label}
352+
shape={shape}
353+
/>
227354
)}
228-
</span>
229-
</div>
355+
{shape === "cylinder" && (
356+
<CylinderSvg
357+
width={width}
358+
height={height}
359+
selected={!!selected}
360+
label={data.label}
361+
shape={shape}
362+
/>
363+
)}
364+
</div>
365+
) : (
366+
/* CSS-based shapes — rectangle, circle, pill */
367+
<div style={getCssShapeStyle(shape, !!selected)}>
368+
<span
369+
style={{
370+
fontSize: 12,
371+
fontFamily: "var(--font-sans)",
372+
color: data.label ? "var(--text-primary)" : "var(--text-muted)",
373+
fontStyle: data.label ? "normal" : "italic",
374+
textAlign: "center",
375+
lineHeight: 1.4,
376+
overflow: "hidden",
377+
textOverflow: "ellipsis",
378+
whiteSpace: "nowrap",
379+
maxWidth: "80%",
380+
position: "relative",
381+
zIndex: 1,
382+
}}
383+
>
384+
{data.label || (shape ?? "node")}
385+
</span>
386+
</div>
387+
)}
230388

231389
{/* Dimension inputs — only on hover */}
232390
{isHovered && (

0 commit comments

Comments
 (0)