Skip to content

Commit 1ef1969

Browse files
feat: BROS-615: Vector UX rework (#8789)
Co-authored-by: niklub <[email protected]> Co-authored-by: nick-skriabin <[email protected]>
1 parent 32da2e9 commit 1ef1969

File tree

22 files changed

+3309
-848
lines changed

22 files changed

+3309
-848
lines changed

web/libs/editor/src/components/KonvaVector/KonvaVector.tsx

Lines changed: 2178 additions & 373 deletions
Large diffs are not rendered by default.

web/libs/editor/src/components/KonvaVector/components/GhostLine.tsx

Lines changed: 251 additions & 157 deletions
Large diffs are not rendered by default.
Lines changed: 60 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,83 @@
1-
import type React from "react";
21
import { Circle } from "react-konva";
2+
import { useRef, useEffect, useImperativeHandle, forwardRef } from "react";
33
import type { GhostPoint as GhostPointType } from "../types";
44

55
interface GhostPointProps {
66
ghostPoint: GhostPointType | null;
77
transform: { zoom: number; offsetX: number; offsetY: number };
88
fitScale: number;
9-
isShiftKeyHeld: boolean;
9+
isShiftKeyHeld?: boolean; // Made optional - if ghostPoint is set, Shift was held
1010
maxPoints?: number;
1111
initialPointsLength: number;
1212
isDragging?: boolean;
1313
}
1414

15-
export const GhostPoint: React.FC<GhostPointProps> = ({
16-
ghostPoint,
17-
transform,
18-
fitScale,
19-
isShiftKeyHeld,
20-
maxPoints,
21-
initialPointsLength,
22-
isDragging = false,
23-
}) => {
24-
// Only show the visual ghost point when Shift is held, but don't clear the ghostPoint state
25-
if (!ghostPoint) return null;
15+
export interface GhostPointRef {
16+
updatePosition: (x: number, y: number) => void;
17+
}
2618

27-
// Only render the visual element when Shift is held
28-
if (!isShiftKeyHeld) return null;
19+
export const GhostPoint = forwardRef<GhostPointRef, GhostPointProps>(
20+
({ ghostPoint, transform, fitScale, isShiftKeyHeld, maxPoints, initialPointsLength, isDragging = false }, ref) => {
21+
if (!ghostPoint) {
22+
return null;
23+
}
2924

30-
// Hide ghost point when max points reached
31-
if (maxPoints !== undefined && initialPointsLength >= maxPoints) return null;
25+
// Hide ghost point when maxPoints is reached
26+
if (maxPoints !== undefined && initialPointsLength >= maxPoints) {
27+
return null;
28+
}
3229

33-
// Hide ghost point when dragging
34-
if (isDragging) return null;
30+
// Scale radius to compensate for Layer scaling
31+
const scale = transform.zoom * fitScale;
32+
const radius = 6 / scale;
3533

36-
// Scale up radius to compensate for Layer scaling
37-
const scale = transform.zoom * fitScale;
38-
const outerRadius = 4 / scale;
39-
const innerRadius = 2 / scale;
34+
// Use a ref to force Konva to update position
35+
const circleRef = useRef<Konva.Circle>(null);
4036

41-
return (
42-
<>
43-
{/* Outer ring */}
44-
<Circle
45-
x={ghostPoint.x}
46-
y={ghostPoint.y}
47-
radius={outerRadius}
48-
fill="rgba(34, 197, 94, 0.2)"
49-
stroke="#22c55e"
50-
strokeWidth={1.5}
51-
strokeScaleEnabled={false}
52-
listening={false}
53-
/>
54-
{/* White center */}
37+
// Expose updatePosition method via ref
38+
useImperativeHandle(ref, () => ({
39+
updatePosition: (x: number, y: number) => {
40+
if (circleRef.current) {
41+
circleRef.current.setPosition({ x, y });
42+
// Force Konva to redraw
43+
const stage = circleRef.current.getStage();
44+
if (stage) {
45+
stage.batchDraw();
46+
}
47+
}
48+
},
49+
}));
50+
51+
// Update position whenever ghostPoint changes
52+
useEffect(() => {
53+
if (circleRef.current && ghostPoint) {
54+
circleRef.current.setPosition({ x: ghostPoint.x, y: ghostPoint.y });
55+
// Force Konva to redraw
56+
const stage = circleRef.current.getStage();
57+
if (stage) {
58+
stage.batchDraw();
59+
}
60+
}
61+
}, [ghostPoint?.x, ghostPoint?.y]);
62+
63+
// Use a key that includes position to force re-render when position changes
64+
// Round position to avoid key changes from floating point precision
65+
const keyX = Math.round(ghostPoint.x * 100) / 100;
66+
const keyY = Math.round(ghostPoint.y * 100) / 100;
67+
68+
return (
5569
<Circle
70+
ref={circleRef}
71+
key={`ghost-point-${keyX}-${keyY}-${ghostPoint.prevPointId}-${ghostPoint.nextPointId}`}
5672
x={ghostPoint.x}
5773
y={ghostPoint.y}
58-
radius={innerRadius}
59-
fill="#ffffff"
60-
stroke="#22c55e"
61-
strokeWidth={0.5}
74+
radius={radius}
75+
fill="#87CEEB"
76+
stroke="white"
77+
strokeWidth={2}
6278
strokeScaleEnabled={false}
6379
listening={false}
6480
/>
65-
</>
66-
);
67-
};
81+
);
82+
},
83+
);

web/libs/editor/src/components/KonvaVector/components/ProxyNodes.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import { Rect } from "react-konva";
1+
import { Circle } from "react-konva";
22
import type Konva from "konva";
33
import type { BezierPoint } from "../types";
44

55
interface ProxyNodesProps {
66
selectedPoints: Set<number>;
77
initialPoints: BezierPoint[];
8-
proxyRefs: React.MutableRefObject<{ [key: number]: Konva.Rect | null }>;
8+
proxyRefs: React.MutableRefObject<{ [key: number]: Konva.Circle | null }>;
99
}
1010

1111
export const ProxyNodes: React.FC<ProxyNodesProps> = ({ selectedPoints, initialPoints, proxyRefs }) => {
@@ -18,15 +18,16 @@ export const ProxyNodes: React.FC<ProxyNodesProps> = ({ selectedPoints, initialP
1818
if (!point) return null;
1919

2020
return (
21-
<Rect
21+
<Circle
2222
key={`proxy-${pointIndex}`}
2323
ref={(node) => {
2424
proxyRefs.current[pointIndex] = node;
2525
}}
2626
x={point.x}
2727
y={point.y}
28-
width={1}
29-
height={1}
28+
radius={10}
29+
fill="transparent"
30+
stroke="transparent"
3031
strokeWidth={1}
3132
listening={true}
3233
name={`proxy-${pointIndex}`}

web/libs/editor/src/components/KonvaVector/components/VectorPoints.tsx

Lines changed: 97 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type React from "react";
22
import { Circle } from "react-konva";
33
import type Konva from "konva";
44
import type { BezierPoint } from "../types";
5+
import { HIT_RADIUS } from "../constants";
56

67
interface VectorPointsProps {
78
initialPoints: BezierPoint[];
@@ -11,6 +12,7 @@ interface VectorPointsProps {
1112
fitScale: number;
1213
pointRefs: React.MutableRefObject<{ [key: number]: Konva.Circle | null }>;
1314
disabled?: boolean;
15+
transformMode?: boolean;
1416
pointRadius?: {
1517
enabled?: number;
1618
disabled?: number;
@@ -19,6 +21,8 @@ interface VectorPointsProps {
1921
pointStroke?: string;
2022
pointStrokeSelected?: string;
2123
pointStrokeWidth?: number;
24+
activePointId?: string | null;
25+
maxPoints?: number;
2226
onPointClick?: (e: Konva.KonvaEventObject<MouseEvent>, pointIndex: number) => void;
2327
}
2428

@@ -30,13 +34,22 @@ export const VectorPoints: React.FC<VectorPointsProps> = ({
3034
fitScale,
3135
pointRefs,
3236
disabled = false,
37+
transformMode = false,
3338
pointRadius,
3439
pointFill = "#ffffff",
3540
pointStroke = "#3b82f6",
36-
pointStrokeSelected = "#fbbf24",
41+
pointStrokeSelected = "#ffffff",
3742
pointStrokeWidth = 2,
43+
activePointId = null,
44+
maxPoints,
3845
onPointClick,
3946
}) => {
47+
// CRITICAL: For single-point regions, we need to allow clicks even when disabled
48+
// Single-point regions have no segments to click on, so clicking the point must trigger region selection
49+
// BUT: Never allow clicks when in transform mode
50+
const isSinglePointRegion = initialPoints.length === 1;
51+
const shouldListenToClicks = !transformMode && (!disabled || isSinglePointRegion);
52+
4053
return (
4154
<>
4255
{initialPoints.map((point, index) => {
@@ -46,26 +59,91 @@ export const VectorPoints: React.FC<VectorPointsProps> = ({
4659
const enabledRadius = pointRadius?.enabled ?? 6;
4760
const disabledRadius = pointRadius?.disabled ?? 4;
4861
const baseRadius = disabled ? disabledRadius : enabledRadius;
49-
const scaledRadius = baseRadius / scale;
50-
const isSelected = selectedPointIndex === index || selectedPoints.has(index);
62+
// Check if maxPoints is reached
63+
const isMaxPointsReached = maxPoints !== undefined && initialPoints.length >= maxPoints;
64+
// Check if multiple points are selected
65+
const isMultiSelection = selectedPoints.size > 1;
66+
// Point is explicitly selected if it's in selectedPoints or is the selectedPointIndex
67+
const isExplicitlySelected = selectedPointIndex === index || selectedPoints.has(index);
68+
// Active point should only be rendered as selected if:
69+
// - It's explicitly selected, OR
70+
// - (Not disabled AND maxPoints not reached AND not in multi-selection AND it's the active point)
71+
const isSelected =
72+
isExplicitlySelected ||
73+
(!disabled &&
74+
!isMaxPointsReached &&
75+
!isMultiSelection &&
76+
activePointId !== null &&
77+
point.id === activePointId);
78+
// Make selected points larger
79+
const radiusMultiplier = isSelected ? 1.3 : 1;
80+
const scaledRadius = (baseRadius * radiusMultiplier) / scale;
5181

5282
return (
53-
<Circle
54-
key={`point-${index}-${point.x}-${point.y}`}
55-
ref={(node) => {
56-
pointRefs.current[index] = node;
57-
}}
58-
x={point.x}
59-
y={point.y}
60-
radius={scaledRadius}
61-
fill={pointFill}
62-
stroke={isSelected ? pointStrokeSelected : pointStroke}
63-
strokeScaleEnabled={false}
64-
strokeWidth={pointStrokeWidth}
65-
listening={true}
66-
name={`point-${index}`}
67-
onClick={onPointClick ? (e) => onPointClick(e, index) : undefined}
68-
/>
83+
<>
84+
{/* White outline ring for selected points - rendered outside the colored stroke */}
85+
{!disabled && isSelected && (
86+
<Circle
87+
key={`point-outline-${index}-${point.x}-${point.y}`}
88+
x={point.x}
89+
y={point.y}
90+
radius={scaledRadius}
91+
fill="transparent"
92+
stroke={pointStrokeSelected}
93+
strokeScaleEnabled={false}
94+
strokeWidth={pointStrokeWidth + 5}
95+
listening={false}
96+
name={`point-outline-${index}`}
97+
/>
98+
)}
99+
{/* Main point circle with colored stroke */}
100+
<Circle
101+
key={`point-${index}-${point.x}-${point.y}`}
102+
ref={(node) => {
103+
pointRefs.current[index] = node;
104+
}}
105+
x={point.x}
106+
y={point.y}
107+
radius={scaledRadius}
108+
fill={pointFill}
109+
stroke={pointStroke}
110+
strokeScaleEnabled={false}
111+
strokeWidth={pointStrokeWidth}
112+
listening={shouldListenToClicks}
113+
name={`point-${index}`}
114+
// Use custom hit function to create a larger clickable area around the point
115+
// This makes points easier to click even when the cursor is not exactly over the point
116+
hitFunc={(context, shape) => {
117+
// Calculate a larger hit radius using the constant (scaled for current zoom)
118+
const hitRadius = HIT_RADIUS.SELECTION / scale;
119+
context.beginPath();
120+
context.arc(0, 0, hitRadius, 0, Math.PI * 2);
121+
context.fillStrokeShape(shape);
122+
}}
123+
onClick={
124+
onPointClick
125+
? (e) => {
126+
// For single-point regions, call onPointClick but don't stop propagation
127+
// The onPointClick handler in KonvaVector will directly call handleClickWithDebouncing
128+
// to trigger region selection
129+
if (isSinglePointRegion && !e.evt.altKey && !e.evt.shiftKey && !e.evt.ctrlKey && !e.evt.metaKey) {
130+
// Don't stop propagation - let onPointClick handle it and call onClick directly
131+
onPointClick(e, index);
132+
return;
133+
}
134+
135+
// Stop propagation immediately to prevent the event from bubbling to VectorShape onClick
136+
// This prevents the shape from being selected/unselected when clicking on points
137+
e.evt.stopImmediatePropagation();
138+
e.evt.stopPropagation();
139+
e.evt.preventDefault();
140+
e.cancelBubble = true;
141+
onPointClick(e, index);
142+
}
143+
: undefined
144+
}
145+
/>
146+
</>
69147
);
70148
})}
71149
</>

web/libs/editor/src/components/KonvaVector/components/VectorShape.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ interface VectorShapeProps {
1717
onClick?: (e: KonvaEventObject<MouseEvent>) => void;
1818
onMouseEnter?: (e: any) => void;
1919
onMouseLeave?: (e: any) => void;
20+
onMouseDown?: (e: KonvaEventObject<MouseEvent>) => void;
21+
onMouseMove?: (e: KonvaEventObject<MouseEvent>) => void;
22+
onMouseUp?: (e: KonvaEventObject<MouseEvent>) => void;
2023
}
2124

2225
// Convert Bezier segments to SVG path data for a single continuous path
@@ -214,6 +217,9 @@ export const VectorShape: React.FC<VectorShapeProps> = ({
214217
onClick,
215218
onMouseEnter,
216219
onMouseLeave,
220+
onMouseDown,
221+
onMouseMove,
222+
onMouseUp,
217223
}) => {
218224
if (segments.length === 0) return null;
219225

@@ -272,6 +278,9 @@ export const VectorShape: React.FC<VectorShapeProps> = ({
272278
onClick={onClick}
273279
onMouseEnter={onMouseEnter}
274280
onMouseLeave={onMouseLeave}
281+
onMouseDown={onMouseDown}
282+
onMouseMove={onMouseMove}
283+
onMouseUp={onMouseUp}
275284
/>
276285
);
277286
})}
@@ -301,6 +310,9 @@ export const VectorShape: React.FC<VectorShapeProps> = ({
301310
onClick={onClick}
302311
onMouseEnter={onMouseEnter}
303312
onMouseLeave={onMouseLeave}
313+
onMouseDown={onMouseDown}
314+
onMouseMove={onMouseMove}
315+
onMouseUp={onMouseUp}
304316
/>
305317
);
306318
})}

0 commit comments

Comments
 (0)