Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
d856228
UX rework: allow external points addition, disable internal one
nick-skriabin Nov 7, 2025
e333b83
Skeleton and ghost line fixes
nick-skriabin Nov 8, 2025
f6c9ff4
Allow dragging shapes without transform
nick-skriabin Nov 8, 2025
c6311ee
Transform mode
nick-skriabin Nov 8, 2025
2a3987b
Fix max update depth exceeded
nick-skriabin Nov 8, 2025
13c937e
Fix infinite loop when using select tool
nick-skriabin Nov 8, 2025
7962d58
Fix ghost line
nick-skriabin Nov 9, 2025
6d71411
Multi-shape transform/translate
nick-skriabin Nov 9, 2025
d76d0ef
Fix ghost line rendering
nick-skriabin Nov 9, 2025
3dc97bc
Fix ghost line conditional rendering when hovering over the path or p…
nick-skriabin Nov 9, 2025
3723567
Fix ghost line disappearing when holding shift
nick-skriabin Nov 9, 2025
20fef6a
Fix ghost point rendering
nick-skriabin Nov 9, 2025
49cba69
Ghost point and shift-click support
nick-skriabin Nov 10, 2025
21849a2
Cleanup, only allow shift-hover over an active region
nick-skriabin Nov 10, 2025
53aa5d7
Fix shape/points transformation
nick-skriabin Nov 10, 2025
bf3e0ac
Fix cmd/ctrl-click on a point
nick-skriabin Nov 10, 2025
b09c3ef
Disable ghost line whe move tool is selected
nick-skriabin Nov 10, 2025
b00a041
Fix ghost point appearance
nick-skriabin Nov 10, 2025
ab2dbd6
Ghost point styles
nick-skriabin Nov 10, 2025
5b3f4e8
Hide ghost point when adding a new point or when maxPoints is reached
nick-skriabin Nov 10, 2025
1868373
Handle points deletion correctly
nick-skriabin Nov 10, 2025
765ba0a
Working on the selected points
nick-skriabin Nov 10, 2025
b51231c
Fix interactions
nick-skriabin Nov 10, 2025
ac8abb6
Fix selected point radius
nick-skriabin Nov 10, 2025
686a24c
Remove debugging
nick-skriabin Nov 10, 2025
3959dbd
Remove debug
nick-skriabin Nov 10, 2025
2d3fca6
Fix ghost point appearance on page load
nick-skriabin Nov 10, 2025
b5516d6
Fine-tune selected/active point appearance
nick-skriabin Nov 10, 2025
4afb489
Fix point dragging when shape is not selected
nick-skriabin Nov 10, 2025
ffb032c
Fix click and double click behavior
nick-skriabin Nov 10, 2025
7f97cd7
Fix points hit radius for easier selection
nick-skriabin Nov 10, 2025
25f5f75
Prevent point addition when in transform mode
nick-skriabin Nov 10, 2025
725f6ab
Formatting
nick-skriabin Nov 10, 2025
f2d9ff2
Properly store transformations in the undo history
nick-skriabin Nov 11, 2025
741240f
Undo for transformations
nick-skriabin Nov 11, 2025
9814550
Prevent undo double execution
nick-skriabin Nov 11, 2025
d210cfa
Fix poly+vector transformations
nick-skriabin Nov 11, 2025
7e53496
Rollback polygon changes as unnecessary
nick-skriabin Nov 11, 2025
aab8b7d
Fix unnecessary click delay
nick-skriabin Nov 11, 2025
f0f75c2
Formatting
nick-skriabin Nov 12, 2025
65e9fc2
Do not require isHovered to detect if the shape is actually hovered
nick-skriabin Nov 12, 2025
ce13dca
Fix path segments deletion and remove logs
nick-skriabin Nov 12, 2025
a85023e
Correctly delete points on a branch path
nick-skriabin Nov 12, 2025
4856e61
Partially delete vector regions
nick-skriabin Nov 12, 2025
376a6b0
Correctly reset transform mode when selecting a shape
nick-skriabin Nov 12, 2025
38fdef5
Delete entire region if it's a part of a selection
nick-skriabin Nov 12, 2025
ad27287
Fix false unselection on point click
nick-skriabin Nov 13, 2025
8017e61
Allow selecting single-point regions (keypoint functionality)
nick-skriabin Nov 13, 2025
f28e6bf
Formatting
nick-skriabin Nov 13, 2025
2c3b562
Remove unnecessary else's
nick-skriabin Nov 13, 2025
2c6d5e8
Ghost point fix
nick-skriabin Nov 13, 2025
520ef18
Fix undo/redo for vector
nick-skriabin Nov 13, 2025
4e9669d
Prevent all interactions with a shape when in transform mode
nick-skriabin Nov 13, 2025
0f6ad0b
Merge branch 'develop' into 'fb-bros-615'
niklub Nov 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,535 changes: 2,162 additions & 373 deletions web/libs/editor/src/components/KonvaVector/KonvaVector.tsx

Large diffs are not rendered by default.

408 changes: 251 additions & 157 deletions web/libs/editor/src/components/KonvaVector/components/GhostLine.tsx

Large diffs are not rendered by default.

104 changes: 60 additions & 44 deletions web/libs/editor/src/components/KonvaVector/components/GhostPoint.tsx
Original file line number Diff line number Diff line change
@@ -1,67 +1,83 @@
import type React from "react";
import { Circle } from "react-konva";
import { useRef, useEffect, useImperativeHandle, forwardRef } from "react";
import type { GhostPoint as GhostPointType } from "../types";

interface GhostPointProps {
ghostPoint: GhostPointType | null;
transform: { zoom: number; offsetX: number; offsetY: number };
fitScale: number;
isShiftKeyHeld: boolean;
isShiftKeyHeld?: boolean; // Made optional - if ghostPoint is set, Shift was held
maxPoints?: number;
initialPointsLength: number;
isDragging?: boolean;
}

export const GhostPoint: React.FC<GhostPointProps> = ({
ghostPoint,
transform,
fitScale,
isShiftKeyHeld,
maxPoints,
initialPointsLength,
isDragging = false,
}) => {
// Only show the visual ghost point when Shift is held, but don't clear the ghostPoint state
if (!ghostPoint) return null;
export interface GhostPointRef {
updatePosition: (x: number, y: number) => void;
}

// Only render the visual element when Shift is held
if (!isShiftKeyHeld) return null;
export const GhostPoint = forwardRef<GhostPointRef, GhostPointProps>(
({ ghostPoint, transform, fitScale, isShiftKeyHeld, maxPoints, initialPointsLength, isDragging = false }, ref) => {
if (!ghostPoint) {
return null;
}

// Hide ghost point when max points reached
if (maxPoints !== undefined && initialPointsLength >= maxPoints) return null;
// Hide ghost point when maxPoints is reached
if (maxPoints !== undefined && initialPointsLength >= maxPoints) {
return null;
}

// Hide ghost point when dragging
if (isDragging) return null;
// Scale radius to compensate for Layer scaling
const scale = transform.zoom * fitScale;
const radius = 6 / scale;

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

return (
<>
{/* Outer ring */}
<Circle
x={ghostPoint.x}
y={ghostPoint.y}
radius={outerRadius}
fill="rgba(34, 197, 94, 0.2)"
stroke="#22c55e"
strokeWidth={1.5}
strokeScaleEnabled={false}
listening={false}
/>
{/* White center */}
// Expose updatePosition method via ref
useImperativeHandle(ref, () => ({
updatePosition: (x: number, y: number) => {
if (circleRef.current) {
circleRef.current.setPosition({ x, y });
// Force Konva to redraw
const stage = circleRef.current.getStage();
if (stage) {
stage.batchDraw();
}
}
},
}));

// Update position whenever ghostPoint changes
useEffect(() => {
if (circleRef.current && ghostPoint) {
circleRef.current.setPosition({ x: ghostPoint.x, y: ghostPoint.y });
// Force Konva to redraw
const stage = circleRef.current.getStage();
if (stage) {
stage.batchDraw();
}
}
}, [ghostPoint?.x, ghostPoint?.y]);

// Use a key that includes position to force re-render when position changes
// Round position to avoid key changes from floating point precision
const keyX = Math.round(ghostPoint.x * 100) / 100;
const keyY = Math.round(ghostPoint.y * 100) / 100;

return (
<Circle
ref={circleRef}
key={`ghost-point-${keyX}-${keyY}-${ghostPoint.prevPointId}-${ghostPoint.nextPointId}`}
x={ghostPoint.x}
y={ghostPoint.y}
radius={innerRadius}
fill="#ffffff"
stroke="#22c55e"
strokeWidth={0.5}
radius={radius}
fill="#87CEEB"
stroke="white"
strokeWidth={2}
strokeScaleEnabled={false}
listening={false}
/>
</>
);
};
);
},
);
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { Rect } from "react-konva";
import { Circle } from "react-konva";
import type Konva from "konva";
import type { BezierPoint } from "../types";

interface ProxyNodesProps {
selectedPoints: Set<number>;
initialPoints: BezierPoint[];
proxyRefs: React.MutableRefObject<{ [key: number]: Konva.Rect | null }>;
proxyRefs: React.MutableRefObject<{ [key: number]: Konva.Circle | null }>;
}

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

return (
<Rect
<Circle
key={`proxy-${pointIndex}`}
ref={(node) => {
proxyRefs.current[pointIndex] = node;
}}
x={point.x}
y={point.y}
width={1}
height={1}
radius={10}
fill="transparent"
stroke="transparent"
strokeWidth={1}
listening={true}
name={`proxy-${pointIndex}`}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type React from "react";
import { Circle } from "react-konva";
import type Konva from "konva";
import type { BezierPoint } from "../types";
import { HIT_RADIUS } from "../constants";

interface VectorPointsProps {
initialPoints: BezierPoint[];
Expand All @@ -11,6 +12,7 @@ interface VectorPointsProps {
fitScale: number;
pointRefs: React.MutableRefObject<{ [key: number]: Konva.Circle | null }>;
disabled?: boolean;
transformMode?: boolean;
pointRadius?: {
enabled?: number;
disabled?: number;
Expand All @@ -19,6 +21,8 @@ interface VectorPointsProps {
pointStroke?: string;
pointStrokeSelected?: string;
pointStrokeWidth?: number;
activePointId?: string | null;
maxPoints?: number;
onPointClick?: (e: Konva.KonvaEventObject<MouseEvent>, pointIndex: number) => void;
}

Expand All @@ -30,13 +34,22 @@ export const VectorPoints: React.FC<VectorPointsProps> = ({
fitScale,
pointRefs,
disabled = false,
transformMode = false,
pointRadius,
pointFill = "#ffffff",
pointStroke = "#3b82f6",
pointStrokeSelected = "#fbbf24",
pointStrokeSelected = "#ffffff",
pointStrokeWidth = 2,
activePointId = null,
maxPoints,
onPointClick,
}) => {
// CRITICAL: For single-point regions, we need to allow clicks even when disabled
// Single-point regions have no segments to click on, so clicking the point must trigger region selection
// BUT: Never allow clicks when in transform mode
const isSinglePointRegion = initialPoints.length === 1;
const shouldListenToClicks = !transformMode && (!disabled || isSinglePointRegion);

return (
<>
{initialPoints.map((point, index) => {
Expand All @@ -46,26 +59,91 @@ export const VectorPoints: React.FC<VectorPointsProps> = ({
const enabledRadius = pointRadius?.enabled ?? 6;
const disabledRadius = pointRadius?.disabled ?? 4;
const baseRadius = disabled ? disabledRadius : enabledRadius;
const scaledRadius = baseRadius / scale;
const isSelected = selectedPointIndex === index || selectedPoints.has(index);
// Check if maxPoints is reached
const isMaxPointsReached = maxPoints !== undefined && initialPoints.length >= maxPoints;
// Check if multiple points are selected
const isMultiSelection = selectedPoints.size > 1;
// Point is explicitly selected if it's in selectedPoints or is the selectedPointIndex
const isExplicitlySelected = selectedPointIndex === index || selectedPoints.has(index);
// Active point should only be rendered as selected if:
// - It's explicitly selected, OR
// - (Not disabled AND maxPoints not reached AND not in multi-selection AND it's the active point)
const isSelected =
isExplicitlySelected ||
(!disabled &&
!isMaxPointsReached &&
!isMultiSelection &&
activePointId !== null &&
point.id === activePointId);
// Make selected points larger
const radiusMultiplier = isSelected ? 1.3 : 1;
const scaledRadius = (baseRadius * radiusMultiplier) / scale;

return (
<Circle
key={`point-${index}-${point.x}-${point.y}`}
ref={(node) => {
pointRefs.current[index] = node;
}}
x={point.x}
y={point.y}
radius={scaledRadius}
fill={pointFill}
stroke={isSelected ? pointStrokeSelected : pointStroke}
strokeScaleEnabled={false}
strokeWidth={pointStrokeWidth}
listening={true}
name={`point-${index}`}
onClick={onPointClick ? (e) => onPointClick(e, index) : undefined}
/>
<>
{/* White outline ring for selected points - rendered outside the colored stroke */}
{!disabled && isSelected && (
<Circle
key={`point-outline-${index}-${point.x}-${point.y}`}
x={point.x}
y={point.y}
radius={scaledRadius}
fill="transparent"
stroke={pointStrokeSelected}
strokeScaleEnabled={false}
strokeWidth={pointStrokeWidth + 5}
listening={false}
name={`point-outline-${index}`}
/>
)}
{/* Main point circle with colored stroke */}
<Circle
key={`point-${index}-${point.x}-${point.y}`}
ref={(node) => {
pointRefs.current[index] = node;
}}
x={point.x}
y={point.y}
radius={scaledRadius}
fill={pointFill}
stroke={pointStroke}
strokeScaleEnabled={false}
strokeWidth={pointStrokeWidth}
listening={shouldListenToClicks}
name={`point-${index}`}
// Use custom hit function to create a larger clickable area around the point
// This makes points easier to click even when the cursor is not exactly over the point
hitFunc={(context, shape) => {
// Calculate a larger hit radius using the constant (scaled for current zoom)
const hitRadius = HIT_RADIUS.SELECTION / scale;
context.beginPath();
context.arc(0, 0, hitRadius, 0, Math.PI * 2);
context.fillStrokeShape(shape);
}}
onClick={
onPointClick
? (e) => {
// For single-point regions, call onPointClick but don't stop propagation
// The onPointClick handler in KonvaVector will directly call handleClickWithDebouncing
// to trigger region selection
if (isSinglePointRegion && !e.evt.altKey && !e.evt.shiftKey && !e.evt.ctrlKey && !e.evt.metaKey) {
// Don't stop propagation - let onPointClick handle it and call onClick directly
onPointClick(e, index);
return;
}

// Stop propagation immediately to prevent the event from bubbling to VectorShape onClick
// This prevents the shape from being selected/unselected when clicking on points
e.evt.stopImmediatePropagation();
e.evt.stopPropagation();
e.evt.preventDefault();
e.cancelBubble = true;
onPointClick(e, index);
}
: undefined
}
/>
</>
);
})}
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ interface VectorShapeProps {
onClick?: (e: KonvaEventObject<MouseEvent>) => void;
onMouseEnter?: (e: any) => void;
onMouseLeave?: (e: any) => void;
onMouseDown?: (e: KonvaEventObject<MouseEvent>) => void;
onMouseMove?: (e: KonvaEventObject<MouseEvent>) => void;
onMouseUp?: (e: KonvaEventObject<MouseEvent>) => void;
}

// Convert Bezier segments to SVG path data for a single continuous path
Expand Down Expand Up @@ -214,6 +217,9 @@ export const VectorShape: React.FC<VectorShapeProps> = ({
onClick,
onMouseEnter,
onMouseLeave,
onMouseDown,
onMouseMove,
onMouseUp,
}) => {
if (segments.length === 0) return null;

Expand Down Expand Up @@ -272,6 +278,9 @@ export const VectorShape: React.FC<VectorShapeProps> = ({
onClick={onClick}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
/>
);
})}
Expand Down Expand Up @@ -301,6 +310,9 @@ export const VectorShape: React.FC<VectorShapeProps> = ({
onClick={onClick}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
/>
);
})}
Expand Down
Loading
Loading