diff --git a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx index 1aab09658e1e..ea63bf287528 100644 --- a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx +++ b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx @@ -5,6 +5,7 @@ import { ControlPoints, GhostLine, GhostPoint, + type GhostPointRef, VectorPoints, VectorShape, VectorTransformer, @@ -13,11 +14,19 @@ import { import { createEventHandlers } from "./eventHandlers"; import { convertPoint } from "./pointManagement"; import { normalizePoints, convertBezierToSimplePoints, isPointInPolygon } from "./utils"; -import { findClosestPointOnPath, getDistance } from "./eventHandlers/utils"; +import { findClosestPointOnPath, getDistance, snapToPixel } from "./eventHandlers/utils"; +import { constrainAnchorPointsToBounds, constrainPointToBounds } from "./utils/boundsChecking"; +import { stageToImageCoordinates } from "./eventHandlers/utils"; import { PointCreationManager } from "./pointCreationManager"; import { VectorSelectionTracker, type VectorInstance } from "./VectorSelectionTracker"; import { calculateShapeBoundingBox } from "./utils/bezierBoundingBox"; -import { shouldClosePathOnPointClick, isActivePointEligibleForClosing } from "./eventHandlers/pointSelection"; +import { + shouldClosePathOnPointClick, + isActivePointEligibleForClosing, + handlePointDeselection, + handlePointSelection, +} from "./eventHandlers/pointSelection"; +import { handlePointSelectionFromIndex } from "./eventHandlers/mouseHandlers"; import { handleShiftClickPointConversion } from "./eventHandlers/drawing"; import { deletePoint } from "./pointManagement"; import type { BezierPoint, GhostPoint as GhostPointType, KonvaVectorProps, KonvaVectorRef } from "./types"; @@ -221,6 +230,7 @@ export const KonvaVector = forwardRef((props, onTransformationComplete, onPointSelected, onFinish, + onGhostPointClick, scaleX, scaleY, x, @@ -235,6 +245,7 @@ export const KonvaVector = forwardRef((props, onMouseUp, onClick, onDblClick, + onTransformStart, onTransformEnd, onMouseEnter, onMouseLeave, @@ -251,6 +262,8 @@ export const KonvaVector = forwardRef((props, disabled = false, transformMode = false, isMultiRegionSelected = false, + disableInternalPointAddition = false, + disableGhostLine = false, pointRadius, pointFill = DEFAULT_POINT_FILL, pointStroke = DEFAULT_POINT_STROKE, @@ -261,10 +274,83 @@ export const KonvaVector = forwardRef((props, // Normalize input points to BezierPoint format const [initialPoints, setInitialPoints] = useState(() => normalizePoints(rawInitialPoints)); - // Update initialPoints when rawInitialPoints changes + // Ref to track current points for immediate access during transformation + // This ensures applyTransformationToPoints always uses the latest points + const currentPointsRef = useRef(initialPoints); + + // Update ref whenever initialPoints state changes + useEffect(() => { + currentPointsRef.current = initialPoints; + }, [initialPoints]); + + // Ref to track if we're updating points internally to prevent infinite loops + const isInternalUpdateRef = useRef(false); + // Ref to track the last normalized points to prevent unnecessary updates + const lastNormalizedPointsRef = useRef(initialPoints); + // Ref to track the last points we sent to parent to detect circular updates + const lastSentToParentRef = useRef(null); + + // Helper function to compare points by their actual data (ignoring IDs) + const arePointsEqual = useCallback((points1: BezierPoint[], points2: BezierPoint[]): boolean => { + if (points1.length !== points2.length) return false; + + for (let i = 0; i < points1.length; i++) { + const p1 = points1[i]; + const p2 = points2[i]; + + if ( + p1.x !== p2.x || + p1.y !== p2.y || + p1.isBezier !== p2.isBezier || + p1.disconnected !== p2.disconnected || + p1.isBranching !== p2.isBranching + ) { + return false; + } + + // Compare control points if bezier + if (p1.isBezier) { + if (!!p1.controlPoint1 !== !!p2.controlPoint1) return false; + if (!!p1.controlPoint2 !== !!p2.controlPoint2) return false; + + if (p1.controlPoint1 && p2.controlPoint1) { + if (p1.controlPoint1.x !== p2.controlPoint1.x || p1.controlPoint1.y !== p2.controlPoint1.y) { + return false; + } + } + + if (p1.controlPoint2 && p2.controlPoint2) { + if (p1.controlPoint2.x !== p2.controlPoint2.x || p1.controlPoint2.y !== p2.controlPoint2.y) { + return false; + } + } + } + } + + return true; + }, []); + + // Update initialPoints when rawInitialPoints changes (only if different) useEffect(() => { - setInitialPoints(normalizePoints(rawInitialPoints)); - }, [rawInitialPoints]); + // Skip if this is an internal update + if (isInternalUpdateRef.current) { + return; + } + + const normalized = normalizePoints(rawInitialPoints); + + // Skip if this matches what we just sent to parent (circular update prevention) + if (lastSentToParentRef.current && arePointsEqual(lastSentToParentRef.current, normalized)) { + lastSentToParentRef.current = null; // Clear after handling + return; + } + + // Only update if points actually changed (compare by data, not IDs) + if (!arePointsEqual(lastNormalizedPointsRef.current, normalized)) { + lastNormalizedPointsRef.current = normalized; + setInitialPoints(normalized); + } + }, [rawInitialPoints, arePointsEqual]); // Initialize lastAddedPointId and activePointId when component loads with existing points useEffect(() => { @@ -294,7 +380,7 @@ export const KonvaVector = forwardRef((props, const transformerRef = useRef(null); const stageRef = useRef(null); const pointRefs = useRef<{ [key: number]: Konva.Circle | null }>({}); - const proxyRefs = useRef<{ [key: number]: Konva.Rect | null }>({}); + const proxyRefs = useRef<{ [key: number]: Konva.Circle | null }>({}); // Store transformer state to preserve rotation, scale, and center when updating selection const transformerStateRef = useRef<{ rotation: number; @@ -307,16 +393,26 @@ export const KonvaVector = forwardRef((props, // Handle Shift key state useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Shift") { + if (e.shiftKey) { setIsShiftKeyHeld(true); setIsDisconnectedMode(true); + // Recalculate ghost point when Shift is pressed + // Use a small delay to ensure state is updated + setTimeout(() => { + if (calculateGhostPointRef.current) { + // Pass true for shiftKeyState since Shift is being pressed + calculateGhostPointRef.current(true); + } + }, 0); } }; const handleKeyUp = (e: KeyboardEvent) => { - if (e.key === "Shift") { + if (!e.shiftKey) { setIsShiftKeyHeld(false); setIsDisconnectedMode(false); + // Clear ghost point when Shift is released + setGhostPoint(null); } }; @@ -335,8 +431,27 @@ export const KonvaVector = forwardRef((props, pointIndex: number; controlIndex: number; } | null>(null); + const [isDraggingShape, setIsDraggingShape] = useState(false); + const shapeDragStartPos = useRef<{ x: number; y: number; imageX: number; imageY: number } | null>(null); + const originalPointsPositions = useRef< + Array<{ x: number; y: number; controlPoint1?: { x: number; y: number }; controlPoint2?: { x: number; y: number } }> + >([]); + const justFinishedShapeDrag = useRef(false); + const shapeDragDistance = useRef(0); const [isDisconnectedMode, setIsDisconnectedMode] = useState(false); const [ghostPoint, setGhostPoint] = useState(null); + + // Clear ghost point when conditions change that should hide it + useEffect(() => { + // Clear ghost point when: + // - Shape is disabled + // - Max points reached + // Note: Shift key release is handled in handleKeyUp, not here + if (disabled || (maxPoints !== undefined && initialPoints.length >= maxPoints)) { + setGhostPoint(null); + } + }, [disabled, maxPoints, initialPoints.length]); + const [_newPointDragIndex, setNewPointDragIndex] = useState(null); const [isDraggingNewBezier, setIsDraggingNewBezier] = useState(false); const [ghostPointDragInfo, setGhostPointDragInfo] = useState<{ @@ -345,10 +460,11 @@ export const KonvaVector = forwardRef((props, dragDistance: number; } | null>(null); - const [cursorPosition, setCursorPosition] = useState<{ + const cursorPositionRef = useRef<{ x: number; y: number; } | null>(null); + const ghostLineRafRef = useRef(null); const lastCallbackTime = useRef(DEFAULT_CALLBACK_TIME); const [visibleControlPoints, setVisibleControlPoints] = useState>(new Set()); const [activePointId, setActivePointId] = useState(null); @@ -366,6 +482,28 @@ export const KonvaVector = forwardRef((props, // Flag to track if we've handled a double-click through debouncing const doubleClickHandledRef = useRef(false); + // Track if we're currently transforming to avoid duplicate onTransformStart calls + const isTransformingRef = useRef(false); + + // Helper function to call onTransformStart (only if not already transforming) + const handleTransformStart = useCallback(() => { + if (!isTransformingRef.current && onTransformStart) { + isTransformingRef.current = true; + onTransformStart(); + } + }, [onTransformStart]); + + // Helper function to call onTransformEnd (only if currently transforming) + const handleTransformEnd = useCallback( + (e?: Konva.KonvaEventObject) => { + if (isTransformingRef.current && onTransformEnd) { + isTransformingRef.current = false; + onTransformEnd(e); + } + }, + [onTransformEnd], + ); + // Track initial transform state for delta calculation const initialTransformRef = useRef<{ x: number; @@ -375,6 +513,163 @@ export const KonvaVector = forwardRef((props, rotation: number; } | null>(null); + // Define commitMultiRegionTransform as a useCallback so we can use it in both useImperativeHandle and onDragEnd + const commitMultiRegionTransform = useCallback(() => { + if (!isMultiRegionSelected || !transformableGroupRef.current || !initialTransformRef.current) { + return; + } + + // Get the _transformable group + const transformableGroup = transformableGroupRef.current; + + // Get the group's current transform values + const currentX = transformableGroup.x(); + const currentY = transformableGroup.y(); + const currentScaleX = transformableGroup.scaleX(); + const currentScaleY = transformableGroup.scaleY(); + const currentRotation = transformableGroup.rotation(); + + // Calculate deltas from initial state + const initial = initialTransformRef.current; + const dx = currentX - initial.x; + const dy = currentY - initial.y; + const scaleX = currentScaleX / initial.scaleX; + const scaleY = currentScaleY / initial.scaleY; + const rotation = currentRotation - initial.rotation; + + // Apply constraints to the transform before committing + const imageWidth = width || 0; + const imageHeight = height || 0; + + let constrainedDx = dx; + let constrainedDy = dy; + const constrainedScaleX = scaleX; + const constrainedScaleY = scaleY; + + if (imageWidth > 0 && imageHeight > 0) { + // Calculate bounding box of current points after transform + const xs = initialPoints.map((p) => p.x); + const ys = initialPoints.map((p) => p.y); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + + // Apply scale and position to get new bounds + const scaledMinX = minX * scaleX + dx; + const scaledMaxX = maxX * scaleX + dx; + const scaledMinY = minY * scaleY + dy; + const scaledMaxY = maxY * scaleY + dy; + + // Apply constraints + if (scaledMinX < 0) constrainedDx = dx - scaledMinX; + if (scaledMaxX > imageWidth) constrainedDx = dx - (scaledMaxX - imageWidth); + if (scaledMinY < 0) constrainedDy = dy - scaledMinY; + if (scaledMaxY > imageHeight) constrainedDy = dy - (scaledMaxY - imageHeight); + } + + // Apply the transformation exactly as the single-region onTransformEnd handler does: + // 1. Scale around origin (0,0) + // 2. Rotate around origin (0,0) + // 3. Translate by (constrainedDx, constrainedDy) + const radians = rotation * (Math.PI / 180); + const cos = Math.cos(radians); + const sin = Math.sin(radians); + + const transformedVertices = initialPoints.map((point) => { + // Step 1: Scale + const x = point.x * constrainedScaleX; + const y = point.y * constrainedScaleY; + + // Step 2: Rotate + const rx = x * cos - y * sin; + const ry = x * sin + y * cos; + + // Step 3: Translate and clamp to image bounds + const result = { + ...point, + x: Math.max(0, Math.min(imageWidth, rx + constrainedDx)), + y: Math.max(0, Math.min(imageHeight, ry + constrainedDy)), + }; + + // Transform control points if bezier + if (point.isBezier) { + if (point.controlPoint1) { + const cp1x = point.controlPoint1.x * constrainedScaleX; + const cp1y = point.controlPoint1.y * constrainedScaleY; + const cp1rx = cp1x * cos - cp1y * sin; + const cp1ry = cp1x * sin + cp1y * cos; + result.controlPoint1 = { + x: Math.max(0, Math.min(imageWidth, cp1rx + constrainedDx)), + y: Math.max(0, Math.min(imageHeight, cp1ry + constrainedDy)), + }; + } + if (point.controlPoint2) { + const cp2x = point.controlPoint2.x * constrainedScaleX; + const cp2y = point.controlPoint2.y * constrainedScaleY; + const cp2rx = cp2x * cos - cp2y * sin; + const cp2ry = cp2x * sin + cp2y * cos; + result.controlPoint2 = { + x: Math.max(0, Math.min(imageWidth, cp2rx + constrainedDx)), + y: Math.max(0, Math.min(imageHeight, cp2ry + constrainedDy)), + }; + } + } + + return result; + }); + + // Update the points + onPointsChange?.(transformedVertices); + + // Reset the _transformable group transform to identity + // This ensures the visual representation matches the committed data + transformableGroup.x(0); + transformableGroup.y(0); + transformableGroup.scaleX(1); + transformableGroup.scaleY(1); + transformableGroup.rotation(0); + + // Update the initial transform state to reflect the reset + initialTransformRef.current = { + x: 0, + y: 0, + scaleX: 1, + scaleY: 1, + rotation: 0, + }; + + // Detach and reattach the transformer to prevent resizing issues + // BUT: Delay this when multiple regions are selected to allow other regions' + // onTransformEnd handlers to fire first (e.g., PolygonRegion's Line onTransformEnd) + const stage = transformableGroup.getStage(); + if (stage) { + const transformer = stage.findOne("Transformer"); + if (transformer) { + // Check if there are multiple nodes attached (multiple regions selected) + const nodes = transformer.nodes(); + const hasMultipleRegions = nodes.length > 1; + + // If multiple regions, delay the detach/reattach to allow other onTransformEnd handlers to fire + const delay = hasMultipleRegions ? 50 : 0; + + setTimeout(() => { + // Temporarily detach the transformer + transformer.nodes([]); + + // Force a redraw + stage.batchDraw(); + + // Reattach the transformer after a brief delay + setTimeout(() => { + transformer.nodes(nodes); + stage.batchDraw(); + }, 0); + }, delay); + } + } + }, [isMultiRegionSelected, initialPoints, width, height, onPointsChange]); + // Capture initial transform state when group is created useEffect(() => { if (isMultiRegionSelected && transformableGroupRef.current && !initialTransformRef.current) { @@ -386,7 +681,6 @@ export const KonvaVector = forwardRef((props, scaleY: group.scaleY(), rotation: group.rotation(), }; - console.log("📊 Captured initial transform state:", initialTransformRef.current); } else if (!isMultiRegionSelected) { // Reset when not in multi-region mode initialTransformRef.current = null; @@ -436,6 +730,65 @@ export const KonvaVector = forwardRef((props, ); const isDragging = useRef(false); + const [stageReadyRetry, setStageReadyRetry] = useState(0); + const calculateGhostPointRef = useRef<(() => void) | null>(null); + const ghostPointRef = useRef(null); + + // Ref to prevent effect from running multiple times + const handlersAttachedRef = useRef(false); + + // Refs to access current values in event handlers without recreating them + const currentValuesRef = useRef({ + initialPoints, + effectiveSelectedPoints, + allowClose, + finalIsPathClosed, + pixelSnapping, + isDraggingNewBezier, + ghostPointDragInfo, + draggedPointIndex, + draggedControlPoint, + isDraggingShape, + instanceId, + transform, + fitScale, + x, + y, + width, + height, + skeletonEnabled, + activePointId, + lastAddedPointId, + disabled, + onFinish, + }); + + // Update refs on every render + currentValuesRef.current = { + initialPoints, + effectiveSelectedPoints, + allowClose, + finalIsPathClosed, + pixelSnapping, + isDraggingNewBezier, + ghostPointDragInfo, + draggedPointIndex, + draggedControlPoint, + isDraggingShape, + instanceId, + transform, + fitScale, + x, + y, + width, + height, + skeletonEnabled, + activePointId, + lastAddedPointId, + disabled, + onFinish, + isShiftKeyHeld, + }; // Determine if drawing should be disabled based on current interaction context const isDrawingDisabled = () => { @@ -452,7 +805,7 @@ export const KonvaVector = forwardRef((props, } // Dynamically check control point hover - if (cursorPosition && initialPoints.length > 0) { + if (cursorPositionRef.current && initialPoints.length > 0) { const scale = transform.zoom * fitScale; const controlPointHitRadius = HIT_RADIUS.CONTROL_POINT / scale; @@ -462,7 +815,8 @@ export const KonvaVector = forwardRef((props, // Check control point 1 if (point.controlPoint1) { const distance = Math.sqrt( - (cursorPosition.x - point.controlPoint1.x) ** 2 + (cursorPosition.y - point.controlPoint1.y) ** 2, + (cursorPositionRef.current.x - point.controlPoint1.x) ** 2 + + (cursorPositionRef.current.y - point.controlPoint1.y) ** 2, ); if (distance <= controlPointHitRadius) { return true; // Disable drawing when hovering over control points @@ -471,7 +825,8 @@ export const KonvaVector = forwardRef((props, // Check control point 2 if (point.controlPoint2) { const distance = Math.sqrt( - (cursorPosition.x - point.controlPoint2.x) ** 2 + (cursorPosition.y - point.controlPoint2.y) ** 2, + (cursorPositionRef.current.x - point.controlPoint2.x) ** 2 + + (cursorPositionRef.current.y - point.controlPoint2.y) ** 2, ); if (distance <= controlPointHitRadius) { return true; // Disable drawing when hovering over control points @@ -482,13 +837,15 @@ export const KonvaVector = forwardRef((props, } // Dynamically check point hover - if (cursorPosition && initialPoints.length > 0) { + if (cursorPositionRef.current && initialPoints.length > 0) { const scale = transform.zoom * fitScale; const selectionHitRadius = HIT_RADIUS.SELECTION / scale; for (let i = 0; i < initialPoints.length; i++) { const point = initialPoints[i]; - const distance = Math.sqrt((cursorPosition.x - point.x) ** 2 + (cursorPosition.y - point.y) ** 2); + const distance = Math.sqrt( + (cursorPositionRef.current.x - point.x) ** 2 + (cursorPositionRef.current.y - point.y) ** 2, + ); if (distance <= selectionHitRadius) { // If exactly one point is selected and this is that point, allow drawing if (selectedPoints.size === SELECTION_SIZE.MULTI_SELECTION_MIN && selectedPoints.has(i)) { @@ -513,14 +870,19 @@ export const KonvaVector = forwardRef((props, } // Dynamically check segment hover (to hide ghost line when hovering over path segments) - if (cursorPosition && initialPoints.length >= 2) { + if (cursorPositionRef.current && initialPoints.length >= 2) { const scale = transform.zoom * fitScale; const segmentHitRadius = HIT_RADIUS.SEGMENT / scale; // Slightly larger than point hit radius // Use the same logic as findClosestPointOnPath for consistent Bezier curve detection - const closestPathPoint = findClosestPointOnPath(cursorPosition, initialPoints, allowClose, finalIsPathClosed); + const closestPathPoint = findClosestPointOnPath( + cursorPositionRef.current, + initialPoints, + allowClose, + finalIsPathClosed, + ); - if (closestPathPoint && getDistance(cursorPosition, closestPathPoint.point) <= segmentHitRadius) { + if (closestPathPoint && getDistance(cursorPositionRef.current, closestPathPoint.point) <= segmentHitRadius) { return true; // Disable drawing when hovering over segments } } @@ -544,15 +906,142 @@ export const KonvaVector = forwardRef((props, } }, [drawingDisabled]); + // Initialize cursor position when points are available or component becomes active + // This ensures ghost line can render immediately + useEffect(() => { + const group = stageRef.current; + if (!group) return; + + const stage = group.getStage(); + if (!stage) return; + + // Try to get current mouse position and set cursor position + const initializeCursorPosition = () => { + const pos = stage.getPointerPosition(); + if (pos) { + const imagePos = stageToImageCoordinates(pos, transform, fitScale, x, y); + // Always update cursor position when points are available (not just when null) + // This ensures ghost line can render immediately after region selection + cursorPositionRef.current = imagePos; + // Trigger a redraw to show ghost line + if (ghostLineRafRef.current) { + cancelAnimationFrame(ghostLineRafRef.current); + } + ghostLineRafRef.current = requestAnimationFrame(() => { + stage.batchDraw(); + }); + return true; + } + return false; + }; + + // Try immediately + const gotPosition = initializeCursorPosition(); + + // Only set fallback position if this instance is active/selected and not disabled + // Check if this instance is the active one using the tracker + const isActiveInstance = tracker.getActiveInstanceId() === instanceId; + const hasSelection = selectedPoints.size > 0 || effectiveSelectedPoints.size > 0; + const isInstanceSelected = tracker.isInstanceSelected(instanceId); + // Show ghost line only if not disabled AND (active OR has selection) + const shouldShowGhostLine = !disabled && (isActiveInstance || hasSelection || isInstanceSelected); + + // If we couldn't get the position and we have points, set a fallback position + // Use the last point or center of the region as a fallback until mouse moves + // Only do this for the active/selected instance that is not disabled + if (!gotPosition && initialPoints.length > 0 && !cursorPositionRef.current && shouldShowGhostLine) { + const lastPoint = initialPoints[initialPoints.length - 1]; + // Set cursor position to a small offset from the last point so ghost line is visible + cursorPositionRef.current = { + x: lastPoint.x + 50, + y: lastPoint.y + 50, + }; + // Trigger a redraw to show ghost line + if (ghostLineRafRef.current) { + cancelAnimationFrame(ghostLineRafRef.current); + } + ghostLineRafRef.current = requestAnimationFrame(() => { + stage.batchDraw(); + }); + } else if (!shouldShowGhostLine && cursorPositionRef.current) { + // Clear cursor position if this instance shouldn't show ghost line + cursorPositionRef.current = null; + } + + // Also try after a short delay to ensure stage is ready + const timeout = setTimeout(() => { + if (!cursorPositionRef.current) { + initializeCursorPosition(); + } + }, 0); + + // Add a one-time mousemove listener to capture cursor position immediately when mouse moves + // This ensures we get the position even if getPointerPosition() returns null initially + const handleOneTimeMouseMove = (e: Konva.KonvaEventObject) => { + const pos = e.target.getStage()?.getPointerPosition(); + if (pos) { + const imagePos = stageToImageCoordinates(pos, transform, fitScale, x, y); + cursorPositionRef.current = imagePos; + // Trigger a redraw to show ghost line + if (ghostLineRafRef.current) { + cancelAnimationFrame(ghostLineRafRef.current); + } + ghostLineRafRef.current = requestAnimationFrame(() => { + stage.batchDraw(); + }); + // Remove listener after first capture + stage.off("mousemove", handleOneTimeMouseMove); + } + }; + + // Add one-time listener + stage.on("mousemove", handleOneTimeMouseMove); + + return () => { + clearTimeout(timeout); + stage.off("mousemove", handleOneTimeMouseMove); + }; + }, [ + initialPoints.length, + transform, + fitScale, + x, + y, + disabled, + instanceId, + selectedPoints.size, + effectiveSelectedPoints.size, + ]); // Re-run when points change, transform changes, or selection changes + // Stabilize functions for tracker registration const getPoints = useCallback(() => initialPoints, [initialPoints]); const updatePoints = useCallback( (points: BezierPoint[]) => { + // Set flag to prevent useEffect from running when we update internally + isInternalUpdateRef.current = true; + // Update the ref to track the last normalized points + lastNormalizedPointsRef.current = points; + // Update current points ref immediately for transformation callbacks + currentPointsRef.current = points; setInitialPoints(points); + // Clear flag immediately - it only needs to prevent the current useEffect run + isInternalUpdateRef.current = false; + // Track what we're sending to parent to detect circular updates + lastSentToParentRef.current = points; onPointsChange?.(points); }, [onPointsChange], ); + + // Function to update current points ref - used by VectorTransformer during transformation + const updateCurrentPointsRef = useCallback((points: BezierPoint[]) => { + currentPointsRef.current = points; + }, []); + + // Function to get current points ref - used by VectorTransformer during transformation + const getCurrentPointsRef = useCallback(() => { + return currentPointsRef.current; + }, []); const setSelectedPointsStable = useCallback((selectedPoints: Set) => { setSelectedPoints(selectedPoints); }, []); @@ -731,6 +1220,8 @@ export const KonvaVector = forwardRef((props, pixelSnapping, width, height, + transform, + fitScale, onPointsChange, onPointAdded, onPointEdited, @@ -743,14 +1234,21 @@ export const KonvaVector = forwardRef((props, setVisibleControlPoints, setNewPointDragIndex, setIsDraggingNewBezier, + ghostPoint, + selectedPoints: effectiveSelectedPoints, + isShiftKeyHeld, + setGhostPoint, }); }, [ pointCreationManager, initialPoints, + effectiveSelectedPoints, allowBezier, pixelSnapping, width, height, + transform, + fitScale, onPointsChange, onPointAdded, onPointEdited, @@ -763,6 +1261,9 @@ export const KonvaVector = forwardRef((props, setVisibleControlPoints, setNewPointDragIndex, setIsDraggingNewBezier, + ghostPoint, + isShiftKeyHeld, + setGhostPoint, ]); // Helper function to get all points for rendering and interactions @@ -1449,180 +1950,136 @@ export const KonvaVector = forwardRef((props, return false; }, // Multi-region transformation method - applies group transform to points - commitMultiRegionTransform: () => { - if (!isMultiRegionSelected || !transformableGroupRef.current || !initialTransformRef.current) { - console.log("🔄 commitMultiRegionTransform: Early return - not multi-region or missing refs"); - return; - } - - console.log("🔄 KonvaVector.commitMultiRegionTransform called"); - - // Get the _transformable group - const transformableGroup = transformableGroupRef.current; - - // Get the group's current transform values - const currentX = transformableGroup.x(); - const currentY = transformableGroup.y(); - const currentScaleX = transformableGroup.scaleX(); - const currentScaleY = transformableGroup.scaleY(); - const currentRotation = transformableGroup.rotation(); - - // Calculate deltas from initial state - const initial = initialTransformRef.current; - const dx = currentX - initial.x; - const dy = currentY - initial.y; - const scaleX = currentScaleX / initial.scaleX; - const scaleY = currentScaleY / initial.scaleY; - const rotation = currentRotation - initial.rotation; - - console.log("📊 Transform deltas:", { - dx, - dy, - scaleX, - scaleY, - rotation, - initial, - current: { x: currentX, y: currentY, scaleX: currentScaleX, scaleY: currentScaleY, rotation: currentRotation }, - }); - - // Apply constraints to the transform before committing - const imageWidth = width || 0; - const imageHeight = height || 0; - - let constrainedDx = dx; - let constrainedDy = dy; - const constrainedScaleX = scaleX; - const constrainedScaleY = scaleY; - - if (imageWidth > 0 && imageHeight > 0) { - // Calculate bounding box of current points after transform - const xs = initialPoints.map((p) => p.x); - const ys = initialPoints.map((p) => p.y); - const minX = Math.min(...xs); - const maxX = Math.max(...xs); - const minY = Math.min(...ys); - const maxY = Math.max(...ys); - - // Apply scale and position to get new bounds - const scaledMinX = minX * scaleX + dx; - const scaledMaxX = maxX * scaleX + dx; - const scaledMinY = minY * scaleY + dy; - const scaledMaxY = maxY * scaleY + dy; - - // Apply constraints - if (scaledMinX < 0) constrainedDx = dx - scaledMinX; - if (scaledMaxX > imageWidth) constrainedDx = dx - (scaledMaxX - imageWidth); - if (scaledMinY < 0) constrainedDy = dy - scaledMinY; - if (scaledMaxY > imageHeight) constrainedDy = dy - (scaledMaxY - imageHeight); - - console.log("🔍 Transform constraints applied:", { - original: { dx, dy, scaleX, scaleY }, - constrained: { dx: constrainedDx, dy: constrainedDy, scaleX: constrainedScaleX, scaleY: constrainedScaleY }, - bounds: `${imageWidth}x${imageHeight}`, - shapeBounds: `(${minX.toFixed(1)}, ${minY.toFixed(1)}) to (${maxX.toFixed(1)}, ${maxY.toFixed(1)})`, - newBounds: `(${scaledMinX.toFixed(1)}, ${scaledMinY.toFixed(1)}) to (${scaledMaxX.toFixed(1)}, ${scaledMaxY.toFixed(1)})`, - }); - } - - // Apply the transformation exactly as the single-region onTransformEnd handler does: - // 1. Scale around origin (0,0) - // 2. Rotate around origin (0,0) - // 3. Translate by (constrainedDx, constrainedDy) - const radians = rotation * (Math.PI / 180); - const cos = Math.cos(radians); - const sin = Math.sin(radians); - - const transformedVertices = initialPoints.map((point) => { - // Step 1: Scale - const x = point.x * constrainedScaleX; - const y = point.y * constrainedScaleY; - - // Step 2: Rotate - const rx = x * cos - y * sin; - const ry = x * sin + y * cos; - - // Step 3: Translate and clamp to image bounds - const result = { - ...point, - x: Math.max(0, Math.min(imageWidth, rx + constrainedDx)), - y: Math.max(0, Math.min(imageHeight, ry + constrainedDy)), - }; + commitMultiRegionTransform, + // Delete multiple points by their IDs + deletePointsByIds: (pointIds: string[]) => { + if (!pointIds || pointIds.length === 0) return; + + // Find indices of points to delete (in reverse order to maintain correct indices) + const indicesToDelete = pointIds + .map((id) => initialPoints.findIndex((p) => p.id === id)) + .filter((idx) => idx >= 0) + .sort((a, b) => b - a); // Sort descending to delete from end to start + + if (indicesToDelete.length === 0) return; + + // Create a set of deleted point IDs for quick lookup + const deletedPointIds = new Set(pointIds); + + // Delete points one by one in reverse order (from highest index to lowest) + // This ensures indices remain valid as we delete + let updatedPoints = [...initialPoints]; + let updatedSelectedPointIndex = selectedPointIndex; + let updatedLastAddedPointId = lastAddedPointId; + + for (const index of indicesToDelete) { + if (index < 0 || index >= updatedPoints.length) continue; + + const deletedPoint = updatedPoints[index]; + const newPoints = [...updatedPoints]; + newPoints.splice(index, 1); + + // Reconnect points after deletion (same logic as deletePoint function) + // For skeleton mode: use deleted point's prevPointId + for (let i = 0; i < newPoints.length; i++) { + const point = newPoints[i]; + if (point.prevPointId === deletedPoint.id) { + let newPrevPointId: string | undefined = deletedPoint.prevPointId; + + // Edge cases: + if (index === 0) { + // If we deleted the first point, this point becomes the new first point + newPrevPointId = undefined; + } else if (!deletedPoint.prevPointId) { + // If deleted point had no prevPointId (was a root point), this point becomes a root + newPrevPointId = undefined; + } else if (deletedPointIds.has(deletedPoint.prevPointId)) { + // If the previous point is also being deleted, we need to find the next valid ancestor + // This handles cascading deletions in skeleton mode + let ancestorId = deletedPoint.prevPointId; + while (ancestorId && deletedPointIds.has(ancestorId)) { + const ancestorIndex = updatedPoints.findIndex((p) => p.id === ancestorId); + if (ancestorIndex >= 0 && ancestorIndex < updatedPoints.length) { + ancestorId = updatedPoints[ancestorIndex].prevPointId; + } else { + ancestorId = undefined; + break; + } + } + newPrevPointId = ancestorId; + } + // Otherwise, use deletedPoint.prevPointId which is correct for both linear and skeleton modes - // Transform control points if bezier - if (point.isBezier) { - if (point.controlPoint1) { - const cp1x = point.controlPoint1.x * constrainedScaleX; - const cp1y = point.controlPoint1.y * constrainedScaleY; - const cp1rx = cp1x * cos - cp1y * sin; - const cp1ry = cp1x * sin + cp1y * cos; - result.controlPoint1 = { - x: Math.max(0, Math.min(imageWidth, cp1rx + constrainedDx)), - y: Math.max(0, Math.min(imageHeight, cp1ry + constrainedDy)), - }; - } - if (point.controlPoint2) { - const cp2x = point.controlPoint2.x * constrainedScaleX; - const cp2y = point.controlPoint2.y * constrainedScaleY; - const cp2rx = cp2x * cos - cp2y * sin; - const cp2ry = cp2x * sin + cp2y * cos; - result.controlPoint2 = { - x: Math.max(0, Math.min(imageWidth, cp2rx + constrainedDx)), - y: Math.max(0, Math.min(imageHeight, cp2ry + constrainedDy)), + newPoints[i] = { + ...point, + prevPointId: newPrevPointId, }; } } - return result; - }); - - // Update the points - onPointsChange?.(transformedVertices); - - console.log( - "📊 Updated points:", - transformedVertices.map((p) => ({ id: p.id, x: p.x, y: p.y })), - ); + // Update selection state + if (updatedSelectedPointIndex === index) { + updatedSelectedPointIndex = null; + onPointSelected?.(null); + } else if (updatedSelectedPointIndex !== null && updatedSelectedPointIndex > index) { + updatedSelectedPointIndex = updatedSelectedPointIndex - 1; + } - // Reset the _transformable group transform to identity - // This ensures the visual representation matches the committed data - transformableGroup.x(0); - transformableGroup.y(0); - transformableGroup.scaleX(1); - transformableGroup.scaleY(1); - transformableGroup.rotation(0); + // Handle active point + if (updatedLastAddedPointId === deletedPoint.id) { + if (newPoints.length > 0) { + const newLastPoint = newPoints[newPoints.length - 1]; + updatedLastAddedPointId = newLastPoint.id; + } else { + updatedLastAddedPointId = null; + } + } - // Update the initial transform state to reflect the reset - initialTransformRef.current = { - x: 0, - y: 0, - scaleX: 1, - scaleY: 1, - rotation: 0, - }; + // Update visible control points + setVisibleControlPoints((prev) => { + const newSet = new Set(prev); + newSet.delete(index); + const adjustedSet = new Set(); + for (const pointIndex of Array.from(newSet)) { + if (pointIndex > index) { + adjustedSet.add(pointIndex - 1); + } else { + adjustedSet.add(pointIndex); + } + } + return adjustedSet; + }); - console.log("📊 Reset _transformable group transform to identity"); + onPointRemoved?.(deletedPoint, index); + updatedPoints = newPoints; + } - // Detach and reattach the transformer to prevent resizing issues - const stage = transformableGroup.getStage(); - if (stage) { - const transformer = stage.findOne("Transformer"); - if (transformer) { - // Temporarily detach the transformer - const nodes = transformer.nodes(); - transformer.nodes([]); + // Clear selection after deleting all selected points + setSelectedPointIndex(updatedSelectedPointIndex); + setLastAddedPointId(updatedLastAddedPointId); + tracker.selectPoints(instanceId, new Set()); + onPointsChange?.(updatedPoints); + }, + })); - // Force a redraw - stage.batchDraw(); + // Ensure commitMultiRegionTransform is called when ImageTransformer's onDragEnd fires + // ImageTransformer will call applyTransform which calls commitMultiRegionTransform + // But we also need to ensure the Group position is preserved when selection is cleared + // by committing the transform before the selection is cleared + useEffect(() => { + if (!isMultiRegionSelected && transformableGroupRef.current) { + // When selection is cleared, commit any pending transform first + const group = transformableGroupRef.current; + const currentX = group.x(); + const currentY = group.y(); - // Reattach the transformer after a brief delay - setTimeout(() => { - transformer.nodes(nodes); - stage.batchDraw(); - }, 0); - } + // If there's a pending transform, commit it before the Group is unmounted/reset + if ((currentX !== 0 || currentY !== 0) && initialTransformRef.current) { + commitMultiRegionTransform(); + handleTransformEnd(); } - }, - })); + } + }, [isMultiRegionSelected, commitMultiRegionTransform, handleTransformEnd]); // Clean up click timeout on unmount useEffect(() => { @@ -1634,43 +2091,1028 @@ export const KonvaVector = forwardRef((props, }; }, []); - // Handle Shift key for disconnected mode + // Set up stage-level event listeners for cursor position, ghost point, and point dragging + // This allows these features to work even when the invisible shape is disabled useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Shift") { - setIsDisconnectedMode(true); - } - }; + // Prevent running if handlers are already attached + // This stops the infinite loop caused by state updates triggering re-renders + if (handlersAttachedRef.current) { + return () => {}; // Return empty cleanup function + } - const handleKeyUp = (e: KeyboardEvent) => { - if (e.key === "Shift") { - setIsDisconnectedMode(false); + const group = stageRef.current; + if (!group) { + // If stageRef is not available yet, try again after a short delay + const retryTimeout = setTimeout(() => { + if (!handlersAttachedRef.current) { + // Force re-run by incrementing retry counter + setStageReadyRetry((prev) => prev + 1); + } + }, 100); + return () => { + clearTimeout(retryTimeout); + }; + } + + const stage = group.getStage(); + if (!stage) { + // If stage is not available yet, try again after a short delay + const retryTimeout = setTimeout(() => { + if (!handlersAttachedRef.current) { + // Force re-run by incrementing retry counter + setStageReadyRetry((prev) => prev + 1); + } + }, 100); + return () => { + clearTimeout(retryTimeout); + }; + } + + handlersAttachedRef.current = true; + + // Helper function to calculate and set ghost point based on current cursor position + const calculateGhostPoint = (shiftKeyState?: boolean, eventPos?: { x: number; y: number }) => { + const group = stageRef.current; + if (!group) return; + const stage = group.getStage(); + if (!stage) return; + + // Use event position if provided, otherwise fall back to stage.getPointerPosition() + const pos = eventPos || stage.getPointerPosition(); + if (!pos) return; + + const { + transform, + fitScale, + x, + y, + width, + height, + initialPoints, + allowClose, + finalIsPathClosed, + pixelSnapping, + isDraggingNewBezier, + ghostPointDragInfo, + disabled, + isShiftKeyHeld: refShiftState, + } = currentValuesRef.current; + + if (disabled || isDragging.current || isDraggingNewBezier || ghostPointDragInfo?.isDragging) { + return; } - }; - window.addEventListener("keydown", handleKeyDown); - window.addEventListener("keyup", handleKeyUp); + const imagePos = stageToImageCoordinates(pos, transform, fitScale, x, y); - return () => { - window.removeEventListener("keydown", handleKeyDown); - window.removeEventListener("keyup", handleKeyUp); - }; - }, []); + // Only process ghost point logic if within bounds + if (imagePos.x >= 0 && imagePos.x <= width && imagePos.y >= 0 && imagePos.y <= height) { + // Use provided shiftKeyState or fall back to ref value + const currentShiftState = shiftKeyState !== undefined ? shiftKeyState : (refShiftState ?? false); - // Click handler with debouncing for single/double-click detection - const handleClickWithDebouncing = useCallback( - (e: any, onClickHandler?: (e: any) => void, onDblClickHandler?: (e: any) => void) => { - console.log("🖱 handleClickWithDebouncing called, timeout exists:", !!clickTimeoutRef.current); + if (initialPoints.length >= 2 && currentShiftState) { + const scale = transform.zoom * fitScale; + const hitRadius = HIT_RADIUS.SELECTION / scale; + let isOverPoint = false; - // Clear any existing timeout - if (clickTimeoutRef.current) { - clearTimeout(clickTimeoutRef.current); - clickTimeoutRef.current = null; - // This is a double-click, handle it - console.log("🖱 Double-click detected, calling onDblClickHandler"); - doubleClickHandledRef.current = true; - if (onDblClickHandler) { - onDblClickHandler(e); + for (let i = 0; i < initialPoints.length; i++) { + const point = initialPoints[i]; + const distance = Math.sqrt((imagePos.x - point.x) ** 2 + (imagePos.y - point.y) ** 2); + + if (distance <= hitRadius) { + isOverPoint = true; + break; + } + } + + if (isOverPoint) { + setGhostPoint(null); + } else { + const closestPathPoint = findClosestPointOnPath(imagePos, initialPoints, allowClose, finalIsPathClosed); + + if (closestPathPoint) { + const snappedGhostPoint = snapToPixel(closestPathPoint.point, pixelSnapping); + + // Always create a new ghost point object to ensure React detects the change + let newGhostPoint: { x: number; y: number; prevPointId: string; nextPointId: string }; + + if (closestPathPoint.segmentIndex === initialPoints.length) { + const lastPoint = initialPoints[initialPoints.length - 1]; + const firstPoint = initialPoints[0]; + + newGhostPoint = { + x: snappedGhostPoint.x, + y: snappedGhostPoint.y, + prevPointId: lastPoint.id, + nextPointId: firstPoint.id, + }; + } else { + const currentPoint = initialPoints[closestPathPoint.segmentIndex]; + const prevPoint = currentPoint?.prevPointId + ? initialPoints.find((p) => p.id === currentPoint.prevPointId) + : null; + + if (currentPoint && prevPoint) { + newGhostPoint = { + x: snappedGhostPoint.x, + y: snappedGhostPoint.y, + prevPointId: prevPoint.id, + nextPointId: currentPoint.id, + }; + } else { + setGhostPoint(null); + return; + } + } + + // Always update the ghost point with a new object reference + // This ensures React detects the change and re-renders + setGhostPoint({ ...newGhostPoint }); + + // Also update directly via ref for immediate visual update + if (ghostPointRef.current) { + ghostPointRef.current.updatePosition(newGhostPoint.x, newGhostPoint.y); + } + } else { + setGhostPoint(null); + } + } + } else { + setGhostPoint(null); + } + } else { + setGhostPoint(null); + } + }; + + // Store the function in a ref so it can be accessed from handleKeyDown + calculateGhostPointRef.current = calculateGhostPoint; + + // Only set up stage-level dragging when disableInternalPointAddition is true + // Otherwise, let the layer handlers handle it + if (!disableInternalPointAddition) { + // Still handle cursor position and ghost point + const handleStageMouseMove = (e: Konva.KonvaEventObject) => { + // Use stage.getPointerPosition() directly for consistent coordinate space + const pos = stage.getPointerPosition(); + if (!pos) return; + + // Get current values from ref to avoid stale closures + const { + transform, + fitScale, + x, + y, + width, + height, + initialPoints, + allowClose, + finalIsPathClosed, + pixelSnapping, + isDraggingNewBezier, + ghostPointDragInfo, + disabled, + } = currentValuesRef.current; + + // Update Shift key state from the event to keep it in sync + if (e.evt.shiftKey !== isShiftKeyHeld) { + setIsShiftKeyHeld(e.evt.shiftKey); + } + + // Debug: Log coordinate conversion + const imagePos = stageToImageCoordinates(pos, transform, fitScale, x, y); + + // Always update cursor position (even outside bounds) so ghost line can work + cursorPositionRef.current = imagePos; + + // Use RAF to batch redraw calls for performance + if (ghostLineRafRef.current) { + cancelAnimationFrame(ghostLineRafRef.current); + } + ghostLineRafRef.current = requestAnimationFrame(() => { + stage.batchDraw(); + }); + + // Recalculate ghost point using the helper function + // Pass the event's shiftKey state and position for real-time updates + calculateGhostPoint(e.evt.shiftKey, pos); + }; + + const handleStageMouseEnter = (e: Konva.KonvaEventObject) => { + // Capture cursor position when mouse enters stage so ghost line can render immediately + const pos = e.target.getStage()?.getPointerPosition(); + if (pos) { + const { transform, fitScale, x, y } = currentValuesRef.current; + const imagePos = stageToImageCoordinates(pos, transform, fitScale, x, y); + cursorPositionRef.current = imagePos; + // Trigger a redraw to show ghost line + if (ghostLineRafRef.current) { + cancelAnimationFrame(ghostLineRafRef.current); + } + ghostLineRafRef.current = requestAnimationFrame(() => { + stage.batchDraw(); + }); + } + }; + + const handleStageMouseLeave = () => { + cursorPositionRef.current = null; + setGhostPoint(null); + }; + + stage.on("mousemove", handleStageMouseMove); + stage.on("mouseenter", handleStageMouseEnter); + stage.on("mouseleave", handleStageMouseLeave); + + // Try to initialize cursor position if mouse is already over the stage + // This ensures ghost line can render immediately even if mouseenter didn't fire + const tryInitializeCursorPosition = () => { + const pos = stage.getPointerPosition(); + if (pos) { + const { transform, fitScale, x, y } = currentValuesRef.current; + const imagePos = stageToImageCoordinates(pos, transform, fitScale, x, y); + cursorPositionRef.current = imagePos; + // Trigger a redraw to show ghost line + if (ghostLineRafRef.current) { + cancelAnimationFrame(ghostLineRafRef.current); + } + ghostLineRafRef.current = requestAnimationFrame(() => { + stage.batchDraw(); + }); + } + }; + + // Try to initialize immediately + tryInitializeCursorPosition(); + + // Also try after a short delay in case the stage isn't ready yet + const initTimeout = setTimeout(() => { + tryInitializeCursorPosition(); + }, 0); + + return () => { + clearTimeout(initTimeout); + if (ghostLineRafRef.current) { + cancelAnimationFrame(ghostLineRafRef.current); + } + stage.off("mousemove", handleStageMouseMove); + stage.off("mouseenter", handleStageMouseEnter); + stage.off("mouseleave", handleStageMouseLeave); + }; + } + + // When disableInternalPointAddition is true, handle point dragging at stage level + const handleStageMouseDown = (e: Konva.KonvaEventObject) => { + // Get current values from ref to avoid stale closures + const { + initialPoints, + effectiveSelectedPoints, + instanceId, + transform, + fitScale, + x, + y, + skeletonEnabled, + activePointId, + lastAddedPointId, + allowClose, + finalIsPathClosed, + disabled, + onFinish, + } = currentValuesRef.current; + + // Check if event target belongs to this instance's group + const target = e.target; + let targetGroup: Konva.Node | null = target; + while (targetGroup && targetGroup !== group) { + targetGroup = targetGroup.getParent(); + } + // If target is not within our group, ignore this event + if (targetGroup !== group) return; + + // Use e.target.getStage() to match how layer handlers get pointer position + const pos = e.target.getStage()?.getPointerPosition(); + if (!pos) return; + + const imagePos = stageToImageCoordinates(pos, transform, fitScale, x, y); + + // Check if clicking on a point (by checking if target is a Circle with name starting with "point-") + const targetName = target.name(); + if (targetName && targetName.startsWith("point-")) { + // Extract point index from name (format: "point-{index}") + const match = targetName.match(/^point-(\d+)$/); + if (match) { + const pointIndex = Number.parseInt(match[1], 10); + if (pointIndex >= 0 && pointIndex < initialPoints.length) { + const point = initialPoints[pointIndex]; + + // If cmd-click, don't handle it here - let the onClick handler on the Circle component handle it + // The onClick handler has the correct pointIndex, while this handler would need to find it by distance + // which could select the wrong point when multiple points are close together + if (e.evt.ctrlKey || e.evt.metaKey) { + // Just prevent event propagation and return - let onClick handle the selection + e.evt.stopPropagation(); + return; + } + + // Normal click - prevent event propagation to avoid region deselection + e.evt.stopPropagation(); + + // Store the potential drag target but don't start dragging yet + // We'll start dragging only if the mouse moves beyond a threshold + setDraggedPointIndex(pointIndex); + lastPos.current = { + x: e.evt.clientX, + y: e.evt.clientY, + originalX: point.x, + originalY: point.y, + originalControlPoint1: point.isBezier ? point.controlPoint1 : undefined, + originalControlPoint2: point.isBezier ? point.controlPoint2 : undefined, + }; + return; + } + } + } + + // Check if clicking on a control point (by checking target name) + if (targetName && targetName.startsWith("control-point-")) { + const match = targetName.match(/^control-point-(\d+)-(\d+)$/); + if (match) { + const pointIndex = Number.parseInt(match[1], 10); + const controlIndex = Number.parseInt(match[2], 10); + if (pointIndex >= 0 && pointIndex < initialPoints.length) { + const point = initialPoints[pointIndex]; + if (point.isBezier && controlIndex >= 1 && controlIndex <= 2) { + const controlPoint = controlIndex === 1 ? point.controlPoint1 : point.controlPoint2; + if (controlPoint) { + setDraggedControlPoint({ pointIndex, controlIndex }); + isDragging.current = true; + handleTransformStart(); + lastPos.current = { + x: e.evt.clientX, + y: e.evt.clientY, + originalX: controlPoint.x, + originalY: controlPoint.y, + }; + return; + } + } + } + } + } + + // Fallback: check by distance (in case names don't match) + const scale = transform.zoom * fitScale; + const hitRadius = HIT_RADIUS.SELECTION / scale; + + for (let i = 0; i < initialPoints.length; i++) { + const point = initialPoints[i]; + const distance = Math.sqrt((imagePos.x - point.x) ** 2 + (imagePos.y - point.y) ** 2); + + if (distance <= hitRadius) { + // If cmd-click, handle selection immediately and don't set up dragging + if (e.evt.ctrlKey || e.evt.metaKey) { + // Prevent event from propagating to avoid region deselection + e.evt.stopPropagation(); + + // Create a mock event object for the selection handlers + const mockEvent = { + ...e, + target: e.target, + evt: e.evt, + } as Konva.KonvaEventObject; + + // Try deselection first + if ( + handlePointDeselection(mockEvent, { + instanceId, + initialPoints, + transform, + fitScale, + x, + y, + selectedPoints: effectiveSelectedPoints, + setSelectedPoints, + skeletonEnabled, + setActivePointId, + setLastAddedPointId, + lastAddedPointId, + activePointId, + } as any) + ) { + return; + } + // If not deselection, try selection (adding to multi-selection) + if ( + handlePointSelection(mockEvent, { + instanceId, + initialPoints, + transform, + fitScale, + x, + y, + selectedPoints: effectiveSelectedPoints, + setSelectedPoints, + setSelectedPointIndex, + skeletonEnabled, + setActivePointId, + setLastAddedPointId, + lastAddedPointId, + activePointId, + allowClose, + isPathClosed: finalIsPathClosed, + disabled, + onFinish, + } as any) + ) { + return; + } + } else { + // Normal click - prevent event propagation to avoid region deselection + e.evt.stopPropagation(); + + // Store the potential drag target but don't start dragging yet + // We'll start dragging only if the mouse moves beyond a threshold + setDraggedPointIndex(i); + lastPos.current = { + x: e.evt.clientX, + y: e.evt.clientY, + originalX: point.x, + originalY: point.y, + originalControlPoint1: point.isBezier ? point.controlPoint1 : undefined, + originalControlPoint2: point.isBezier ? point.controlPoint2 : undefined, + }; + } + return; + } + } + + // Check if clicking on a control point + for (let i = 0; i < initialPoints.length; i++) { + const point = initialPoints[i]; + if (point.isBezier) { + if (point.controlPoint1) { + const distance = Math.sqrt( + (imagePos.x - point.controlPoint1.x) ** 2 + (imagePos.y - point.controlPoint1.y) ** 2, + ); + if (distance <= hitRadius) { + setDraggedControlPoint({ pointIndex: i, controlIndex: 1 }); + isDragging.current = true; + handleTransformStart(); + lastPos.current = { + x: e.evt.clientX, + y: e.evt.clientY, + originalX: point.controlPoint1.x, + originalY: point.controlPoint1.y, + }; + return; + } + } + + if (point.controlPoint2) { + const distance = Math.sqrt( + (imagePos.x - point.controlPoint2.x) ** 2 + (imagePos.y - point.controlPoint2.y) ** 2, + ); + if (distance <= hitRadius) { + setDraggedControlPoint({ pointIndex: i, controlIndex: 2 }); + isDragging.current = true; + handleTransformStart(); + lastPos.current = { + x: e.evt.clientX, + y: e.evt.clientY, + originalX: point.controlPoint2.x, + originalY: point.controlPoint2.y, + }; + return; + } + } + } + } + }; + + const handleStageMouseMove = (e: Konva.KonvaEventObject) => { + // Get current values from ref to avoid stale closures + const { + initialPoints, + allowClose, + finalIsPathClosed, + pixelSnapping, + isDraggingNewBezier, + ghostPointDragInfo, + draggedPointIndex, + draggedControlPoint, + isDraggingShape, + effectiveSelectedPoints, + transform, + fitScale, + x, + y, + width, + height, + } = currentValuesRef.current; + + // Always update cursor position first (for ghost line to work everywhere) + const pos = e.target.getStage()?.getPointerPosition(); + if (!pos) return; + + // Update Shift key state from the event to keep it in sync + if (e.evt.shiftKey !== isShiftKeyHeld) { + setIsShiftKeyHeld(e.evt.shiftKey); + } + + const imagePos = stageToImageCoordinates(pos, transform, fitScale, x, y); + + // Always update cursor position (even outside bounds) so ghost line can work + cursorPositionRef.current = imagePos; + + // Use RAF to batch redraw calls for performance + if (ghostLineRafRef.current) { + cancelAnimationFrame(ghostLineRafRef.current); + } + ghostLineRafRef.current = requestAnimationFrame(() => { + stage.batchDraw(); + }); + + // Recalculate ghost point using the helper function + // Pass the event's shiftKey state and position for real-time updates + if (calculateGhostPointRef.current) { + calculateGhostPointRef.current(e.evt.shiftKey, pos); + } + + // Handle shape dragging first (if active, allow dragging to continue even outside bounds) + // Skip individual shape dragging if in multi-region mode (ImageTransformer handles it) + if (isDraggingShape && shapeDragStartPos.current && !isMultiRegionSelected) { + // Calculate delta from start position + const deltaX = imagePos.x - shapeDragStartPos.current.imageX; + const deltaY = imagePos.y - shapeDragStartPos.current.imageY; + + // Track drag distance + shapeDragDistance.current = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + + // Apply delta to all points + const newPoints = initialPoints.map((point, index) => { + const original = originalPointsPositions.current[index]; + if (!original) return point; + + const newX = original.x + deltaX; + const newY = original.y + deltaY; + + const updatedPoint = { + ...point, + x: newX, + y: newY, + }; + + // Move control points with the anchor point + if (point.isBezier) { + if (original.controlPoint1) { + updatedPoint.controlPoint1 = { + x: original.controlPoint1.x + deltaX, + y: original.controlPoint1.y + deltaY, + }; + } + if (original.controlPoint2) { + updatedPoint.controlPoint2 = { + x: original.controlPoint2.x + deltaX, + y: original.controlPoint2.y + deltaY, + }; + } + } + + return updatedPoint; + }); + + // Apply bounds checking to all points + const constrainedPoints = constrainAnchorPointsToBounds(newPoints, { width, height }); + + onPointsChange?.(constrainedPoints); + return; // Don't process other logic when dragging shape + } + + // If we're dragging a point from this instance, allow dragging to continue + // even if mouse moves outside our group (user might drag outside bounds) + const isDraggingFromThisInstance = draggedPointIndex !== null || draggedControlPoint !== null; + + // Check if event target belongs to this instance's group + // This prevents ghost point snapping from working for other regions + // But we still update cursor position above so ghost line can work + const target = e.target; + let targetGroup: Konva.Node | null = target; + while (targetGroup && targetGroup !== group && targetGroup.getParent()) { + targetGroup = targetGroup.getParent(); + } + const isTargetInGroup = targetGroup === group; + // Also allow when hovering over empty space (stage or layer) + const isStageOrLayer = target === stage || target.getParent() === stage; + + // Only process ghost point snapping and dragging if: + // - Target is in our group, OR + // - We're hovering over empty space (stage/layer), OR + // - We're dragging from this instance + if (!isDraggingFromThisInstance && !isTargetInGroup && !isStageOrLayer) { + // Clear ghost point when hovering over other regions, but keep cursor position for ghost line + setGhostPoint(null); + return; + } + + // Only process ghost point and other logic if within bounds + if (imagePos.x >= 0 && imagePos.x <= width && imagePos.y >= 0 && imagePos.y <= height) { + // Handle ghost point when Shift is held (check event directly for real-time updates) + // Only show ghost point when region is selected (not disabled) + if ( + e.evt.shiftKey && + imagePos && + initialPoints.length >= 2 && + !isDragging.current && + !isDraggingNewBezier && + !ghostPointDragInfo?.isDragging && + !disabled + ) { + const scale = transform.zoom * fitScale; + const hitRadius = HIT_RADIUS.SELECTION / scale; + let isOverPoint = false; + + for (let i = 0; i < initialPoints.length; i++) { + const point = initialPoints[i]; + const distance = Math.sqrt((imagePos.x - point.x) ** 2 + (imagePos.y - point.y) ** 2); + + if (distance <= hitRadius) { + isOverPoint = true; + break; + } + } + + if (isOverPoint) { + setGhostPoint(null); + } else { + const closestPathPoint = findClosestPointOnPath(imagePos, initialPoints, allowClose, finalIsPathClosed); + + if (closestPathPoint) { + const snappedGhostPoint = snapToPixel(closestPathPoint.point, pixelSnapping); + + if (closestPathPoint.segmentIndex === initialPoints.length) { + const lastPoint = initialPoints[initialPoints.length - 1]; + const firstPoint = initialPoints[0]; + + const ghostPointData = { + x: snappedGhostPoint.x, + y: snappedGhostPoint.y, + prevPointId: lastPoint.id, + nextPointId: firstPoint.id, + }; + setGhostPoint(ghostPointData); + } else { + const currentPoint = initialPoints[closestPathPoint.segmentIndex]; + const prevPoint = currentPoint?.prevPointId + ? initialPoints.find((p) => p.id === currentPoint.prevPointId) + : null; + + if (currentPoint && prevPoint) { + const ghostPointData = { + x: snappedGhostPoint.x, + y: snappedGhostPoint.y, + prevPointId: prevPoint.id, + nextPointId: currentPoint.id, + }; + setGhostPoint(ghostPointData); + } + } + } else { + setGhostPoint(null); + } + } + } else if (!e.evt.shiftKey) { + setGhostPoint(null); + } + + // Handle point dragging + if (draggedPointIndex !== null && lastPos.current) { + if (effectiveSelectedPoints.size > 1) { + return; // Don't drag when transformer is active + } + + // Check if we should start dragging + const dragThreshold = 5; + const mouseDeltaX = Math.abs(e.evt.clientX - lastPos.current.x); + const mouseDeltaY = Math.abs(e.evt.clientY - lastPos.current.y); + + if (!isDragging.current && (mouseDeltaX > dragThreshold || mouseDeltaY > dragThreshold)) { + isDragging.current = true; + handleTransformStart(); + } + + if (!isDragging.current) { + return; + } + + const newPoints = [...initialPoints]; + const draggedPoint = newPoints[draggedPointIndex]; + + const originalX = lastPos.current.originalX ?? draggedPoint.x; + const originalY = lastPos.current.originalY ?? draggedPoint.y; + + const snappedPos = snapToPixel(imagePos, pixelSnapping); + const finalPos = constrainAnchorPointsToBounds([snappedPos], { width, height })[0]; + + newPoints[draggedPointIndex] = { + ...draggedPoint, + x: finalPos.x, + y: finalPos.y, + }; + + // Handle bezier control points + if (draggedPoint.isBezier) { + const updatedPoint = newPoints[draggedPointIndex]; + + if (updatedPoint.controlPoint1 && lastPos.current.originalControlPoint1) { + const deltaX = finalPos.x - originalX; + const deltaY = finalPos.y - originalY; + updatedPoint.controlPoint1 = { + x: lastPos.current.originalControlPoint1.x + deltaX, + y: lastPos.current.originalControlPoint1.y + deltaY, + }; + } + + if (updatedPoint.controlPoint2 && lastPos.current.originalControlPoint2) { + const deltaX = finalPos.x - originalX; + const deltaY = finalPos.y - originalY; + updatedPoint.controlPoint2 = { + x: lastPos.current.originalControlPoint2.x + deltaX, + y: lastPos.current.originalControlPoint2.y + deltaY, + }; + } + + const constrainedPoint = constrainAnchorPointsToBounds([updatedPoint], { width, height })[0]; + newPoints[draggedPointIndex] = constrainedPoint; + } + + onPointsChange?.(newPoints); + onPointRepositioned?.(newPoints[draggedPointIndex], draggedPointIndex); + } + + // Handle control point dragging + if (draggedControlPoint && lastPos.current) { + const newPoints = [...initialPoints]; + const point = newPoints[draggedControlPoint.pointIndex]; + + if (point.isBezier) { + const snappedPos = snapToPixel(imagePos, pixelSnapping); + const finalPos = constrainPointToBounds(snappedPos, { width, height }); + + if (draggedControlPoint.controlIndex === 1) { + point.controlPoint1 = finalPos; + } else if (draggedControlPoint.controlIndex === 2) { + point.controlPoint2 = finalPos; + } + + newPoints[draggedControlPoint.pointIndex] = point; + onPointsChange?.(newPoints); + onPointEdited?.(point, draggedControlPoint.pointIndex); + } + } + } else { + // When outside bounds, clear ghost point but keep cursor position for ghost line + setGhostPoint(null); + } + }; + + const handleStageMouseUp = (e: Konva.KonvaEventObject) => { + // Get current values from ref to avoid stale closures + const { + isDraggingShape, + draggedPointIndex, + initialPoints, + effectiveSelectedPoints, + instanceId, + skeletonEnabled, + activePointId, + onFinish, + } = currentValuesRef.current; + + // Handle shape dragging end + if (isDraggingShape) { + const dragThreshold = 5; // Only prevent clicks if we actually dragged + const actuallyDragged = shapeDragDistance.current > dragThreshold; + + setIsDraggingShape(false); + handleTransformEnd(e); + shapeDragStartPos.current = null; + originalPointsPositions.current = []; + + // Only prevent click handler from adding a point if we actually dragged + if (actuallyDragged) { + justFinishedShapeDrag.current = true; + // Prevent event propagation to avoid triggering click handlers + e.evt.stopPropagation(); + e.evt.preventDefault(); + // Don't use setTimeout - let the click handlers clear the flag + } + + shapeDragDistance.current = 0; + return; // Don't process point selection when ending shape drag + } + + // Handle point selection if we clicked but didn't drag + if (draggedPointIndex !== null && !isDragging.current) { + // Use handlePointSelectionFromIndex to properly handle selection through tracker + handlePointSelectionFromIndex( + draggedPointIndex, + { + instanceId, + initialPoints, + selectedPoints: effectiveSelectedPoints, + setSelectedPoints, + skeletonEnabled, + setActivePointId, + activePointId, + onFinish, + } as any, + e, + ); + onPointSelected?.(draggedPointIndex); + } + + // Reset dragging state + isDragging.current = false; + handleTransformEnd(e); + setDraggedPointIndex(null); + setDraggedControlPoint(null); + }; + + const handleStageMouseEnter = (e: Konva.KonvaEventObject) => { + // Capture cursor position when mouse enters stage so ghost line can render immediately + const pos = e.target.getStage()?.getPointerPosition(); + if (pos) { + const { transform, fitScale, x, y } = currentValuesRef.current; + const imagePos = stageToImageCoordinates(pos, transform, fitScale, x, y); + cursorPositionRef.current = imagePos; + // Trigger a redraw to show ghost line + if (ghostLineRafRef.current) { + cancelAnimationFrame(ghostLineRafRef.current); + } + ghostLineRafRef.current = requestAnimationFrame(() => { + stage.batchDraw(); + }); + } + }; + + const handleStageMouseLeave = () => { + cursorPositionRef.current = null; + setGhostPoint(null); + }; + + if (disableInternalPointAddition) { + stage.on("mousedown", handleStageMouseDown); + stage.on("mousemove", handleStageMouseMove); + stage.on("mouseup", handleStageMouseUp); + stage.on("mouseenter", handleStageMouseEnter); + stage.on("mouseleave", handleStageMouseLeave); + + // Try to initialize cursor position if mouse is already over the stage + // This ensures ghost line can render immediately even if mouseenter didn't fire + const tryInitializeCursorPosition = () => { + const pos = stage.getPointerPosition(); + if (pos) { + const { transform, fitScale, x, y } = currentValuesRef.current; + const imagePos = stageToImageCoordinates(pos, transform, fitScale, x, y); + cursorPositionRef.current = imagePos; + // Trigger a redraw to show ghost line + if (ghostLineRafRef.current) { + cancelAnimationFrame(ghostLineRafRef.current); + } + ghostLineRafRef.current = requestAnimationFrame(() => { + stage.batchDraw(); + }); + } + }; + + // Try to initialize immediately + tryInitializeCursorPosition(); + + // Also try after a short delay in case the stage isn't ready yet + const initTimeout = setTimeout(() => { + tryInitializeCursorPosition(); + }, 0); + + return () => { + clearTimeout(initTimeout); + handlersAttachedRef.current = false; + stage.off("mousedown", handleStageMouseDown); + stage.off("mousemove", handleStageMouseMove); + stage.off("mouseup", handleStageMouseUp); + stage.off("mouseenter", handleStageMouseEnter); + stage.off("mouseleave", handleStageMouseLeave); + }; + } + // Handle cursor position, ghost point, and shape dragging + stage.on("mousemove", handleStageMouseMove); + stage.on("mouseup", handleStageMouseUp); + stage.on("mouseenter", handleStageMouseEnter); + stage.on("mouseleave", handleStageMouseLeave); + + // Try to initialize cursor position if mouse is already over the stage + // This ensures ghost line can render immediately even if mouseenter didn't fire + const tryInitializeCursorPosition = () => { + const pos = stage.getPointerPosition(); + if (pos) { + const { transform, fitScale, x, y } = currentValuesRef.current; + const imagePos = stageToImageCoordinates(pos, transform, fitScale, x, y); + cursorPositionRef.current = imagePos; + // Trigger a redraw to show ghost line + if (ghostLineRafRef.current) { + cancelAnimationFrame(ghostLineRafRef.current); + } + ghostLineRafRef.current = requestAnimationFrame(() => { + stage.batchDraw(); + }); + } + }; + + // Try to initialize immediately + tryInitializeCursorPosition(); + + // Also try after a short delay in case the stage isn't ready yet + const initTimeout = setTimeout(() => { + tryInitializeCursorPosition(); + }, 0); + + return () => { + clearTimeout(initTimeout); + handlersAttachedRef.current = false; + if (ghostLineRafRef.current) { + cancelAnimationFrame(ghostLineRafRef.current); + } + stage.off("mousemove", handleStageMouseMove); + stage.off("mouseup", handleStageMouseUp); + stage.off("mouseenter", handleStageMouseEnter); + stage.off("mouseleave", handleStageMouseLeave); + }; + }, [disableInternalPointAddition, stageReadyRetry]); // Re-run when disableInternalPointAddition changes or when retrying + + // Handle Shift key for disconnected mode + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Shift") { + setIsDisconnectedMode(true); + } + }; + + const handleKeyUp = (e: KeyboardEvent) => { + if (e.key === "Shift") { + setIsDisconnectedMode(false); + } + }; + + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + }; + }, []); + + // Click handler with debouncing for single/double-click detection + const handleClickWithDebouncing = useCallback( + (e: any, onClickHandler?: (e: any) => void, onDblClickHandler?: (e: any) => void) => { + // If disabled, fire onClick immediately (no need to wait for double-click detection) + if (disabled) { + if (onClickHandler) { + const newEvent = { + ...e, + evt: { + ...e.evt, + defaultPrevented: false, + stopImmediatePropagation: e.evt.stopImmediatePropagation?.bind(e.evt) || (() => {}), + stopPropagation: e.evt.stopPropagation?.bind(e.evt) || (() => {}), + preventDefault: e.evt.preventDefault?.bind(e.evt) || (() => {}), + }, + }; + onClickHandler(newEvent); + } + return; + } + + // Clear any existing timeout + if (clickTimeoutRef.current) { + clearTimeout(clickTimeoutRef.current); + clickTimeoutRef.current = null; + // This is a double-click, handle it + doubleClickHandledRef.current = true; + if (onDblClickHandler) { + // Create a new event object to avoid defaultPrevented issues + // Preserve all event methods by copying the original evt object and only resetting defaultPrevented + const newEvent = { + ...e, + evt: { + ...e.evt, + defaultPrevented: false, + // Preserve all methods from the original event + stopImmediatePropagation: e.evt.stopImmediatePropagation?.bind(e.evt) || (() => {}), + stopPropagation: e.evt.stopPropagation?.bind(e.evt) || (() => {}), + preventDefault: e.evt.preventDefault?.bind(e.evt) || (() => {}), + }, + }; + onDblClickHandler(newEvent); } // Reset the flag after a short delay setTimeout(() => { @@ -1679,17 +3121,30 @@ export const KonvaVector = forwardRef((props, return; } - // Set a timeout for single-click handling - console.log("🖱 Single-click detected, setting timeout"); + // Set a timeout for single-click handling (only when not disabled, to detect double-clicks) clickTimeoutRef.current = setTimeout(() => { clickTimeoutRef.current = null; - console.log("🖱 Single-click timeout fired, calling onClickHandler"); if (onClickHandler) { - onClickHandler(e); + // Create a new event object to avoid defaultPrevented issues + // This ensures the onClick handler in VectorRegion.jsx can fire even if + // the event was prevented elsewhere (e.g., by onFinish handler) + // Preserve all event methods by copying the original evt object and only resetting defaultPrevented + const newEvent = { + ...e, + evt: { + ...e.evt, + defaultPrevented: false, + // Preserve all methods from the original event + stopImmediatePropagation: e.evt.stopImmediatePropagation?.bind(e.evt) || (() => {}), + stopPropagation: e.evt.stopPropagation?.bind(e.evt) || (() => {}), + preventDefault: e.evt.preventDefault?.bind(e.evt) || (() => {}), + }, + }; + onClickHandler(newEvent); } - }, 300); + }, 200); }, - [], + [disabled], ); // Create event handlers @@ -1710,7 +3165,6 @@ export const KonvaVector = forwardRef((props, setNewPointDragIndex, setIsDraggingNewBezier, setGhostPointDragInfo, - setCursorPosition, setVisibleControlPoints, setIsPathClosed, isDragging, @@ -1730,7 +3184,6 @@ export const KonvaVector = forwardRef((props, draggedControlPoint, isDraggingNewBezier, newPointDragIndex: _newPointDragIndex, - cursorPosition, visibleControlPoints, isDisconnectedMode, onPointsChange, @@ -1742,6 +3195,7 @@ export const KonvaVector = forwardRef((props, onPathShapeChanged, onPointSelected, onFinish, + onGhostPointClick, onMouseDown, onMouseMove, onMouseUp, @@ -1760,6 +3214,8 @@ export const KonvaVector = forwardRef((props, setActivePointId, isTransforming, disabled, + transformMode, + disableInternalPointAddition, pointCreationManager, }); @@ -1775,19 +3231,39 @@ export const KonvaVector = forwardRef((props, onMouseMove={disabled ? undefined : eventHandlers.handleLayerMouseMove} onMouseUp={disabled ? undefined : eventHandlers.handleLayerMouseUp} onClick={ - disabled + disabled || transformMode ? undefined : (e) => { + // Prevent all clicks when in transform mode (already checked above, but double-check) + if (transformMode) { + e.evt.stopPropagation(); + e.evt.preventDefault(); + e.evt.stopImmediatePropagation(); + e.cancelBubble = true; + return; + } + + // Don't add points if we just finished shape dragging + if (justFinishedShapeDrag.current) { + e.evt.stopPropagation(); + e.evt.preventDefault(); + e.cancelBubble = true; // Also set cancelBubble for Konva + justFinishedShapeDrag.current = false; // Clear flag immediately + return; + } + // Skip if point selection was already handled by VectorPoints onClick if (pointSelectionHandled.current) { pointSelectionHandled.current = false; + // Don't prevent propagation - let the event bubble to VectorShape onClick return; } // For the first point in drawing mode, we need to ensure the click handler works // The issue is that the flag logic is interfering with first point creation // Let's try calling the drawing mode click handler directly for the first point - if (initialPoints.length === 0 && !drawingDisabled) { + // Skip if internal point addition is disabled + if (initialPoints.length === 0 && !drawingDisabled && !disableInternalPointAddition) { // For the first point, call the drawing mode click handler directly const pos = e.target.getStage()?.getPointerPosition(); if (pos) { @@ -1821,27 +3297,28 @@ export const KonvaVector = forwardRef((props, } // For subsequent points, use the normal event handler + // But don't prevent propagation - let the event bubble to VectorShape onClick + // so that shape selection/unselection can work when clicking on segments eventHandlers.handleLayerClick(e); + // Don't call preventDefault or stopPropagation here - let the event bubble } } onDblClick={ disabled ? undefined : (e) => { - console.log("🖱 Group onDblClick called, doubleClickHandled:", doubleClickHandledRef.current); // If we've already handled this double-click through debouncing, ignore it if (doubleClickHandledRef.current) { - console.log("🖱 Ignoring Group onDblClick - already handled through debouncing"); return; } // Otherwise, call the original onDblClick handler - console.log("🖱 Calling original onDblClick handler"); onDblClick?.(e); } } > - {/* Invisible rectangle - always render to capture mouse events for cursor position updates */} - {!disabled && ( + {/* Invisible rectangle - render to capture mouse events for cursor position updates */} + {/* Disabled when disableInternalPointAddition is true or when component is disabled */} + {!disabled && !disableInternalPointAddition && ( { ctx.beginPath(); @@ -1857,77 +3334,13 @@ export const KonvaVector = forwardRef((props, { - // Apply image coordinate bounds for VectorRegion drag constraints - const imageWidth = width || 0; - const imageHeight = height || 0; - - if (imageWidth > 0 && imageHeight > 0) { - const node = e.target; - const { x, y } = node.position(); - - // Calculate bounding box of current points - const xs = rawInitialPoints.map((p) => p.x); - const ys = rawInitialPoints.map((p) => p.y); - const minX = Math.min(...xs); - const maxX = Math.max(...xs); - const minY = Math.min(...ys); - const maxY = Math.max(...ys); - - // Calculate where the shape would be after this drag - const newMinX = minX + x; - const newMaxX = maxX + x; - const newMinY = minY + y; - const newMaxY = maxY + y; - - // Apply constraints - let constrainedX = x; - let constrainedY = y; - - if (newMinX < 0) constrainedX = x - newMinX; - if (newMaxX > imageWidth) constrainedX = x - (newMaxX - imageWidth); - if (newMinY < 0) constrainedY = y - newMinY; - if (newMaxY > imageHeight) constrainedY = y - (newMaxY - imageHeight); - - // Update position if constraints were applied - if (constrainedX !== x || constrainedY !== y) { - // For multi-region selection, apply the same constraint to all selected shapes - if (isMultiRegionSelected) { - const stage = node.getStage(); - const allTransformableGroups = stage?.find("._transformable"); - const allNodes = stage?.getChildren(); - - if (allTransformableGroups && allTransformableGroups.length > 1) { - // Calculate the constraint offset - const constraintOffsetX = constrainedX - x; - const constraintOffsetY = constrainedY - y; - - console.log( - `🔍 Multi-region constraint offset: (${constraintOffsetX.toFixed(1)}, ${constraintOffsetY.toFixed(1)})`, - ); - - // Apply the same constraint to all other transformable groups - allTransformableGroups.forEach((group) => { - if (group !== node) { - const currentPos = group.position(); - console.log( - `🔍 Applying constraint to group ${group.name()}: (${currentPos.x.toFixed(1)}, ${currentPos.y.toFixed(1)}) -> (${(currentPos.x + constraintOffsetX).toFixed(1)}, ${(currentPos.y + constraintOffsetY).toFixed(1)})`, - ); - group.position({ - x: currentPos.x + constraintOffsetX, - y: currentPos.y + constraintOffsetY, - }); - } - }); - } - } - - node.position({ x: constrainedX, y: constrainedY }); - } - - console.log( - `🔍 VectorDragConstraint: bounds=${imageWidth}x${imageHeight}, pos=(${constrainedX.toFixed(1)}, ${constrainedY.toFixed(1)})`, - ); + draggable={true} + onTransformEnd={(e) => { + // This is called when ImageTransformer finishes transforming the Group + // Commit the transform immediately to prevent position reset + if (e.target === e.currentTarget && transformableGroupRef.current && initialTransformRef.current) { + commitMultiRegionTransform(); + handleTransformEnd(e); } }} > @@ -1943,14 +3356,41 @@ export const KonvaVector = forwardRef((props, transform={transform} fitScale={fitScale} onClick={(e) => { + // Prevent all clicks when in transform mode + if (transformMode) { + e.evt.stopPropagation(); + e.evt.preventDefault(); + e.evt.stopImmediatePropagation(); + e.cancelBubble = true; + return; + } + + // CRITICAL: Handle Alt+click FIRST (for point deletion and segment breaking) + // This must happen before any other click handling to ensure deletion works + if (e.evt.altKey && !e.evt.shiftKey && !disabled) { + // Let the event bubble to the Group onClick handler which has the Alt+click logic + // Don't stop propagation or prevent default - let it reach createClickHandler + return; + } + + // Don't add points if we just finished shape dragging + if (justFinishedShapeDrag.current) { + e.evt.stopPropagation(); + e.evt.preventDefault(); + e.cancelBubble = true; // Also set cancelBubble for Konva + justFinishedShapeDrag.current = false; // Clear flag immediately + return; + } + // Check if click is on the last added point by checking cursor position - if (cursorPosition && lastAddedPointId) { + if (cursorPositionRef.current && lastAddedPointId) { const lastAddedPoint = initialPoints.find((p) => p.id === lastAddedPointId); if (lastAddedPoint) { const scale = transform.zoom * fitScale; - const hitRadius = 15 / scale; // Same radius as used in event handlers + const hitRadius = HIT_RADIUS.SELECTION / scale; // Use constant for consistent hit detection const distance = Math.sqrt( - (cursorPosition.x - lastAddedPoint.x) ** 2 + (cursorPosition.y - lastAddedPoint.y) ** 2, + (cursorPositionRef.current.x - lastAddedPoint.x) ** 2 + + (cursorPositionRef.current.y - lastAddedPoint.y) ** 2, ); if (distance <= hitRadius) { @@ -1959,7 +3399,13 @@ export const KonvaVector = forwardRef((props, // Only trigger onFinish if the last added point is already selected (second click) // and no modifiers are pressed (ctrl, meta, shift, alt) and component is not disabled - if (lastAddedPointIndex !== -1 && effectiveSelectedPoints.has(lastAddedPointIndex) && !disabled) { + // and not in transform mode + if ( + lastAddedPointIndex !== -1 && + effectiveSelectedPoints.has(lastAddedPointIndex) && + !disabled && + !transformMode + ) { const hasModifiers = e.evt.ctrlKey || e.evt.metaKey || e.evt.shiftKey || e.evt.altKey; if (!hasModifiers) { e.evt.preventDefault(); @@ -1974,7 +3420,93 @@ export const KonvaVector = forwardRef((props, } // Use debouncing for click/double-click detection - handleClickWithDebouncing(e, onClick, onDblClick); + // This will call the onClick handler from VectorRegion.jsx which handles shape selection/unselection + // Only call if we didn't just finish dragging (to allow shape dragging to work) + // Don't call if Shift is held (to allow shift-click for ghost point insertion without unselecting) + if (!justFinishedShapeDrag.current && !e.evt.shiftKey) { + // Stop propagation to prevent the Group onClick handler from also processing the click + // This prevents the shape from being selected and then immediately unselected + e.evt.stopPropagation(); + e.evt.preventDefault(); + e.cancelBubble = true; + handleClickWithDebouncing(e, onClick, onDblClick); + } + }} + onMouseDown={(e) => { + // Don't start shape drag if in multi-region selection mode + // ImageTransformer will handle dragging in this case + if (isMultiRegionSelected) { + return; + } + + // Don't start shape drag if we're already dragging a point or control point + if (draggedPointIndex !== null || draggedControlPoint !== null) { + return; + } + + // Don't start shape drag if transformer is active + if (effectiveSelectedPoints.size > 1) { + return; + } + + // Don't start shape drag if clicking on a point + const pos = e.target.getStage()?.getPointerPosition(); + if (!pos) return; + + const imagePos = stageToImageCoordinates(pos, transform, fitScale, x, y); + const scale = transform.zoom * fitScale; + const hitRadius = HIT_RADIUS.SELECTION / scale; + + // Check if clicking on any point + for (let i = 0; i < initialPoints.length; i++) { + const point = initialPoints[i]; + const distance = Math.sqrt((imagePos.x - point.x) ** 2 + (imagePos.y - point.y) ** 2); + if (distance <= hitRadius) { + return; // Let point dragging handle it + } + } + + // Check if clicking on any control point + for (let i = 0; i < initialPoints.length; i++) { + const point = initialPoints[i]; + if (point.isBezier) { + if (point.controlPoint1) { + const distance = Math.sqrt( + (imagePos.x - point.controlPoint1.x) ** 2 + (imagePos.y - point.controlPoint1.y) ** 2, + ); + if (distance <= hitRadius) { + return; // Let control point dragging handle it + } + } + if (point.controlPoint2) { + const distance = Math.sqrt( + (imagePos.x - point.controlPoint2.x) ** 2 + (imagePos.y - point.controlPoint2.y) ** 2, + ); + if (distance <= hitRadius) { + return; // Let control point dragging handle it + } + } + } + } + + // Start shape dragging (don't stop propagation yet - we'll do it on mouseup if we actually drag) + setIsDraggingShape(true); + handleTransformStart(); + shapeDragDistance.current = 0; // Reset drag distance + shapeDragStartPos.current = { + x: e.evt.clientX, + y: e.evt.clientY, + imageX: imagePos.x, + imageY: imagePos.y, + }; + + // Store original positions of all points + originalPointsPositions.current = initialPoints.map((point) => ({ + x: point.x, + y: point.y, + controlPoint1: point.controlPoint1 ? { x: point.controlPoint1.x, y: point.controlPoint1.y } : undefined, + controlPoint2: point.controlPoint2 ? { x: point.controlPoint2.x, y: point.controlPoint2.y } : undefined, + })); }} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} @@ -1982,26 +3514,31 @@ export const KonvaVector = forwardRef((props, /> {/* Ghost line - preview from last point to cursor */} - + {!disabled && !disableGhostLine && ( + + )} {/* Control points - render first so lines appear under main points */} {!disabled && ( @@ -2026,12 +3563,46 @@ export const KonvaVector = forwardRef((props, fitScale={fitScale} pointRefs={pointRefs} disabled={disabled} + transformMode={transformMode} pointRadius={pointRadius} pointFill={pointFill} pointStroke={pointStroke} pointStrokeSelected={pointStrokeSelected} pointStrokeWidth={pointStrokeWidth} + activePointId={activePointId} + maxPoints={maxPoints} onPointClick={(e, pointIndex) => { + // Prevent all clicks when in transform mode + if (transformMode) { + e.evt.stopPropagation(); + e.evt.preventDefault(); + e.evt.stopImmediatePropagation(); + e.cancelBubble = true; + return; + } + + // CRITICAL: For single-point regions, directly call onClick handler so the region can be selected + // Single-point regions have no segments to click on, so clicking the point must trigger region selection + // Check this FIRST before any other logic + // BUT: Don't do this in transform mode - clicks must be completely disabled + const isSinglePointRegion = initialPoints.length === 1; + if ( + isSinglePointRegion && + !e.evt.altKey && + !e.evt.shiftKey && + !e.evt.ctrlKey && + !e.evt.metaKey && + !transformMode + ) { + // Select the point first + tracker.selectPoints(instanceId, new Set([pointIndex])); + // Directly call handleClickWithDebouncing to trigger region selection + // This works even when disabled=true (Group onClick is undefined) + pointSelectionHandled.current = true; + handleClickWithDebouncing(e, onClick, onDblClick); + return; + } + // Handle point selection even when disabled (similar to shape clicks) if (disabled) { // Check if this instance can have selection @@ -2076,21 +3647,25 @@ export const KonvaVector = forwardRef((props, } // Check if this is the last added point and already selected (second click) - const isLastAddedPoint = lastAddedPointId && initialPoints[pointIndex]?.id === lastAddedPointId; - const isAlreadySelected = effectiveSelectedPoints.has(pointIndex); - - // Only fire onFinish if this is the last added point AND it was already selected (second click) - // and no modifiers are pressed (ctrl, meta, shift, alt) and component is not disabled - if (isLastAddedPoint && isAlreadySelected && !disabled) { - const hasModifiers = e.evt.ctrlKey || e.evt.metaKey || e.evt.shiftKey || e.evt.altKey; - if (!hasModifiers) { - onFinish?.(e); - pointSelectionHandled.current = true; // Mark that we handled selection - e.evt.stopImmediatePropagation(); // Prevent all other handlers from running + // Only check if the shape is NOT disabled - disabled shapes should not trigger onFinish + if (!disabled) { + const isLastAddedPoint = lastAddedPointId && initialPoints[pointIndex]?.id === lastAddedPointId; + const isAlreadySelected = effectiveSelectedPoints.has(pointIndex); + + // Only fire onFinish if this is the last added point AND it was already selected (second click) + // and no modifiers are pressed (ctrl, meta, shift, alt) and we're in drawing mode + // and not in transform mode + if (isLastAddedPoint && isAlreadySelected && !drawingDisabled && !transformMode) { + const hasModifiers = e.evt.ctrlKey || e.evt.metaKey || e.evt.shiftKey || e.evt.altKey; + if (!hasModifiers) { + onFinish?.(e); + pointSelectionHandled.current = true; // Mark that we handled selection + e.evt.stopImmediatePropagation(); // Prevent all other handlers from running + return; + } + // If modifiers are held, skip onFinish entirely and let normal modifier handling take over return; } - // If modifiers are held, skip onFinish entirely and let normal modifier handling take over - return; } // Handle regular point selection (only when not in transform mode) @@ -2106,12 +3681,17 @@ export const KonvaVector = forwardRef((props, } } - // Call the original onClick handler if provided - onClick?.(e); + // Don't call onClick here - clicking on points should NOT select/unselect the shape + // Only clicking on segments should select/unselect the shape + // EXCEPTION: Single-point regions (handled above) // Mark that we handled selection and prevent all other handlers from running + // This prevents the VectorShape onClick handler from firing, which would call onFinish pointSelectionHandled.current = true; e.evt.stopImmediatePropagation(); + e.evt.stopPropagation(); + e.evt.preventDefault(); + e.cancelBubble = true; return; } @@ -2143,6 +3723,8 @@ export const KonvaVector = forwardRef((props, scaleY={scaleY} transform={transform} fitScale={fitScale} + getCurrentPointsRef={getCurrentPointsRef} + updateCurrentPointsRef={updateCurrentPointsRef} onPointsChange={(newPoints) => { // Update main path points onPointsChange?.(newPoints); @@ -2152,23 +3734,14 @@ export const KonvaVector = forwardRef((props, }} onTransformationStart={() => { setIsTransforming(true); + handleTransformStart(); }} onTransformationEnd={() => { setIsTransforming(false); + handleTransformEnd(); }} /> )} - - {/* Ghost point */} - ) : ( <> @@ -2184,14 +3757,41 @@ export const KonvaVector = forwardRef((props, transform={transform} fitScale={fitScale} onClick={(e) => { + // Prevent all clicks when in transform mode + if (transformMode) { + e.evt.stopPropagation(); + e.evt.preventDefault(); + e.evt.stopImmediatePropagation(); + e.cancelBubble = true; + return; + } + + // CRITICAL: Handle Alt+click FIRST (for point deletion and segment breaking) + // This must happen before any other click handling to ensure deletion works + if (e.evt.altKey && !e.evt.shiftKey && !disabled) { + // Let the event bubble to the Group onClick handler which has the Alt+click logic + // Don't stop propagation or prevent default - let it reach createClickHandler + return; + } + + // Don't add points if we just finished shape dragging + if (justFinishedShapeDrag.current) { + e.evt.stopPropagation(); + e.evt.preventDefault(); + e.cancelBubble = true; // Also set cancelBubble for Konva + justFinishedShapeDrag.current = false; // Clear flag immediately + return; + } + // Check if click is on the last added point by checking cursor position - if (cursorPosition && lastAddedPointId) { + if (cursorPositionRef.current && lastAddedPointId) { const lastAddedPoint = initialPoints.find((p) => p.id === lastAddedPointId); if (lastAddedPoint) { const scale = transform.zoom * fitScale; - const hitRadius = 15 / scale; // Same radius as used in event handlers + const hitRadius = HIT_RADIUS.SELECTION / scale; // Use constant for consistent hit detection const distance = Math.sqrt( - (cursorPosition.x - lastAddedPoint.x) ** 2 + (cursorPosition.y - lastAddedPoint.y) ** 2, + (cursorPositionRef.current.x - lastAddedPoint.x) ** 2 + + (cursorPositionRef.current.y - lastAddedPoint.y) ** 2, ); if (distance <= hitRadius) { @@ -2200,7 +3800,13 @@ export const KonvaVector = forwardRef((props, // Only trigger onFinish if the last added point is already selected (second click) // and no modifiers are pressed (ctrl, meta, shift, alt) and component is not disabled - if (lastAddedPointIndex !== -1 && effectiveSelectedPoints.has(lastAddedPointIndex) && !disabled) { + // and not in transform mode + if ( + lastAddedPointIndex !== -1 && + effectiveSelectedPoints.has(lastAddedPointIndex) && + !disabled && + !transformMode + ) { const hasModifiers = e.evt.ctrlKey || e.evt.metaKey || e.evt.shiftKey || e.evt.altKey; if (!hasModifiers) { e.evt.preventDefault(); @@ -2215,7 +3821,93 @@ export const KonvaVector = forwardRef((props, } // Use debouncing for click/double-click detection - handleClickWithDebouncing(e, onClick, onDblClick); + // This will call the onClick handler from VectorRegion.jsx which handles shape selection/unselection + // Only call if we didn't just finish dragging (to allow shape dragging to work) + // Don't call if Shift is held (to allow shift-click for ghost point insertion without unselecting) + if (!justFinishedShapeDrag.current && !e.evt.shiftKey) { + // Stop propagation to prevent the Group onClick handler from also processing the click + // This prevents the shape from being selected and then immediately unselected + e.evt.stopPropagation(); + e.evt.preventDefault(); + e.cancelBubble = true; + handleClickWithDebouncing(e, onClick, onDblClick); + } + }} + onMouseDown={(e) => { + // Don't start shape drag if in multi-region selection mode + // ImageTransformer will handle dragging in this case + if (isMultiRegionSelected) { + return; + } + + // Don't start shape drag if we're already dragging a point or control point + if (draggedPointIndex !== null || draggedControlPoint !== null) { + return; + } + + // Don't start shape drag if transformer is active + if (effectiveSelectedPoints.size > 1) { + return; + } + + // Don't start shape drag if clicking on a point + const pos = e.target.getStage()?.getPointerPosition(); + if (!pos) return; + + const imagePos = stageToImageCoordinates(pos, transform, fitScale, x, y); + const scale = transform.zoom * fitScale; + const hitRadius = HIT_RADIUS.SELECTION / scale; + + // Check if clicking on any point + for (let i = 0; i < initialPoints.length; i++) { + const point = initialPoints[i]; + const distance = Math.sqrt((imagePos.x - point.x) ** 2 + (imagePos.y - point.y) ** 2); + if (distance <= hitRadius) { + return; // Let point dragging handle it + } + } + + // Check if clicking on any control point + for (let i = 0; i < initialPoints.length; i++) { + const point = initialPoints[i]; + if (point.isBezier) { + if (point.controlPoint1) { + const distance = Math.sqrt( + (imagePos.x - point.controlPoint1.x) ** 2 + (imagePos.y - point.controlPoint1.y) ** 2, + ); + if (distance <= hitRadius) { + return; // Let control point dragging handle it + } + } + if (point.controlPoint2) { + const distance = Math.sqrt( + (imagePos.x - point.controlPoint2.x) ** 2 + (imagePos.y - point.controlPoint2.y) ** 2, + ); + if (distance <= hitRadius) { + return; // Let control point dragging handle it + } + } + } + } + + // Start shape dragging (don't stop propagation yet - we'll do it on mouseup if we actually drag) + setIsDraggingShape(true); + handleTransformStart(); + shapeDragDistance.current = 0; // Reset drag distance + shapeDragStartPos.current = { + x: e.evt.clientX, + y: e.evt.clientY, + imageX: imagePos.x, + imageY: imagePos.y, + }; + + // Store original positions of all points + originalPointsPositions.current = initialPoints.map((point) => ({ + x: point.x, + y: point.y, + controlPoint1: point.controlPoint1 ? { x: point.controlPoint1.x, y: point.controlPoint1.y } : undefined, + controlPoint2: point.controlPoint2 ? { x: point.controlPoint2.x, y: point.controlPoint2.y } : undefined, + })); }} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} @@ -2223,26 +3915,31 @@ export const KonvaVector = forwardRef((props, /> {/* Ghost line - preview from last point to cursor */} - + {!disabled && !disableGhostLine && ( + + )} {/* Control points - render first so lines appear under main points */} {!disabled && ( @@ -2267,11 +3964,14 @@ export const KonvaVector = forwardRef((props, fitScale={fitScale} pointRefs={pointRefs} disabled={disabled} + transformMode={transformMode} pointRadius={pointRadius} pointFill={pointFill} pointStroke={pointStroke} pointStrokeSelected={pointStrokeSelected} pointStrokeWidth={pointStrokeWidth} + activePointId={activePointId} + maxPoints={maxPoints} onPointClick={(e, pointIndex) => { // Handle Alt+click point deletion FIRST (before other checks) if (e.evt.altKey && !e.evt.shiftKey && !disabled) { @@ -2288,6 +3988,10 @@ export const KonvaVector = forwardRef((props, lastAddedPointId, ); pointSelectionHandled.current = true; + // Stop event propagation to prevent point addition + e.evt.stopPropagation(); + e.evt.preventDefault(); + e.cancelBubble = true; return; // Successfully deleted point } @@ -2312,6 +4016,32 @@ export const KonvaVector = forwardRef((props, } } + // Handle cmd/ctrl-click for multi-selection (when not disabled) + if (!disabled && (e.evt.ctrlKey || e.evt.metaKey) && !e.evt.altKey && !e.evt.shiftKey) { + // Check if this instance can have selection + if (!tracker.canInstanceHaveSelection(instanceId)) { + return; // Block the selection + } + + // Check if this point is already selected - if so, deselect it + if (effectiveSelectedPoints.has(pointIndex)) { + const newSelection = new Set(effectiveSelectedPoints); + newSelection.delete(pointIndex); + tracker.selectPoints(instanceId, newSelection); + pointSelectionHandled.current = true; + e.evt.stopImmediatePropagation(); + return; + } + + // If not deselection, add to multi-selection + const newSelection = new Set(effectiveSelectedPoints); + newSelection.add(pointIndex); + tracker.selectPoints(instanceId, newSelection); + pointSelectionHandled.current = true; + e.evt.stopImmediatePropagation(); + return; + } + // Handle point selection even when disabled (similar to shape clicks) if (disabled) { // Check if this instance can have selection @@ -2335,6 +4065,58 @@ export const KonvaVector = forwardRef((props, ) { return; // Block the selection } + + // For disabled mode, still allow point selection + tracker.selectPoints(instanceId, new Set([pointIndex])); + pointSelectionHandled.current = true; + + // CRITICAL: For single-point regions, directly call onClick handler so the region can be selected + // Single-point regions have no segments to click on, so clicking the point must trigger region selection + const isSinglePointRegion = initialPoints.length === 1; + if (isSinglePointRegion && !e.evt.altKey && !e.evt.shiftKey && !e.evt.ctrlKey && !e.evt.metaKey) { + // Directly call handleClickWithDebouncing to trigger region selection + // This works even when disabled=true (Group onClick is undefined) + handleClickWithDebouncing(e, onClick, onDblClick); + return; + } + + e.evt.stopPropagation(); + e.evt.preventDefault(); + e.cancelBubble = true; + return; + } + + // Handle regular point selection (when not disabled and not in transform mode) + if (!transformMode) { + // Check if this instance can have selection + if (!tracker.canInstanceHaveSelection(instanceId)) { + return; // Block the selection + } + + // Select only this point (single selection for regular click) + tracker.selectPoints(instanceId, new Set([pointIndex])); + pointSelectionHandled.current = true; + + // CRITICAL: For single-point regions, directly call onClick handler so the region can be selected + // Single-point regions have no segments to click on, so clicking the point must trigger region selection + const isSinglePointRegion = initialPoints.length === 1; + if (isSinglePointRegion && !e.evt.altKey && !e.evt.shiftKey && !e.evt.ctrlKey && !e.evt.metaKey) { + // Directly call handleClickWithDebouncing to trigger region selection + // This works even when disabled=true (Group onClick is undefined) + handleClickWithDebouncing(e, onClick, onDblClick); + return; + } + + // Don't call onClick here - clicking on points should NOT select/unselect the shape + // Only clicking on segments should select/unselect the shape + // EXCEPTION: Single-point regions (handled above) + // Always stop event propagation to prevent the VectorShape onClick handler from firing + // This prevents the shape from being selected/unselected when clicking on points + e.evt.stopImmediatePropagation(); + e.evt.stopPropagation(); + e.evt.preventDefault(); + e.cancelBubble = true; + return; } // Mark that point selection was handled @@ -2367,8 +4149,19 @@ export const KonvaVector = forwardRef((props, initialPoints={getAllPoints()} transformerRef={transformerRef} proxyRefs={proxyRefs} - onPointsChange={onPointsChange} + getCurrentPointsRef={getCurrentPointsRef} + updateCurrentPointsRef={updateCurrentPointsRef} + onPointsChange={(newPoints) => { + // Update main path points + onPointsChange?.(newPoints); + }} onTransformationComplete={notifyTransformationComplete} + onTransformationStart={() => { + handleTransformStart(); + }} + onTransformationEnd={() => { + handleTransformEnd(); + }} bounds={{ x: 0, y: 0, width, height }} transform={transform} fitScale={fitScale} @@ -2376,6 +4169,18 @@ export const KonvaVector = forwardRef((props, )} )} + + {/* Ghost point - ALWAYS render if ghostPoint exists, outside any conditionals */} + ); }); diff --git a/web/libs/editor/src/components/KonvaVector/components/GhostLine.tsx b/web/libs/editor/src/components/KonvaVector/components/GhostLine.tsx index 3933374ea547..222f599d3043 100644 --- a/web/libs/editor/src/components/KonvaVector/components/GhostLine.tsx +++ b/web/libs/editor/src/components/KonvaVector/components/GhostLine.tsx @@ -1,11 +1,12 @@ import type React from "react"; import { Shape } from "react-konva"; import type { BezierPoint } from "../types"; -import { GHOST_LINE_STYLING, DEFAULT_STROKE_COLOR } from "../constants"; +import { GHOST_LINE_STYLING, DEFAULT_STROKE_COLOR, HIT_RADIUS } from "../constants"; +import { findClosestPointOnPath, getDistance } from "../eventHandlers/utils"; interface GhostLineProps { initialPoints: BezierPoint[]; - cursorPosition: { x: number; y: number } | null; + cursorPositionRef: React.RefObject<{ x: number; y: number } | null>; draggedControlPoint: { pointIndex: number; controlIndex: number } | null; draggedPointIndex?: number | null; isDraggingNewBezier?: boolean; @@ -22,11 +23,15 @@ interface GhostLineProps { stroke?: string; pixelSnapping?: boolean; drawingDisabled?: boolean; + // Non-hover-related disabled states (for real-time checks) + isShiftKeyHeld?: boolean; + transformMode?: boolean; + effectiveSelectedPointsSize?: number; } export const GhostLine: React.FC = ({ initialPoints, - cursorPosition, + cursorPositionRef, draggedControlPoint, draggedPointIndex = null, isDraggingNewBezier = false, @@ -37,11 +42,15 @@ export const GhostLine: React.FC = ({ maxPoints, minPoints, skeletonEnabled, + selectedPointIndex = null, lastAddedPointId, activePointId = null, stroke = DEFAULT_STROKE_COLOR, pixelSnapping = false, drawingDisabled = false, + isShiftKeyHeld = false, + transformMode = false, + effectiveSelectedPointsSize = 0, }) => { // Helper function to snap coordinates to pixel grid const snapToPixel = (point: { x: number; y: number }) => { @@ -61,7 +70,17 @@ export const GhostLine: React.FC = ({ } } - // In non-skeleton mode, always use the last added point + // If a point is selected, use that point for the ghost line + if ( + selectedPointIndex !== null && + selectedPointIndex !== undefined && + selectedPointIndex >= 0 && + selectedPointIndex < initialPoints.length + ) { + return initialPoints[selectedPointIndex]; + } + + // In non-skeleton mode, use the last added point // Fallback to lastAddedPointId for backward compatibility if (lastAddedPointId) { const lastAddedPoint = initialPoints.find((p) => p.id === lastAddedPointId); @@ -80,105 +99,151 @@ export const GhostLine: React.FC = ({ const activePoint = getActivePoint(); - // Check if we're near the first or last point (for closing indicator) - const getClosingTarget = () => { - if (!allowClose || !cursorPosition || !activePoint) { - return null; - } + // Always render the Shape components - conditional logic happens inside sceneFunc + // This allows the ghost line to update via stage.batchDraw() without React re-renders + return ( + <> + {/* Ghost line from active point to cursor */} + {/* Only show ghost line when not at max points */} + {activePoint && ( + { + // Read cursor position from ref inside sceneFunc for real-time updates + const cursorPos = cursorPositionRef.current; - // Check if we can close the path based on point count or bezier points - const canClosePath = () => { - // Allow closing if we have more than 2 points - if (initialPoints.length > 2) { - return true; - } + // Check all conditions for showing ghost line + // Show ghost line when we have points and cursor position, unless: + // - We're dragging something + // - Path is closed + // - Max points reached + // - Drawing is disabled (includes hovering over points, control points, or segments) + if ( + !cursorPos || + draggedControlPoint || + draggedPointIndex !== null || + isDraggingNewBezier || + isPathClosed || + (maxPoints !== undefined && initialPoints.length >= maxPoints) + ) { + return; // Don't draw anything + } - // Allow closing if we have at least one bezier point - const hasBezierPoint = initialPoints.some((point) => point.isBezier); - if (hasBezierPoint) { - return true; - } + // Real-time hover detection: check if cursor is over points, control points, or segments + // This runs inside sceneFunc for real-time updates without React re-renders + const scale = transform.zoom * fitScale; - return false; - }; + // Check if hovering over control points + if (cursorPos && initialPoints.length > 0) { + const controlPointHitRadius = HIT_RADIUS.CONTROL_POINT / scale; + for (let i = 0; i < initialPoints.length; i++) { + const point = initialPoints[i]; + if (point.isBezier) { + if (point.controlPoint1) { + const distance = Math.sqrt( + (cursorPos.x - point.controlPoint1.x) ** 2 + (cursorPos.y - point.controlPoint1.y) ** 2, + ); + if (distance <= controlPointHitRadius) { + return; // Hide ghost line when hovering over control points + } + } + if (point.controlPoint2) { + const distance = Math.sqrt( + (cursorPos.x - point.controlPoint2.x) ** 2 + (cursorPos.y - point.controlPoint2.y) ** 2, + ); + if (distance <= controlPointHitRadius) { + return; // Hide ghost line when hovering over control points + } + } + } + } + } - if (!canClosePath()) { - return null; - } + // Check if hovering over points (except last point and first point when closing is possible) + if (cursorPos && initialPoints.length > 0) { + const selectionHitRadius = HIT_RADIUS.SELECTION / scale; + for (let i = 0; i < initialPoints.length; i++) { + const point = initialPoints[i]; + const distance = Math.sqrt((cursorPos.x - point.x) ** 2 + (cursorPos.y - point.y) ** 2); + if (distance <= selectionHitRadius) { + // Allow ghost line when hovering over the last point (so you can continue drawing) + if (i === initialPoints.length - 1) { + continue; // Don't hide ghost line for the last point + } + // Allow ghost line when hovering over the first point if path closing is possible + if (i === 0 && allowClose && !isPathClosed) { + continue; // Don't hide ghost line for the first point when closing is possible + } + // Hide ghost line when hovering over other points + return; + } + } + } - // Additional validation: ensure we meet the minimum points requirement - if (minPoints && initialPoints.length < minPoints) { - return null; - } + // Check if hovering over path segments + if (cursorPos && initialPoints.length >= 2) { + const segmentHitRadius = HIT_RADIUS.SEGMENT / scale; + const closestPathPoint = findClosestPointOnPath(cursorPos, initialPoints, allowClose, isPathClosed); + if (closestPathPoint && getDistance(cursorPos, closestPathPoint.point) <= segmentHitRadius) { + return; // Hide ghost line when hovering over segments + } + } - const firstPoint = initialPoints[0]; - const lastPoint = initialPoints[initialPoints.length - 1]; - const closeRadius = GHOST_LINE_STYLING.CLOSE_RADIUS / (transform.zoom * fitScale); + // Hide ghost line when drawing is disabled for non-hover reasons (Shift key, transform mode, etc.) + // Note: Hover detection (points, control points, segments) is handled above in real-time + // Check non-hover-related disabled states directly (not from drawingDisabled which includes hover state) + // Only show ghost line if drawing is enabled OR if we have a selected point (for editing) + const isNonHoverDisabled = isShiftKeyHeld || transformMode || effectiveSelectedPointsSize > 1; + if (isNonHoverDisabled) { + // If we have a selected point, still show ghost line (useful for editing) + // Otherwise, hide it when drawing is disabled (but only for non-hover reasons) + // Hover detection is already handled above, so this only checks Shift key, transform mode, etc. + if (selectedPointIndex === null) { + return; // Don't draw anything - drawing is disabled and no point is selected + } + } - // Only show closing indicator if the active point is the first or last point - const isActivePointFirst = activePoint.id === firstPoint.id; - const isActivePointLast = activePoint.id === lastPoint.id; + // Check if we should hide ghost line when closing indicator is visible + const closingTargetCheck = (() => { + if (!allowClose || !activePoint) return null; - if (!isActivePointFirst && !isActivePointLast) { - return null; // Active point is not first or last, no closing possible - } + const canClosePath = initialPoints.length > 2 || initialPoints.some((p) => p.isBezier); + if (!canClosePath || (minPoints && initialPoints.length < minPoints)) return null; - const distanceToFirst = Math.sqrt((cursorPosition.x - firstPoint.x) ** 2 + (cursorPosition.y - firstPoint.y) ** 2); - const distanceToLast = Math.sqrt((cursorPosition.x - lastPoint.x) ** 2 + (cursorPosition.y - lastPoint.y) ** 2); + const firstPoint = initialPoints[0]; + const lastPoint = initialPoints[initialPoints.length - 1]; + const closeRadius = GHOST_LINE_STYLING.CLOSE_RADIUS / (transform.zoom * fitScale); - // If active point is first, show closing to last point when hovering near last - if (isActivePointFirst && distanceToLast <= closeRadius) { - return { point: lastPoint, index: initialPoints.length - 1 }; - } + const isActivePointFirst = activePoint.id === firstPoint.id; + const isActivePointLast = activePoint.id === lastPoint.id; - // If active point is last, show closing to first point when hovering near first - if (isActivePointLast && distanceToFirst <= closeRadius) { - return { point: firstPoint, index: 0 }; - } + if (!isActivePointFirst && !isActivePointLast) return null; - return null; - }; + const distanceToFirst = Math.sqrt((cursorPos.x - firstPoint.x) ** 2 + (cursorPos.y - firstPoint.y) ** 2); + const distanceToLast = Math.sqrt((cursorPos.x - lastPoint.x) ** 2 + (cursorPos.y - lastPoint.y) ** 2); - // Check if we should show the ghost line - const shouldShowGhostLine = - !drawingDisabled && - cursorPosition && - !draggedControlPoint && - draggedPointIndex === null && - !isDraggingNewBezier && - !isPathClosed && - (maxPoints === undefined || initialPoints.length < maxPoints) && - activePoint && - !getClosingTarget(); // Hide ghost line when green closing line is visible - - // Always render if we have the necessary conditions for ghost line or closing indicator - // But allow rendering even when drawing is disabled if we're near a closing target - const closingTarget = getClosingTarget(); - const shouldRender = cursorPosition && !isPathClosed && (!drawingDisabled || closingTarget); - - if (!shouldRender) { - return null; - } + if (isActivePointFirst && distanceToLast <= closeRadius) { + return { point: lastPoint, index: initialPoints.length - 1 }; + } + if (isActivePointLast && distanceToFirst <= closeRadius) { + return { point: firstPoint, index: 0 }; + } + return null; + })(); + + if (closingTargetCheck) return; // Hide ghost line when closing indicator should show - return ( - <> - {/* Ghost line from active point to cursor */} - {/* Only show ghost line when not at max points */} - {shouldShowGhostLine && activePoint && ( - { ctx.beginPath(); ctx.moveTo(activePoint.x, activePoint.y); // Snap cursor position to pixel grid if enabled - const snappedCursor = snapToPixel(cursorPosition); + const snappedCursor = snapToPixel(cursorPos); // Check if the active point is a bezier point and has control points if (activePoint.isBezier && activePoint.controlPoint1 && activePoint.controlPoint2) { @@ -210,79 +275,108 @@ export const GhostLine: React.FC = ({ )} {/* Closing indicator when near first or last point - always show when appropriate */} - {(() => { - return ( - closingTarget && - activePoint && ( - { - ctx.beginPath(); - ctx.moveTo(activePoint.x, activePoint.y); - - const targetPoint = closingTarget.point; - - // Check if either point is a bezier point and handle curves accordingly - if ( - activePoint.isBezier && - activePoint.controlPoint2 && - targetPoint.isBezier && - targetPoint.controlPoint1 - ) { - // Both points are bezier - use their control points - ctx.bezierCurveTo( - activePoint.controlPoint2.x, - activePoint.controlPoint2.y, - targetPoint.controlPoint1.x, - targetPoint.controlPoint1.y, - targetPoint.x, - targetPoint.y, - ); - } else if (activePoint.isBezier && activePoint.controlPoint2) { - // Only active point is bezier - calculate control point for target point - const dx = targetPoint.x - activePoint.x; - const dy = targetPoint.y - activePoint.y; - const controlX = targetPoint.x - dx * GHOST_LINE_STYLING.BEZIER_CONTROL_MULTIPLIER; - const controlY = targetPoint.y - dy * GHOST_LINE_STYLING.BEZIER_CONTROL_MULTIPLIER; - ctx.bezierCurveTo( - activePoint.controlPoint2.x, - activePoint.controlPoint2.y, - controlX, - controlY, - targetPoint.x, - targetPoint.y, - ); - } else if (targetPoint.isBezier && targetPoint.controlPoint1) { - // Only target point is bezier - calculate control point for active point - const dx = targetPoint.x - activePoint.x; - const dy = targetPoint.y - activePoint.y; - const controlX = activePoint.x + dx * GHOST_LINE_STYLING.BEZIER_CONTROL_MULTIPLIER; - const controlY = activePoint.y + dy * GHOST_LINE_STYLING.BEZIER_CONTROL_MULTIPLIER; - ctx.bezierCurveTo( - controlX, - controlY, - targetPoint.controlPoint1.x, - targetPoint.controlPoint1.y, - targetPoint.x, - targetPoint.y, - ); - } else { - // Both points are regular - straight line - ctx.lineTo(targetPoint.x, targetPoint.y); - } + {activePoint && ( + { + // Read cursor position and check for closing target inside sceneFunc + const cursorPos = cursorPositionRef.current; + if (!cursorPos || isPathClosed || (drawingDisabled && !allowClose)) return; + + // Calculate closing target + const closingTarget = (() => { + if (!allowClose) return null; + + const canClosePath = initialPoints.length > 2 || initialPoints.some((p) => p.isBezier); + if (!canClosePath || (minPoints && initialPoints.length < minPoints)) return null; + + const firstPoint = initialPoints[0]; + const lastPoint = initialPoints[initialPoints.length - 1]; + const closeRadius = GHOST_LINE_STYLING.CLOSE_RADIUS / (transform.zoom * fitScale); + + const isActivePointFirst = activePoint.id === firstPoint.id; + const isActivePointLast = activePoint.id === lastPoint.id; + + if (!isActivePointFirst && !isActivePointLast) return null; + + const distanceToFirst = Math.sqrt((cursorPos.x - firstPoint.x) ** 2 + (cursorPos.y - firstPoint.y) ** 2); + const distanceToLast = Math.sqrt((cursorPos.x - lastPoint.x) ** 2 + (cursorPos.y - lastPoint.y) ** 2); + + if (isActivePointFirst && distanceToLast <= closeRadius) { + return { point: lastPoint, index: initialPoints.length - 1 }; + } + if (isActivePointLast && distanceToFirst <= closeRadius) { + return { point: firstPoint, index: 0 }; + } + return null; + })(); + + if (!closingTarget) return; + + ctx.beginPath(); + ctx.moveTo(activePoint.x, activePoint.y); + + const targetPoint = closingTarget.point; - ctx.strokeShape(shape); - }} - /> - ) - ); - })()} + // Check if either point is a bezier point and handle curves accordingly + if ( + activePoint.isBezier && + activePoint.controlPoint2 && + targetPoint.isBezier && + targetPoint.controlPoint1 + ) { + // Both points are bezier - use their control points + ctx.bezierCurveTo( + activePoint.controlPoint2.x, + activePoint.controlPoint2.y, + targetPoint.controlPoint1.x, + targetPoint.controlPoint1.y, + targetPoint.x, + targetPoint.y, + ); + } else if (activePoint.isBezier && activePoint.controlPoint2) { + // Only active point is bezier - calculate control point for target point + const dx = targetPoint.x - activePoint.x; + const dy = targetPoint.y - activePoint.y; + const controlX = targetPoint.x - dx * GHOST_LINE_STYLING.BEZIER_CONTROL_MULTIPLIER; + const controlY = targetPoint.y - dy * GHOST_LINE_STYLING.BEZIER_CONTROL_MULTIPLIER; + ctx.bezierCurveTo( + activePoint.controlPoint2.x, + activePoint.controlPoint2.y, + controlX, + controlY, + targetPoint.x, + targetPoint.y, + ); + } else if (targetPoint.isBezier && targetPoint.controlPoint1) { + // Only target point is bezier - calculate control point for active point + const dx = targetPoint.x - activePoint.x; + const dy = targetPoint.y - activePoint.y; + const controlX = activePoint.x + dx * GHOST_LINE_STYLING.BEZIER_CONTROL_MULTIPLIER; + const controlY = activePoint.y + dy * GHOST_LINE_STYLING.BEZIER_CONTROL_MULTIPLIER; + ctx.bezierCurveTo( + controlX, + controlY, + targetPoint.controlPoint1.x, + targetPoint.controlPoint1.y, + targetPoint.x, + targetPoint.y, + ); + } else { + // Both points are regular - straight line + ctx.lineTo(targetPoint.x, targetPoint.y); + } + + ctx.strokeShape(shape); + }} + /> + )} ); }; diff --git a/web/libs/editor/src/components/KonvaVector/components/GhostPoint.tsx b/web/libs/editor/src/components/KonvaVector/components/GhostPoint.tsx index 080efbb304fa..eb7bf844350f 100644 --- a/web/libs/editor/src/components/KonvaVector/components/GhostPoint.tsx +++ b/web/libs/editor/src/components/KonvaVector/components/GhostPoint.tsx @@ -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 = ({ - 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( + ({ 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(null); - return ( - <> - {/* Outer ring */} - - {/* 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 ( - - ); -}; + ); + }, +); diff --git a/web/libs/editor/src/components/KonvaVector/components/ProxyNodes.tsx b/web/libs/editor/src/components/KonvaVector/components/ProxyNodes.tsx index 039d6e6e9263..0e8804169938 100644 --- a/web/libs/editor/src/components/KonvaVector/components/ProxyNodes.tsx +++ b/web/libs/editor/src/components/KonvaVector/components/ProxyNodes.tsx @@ -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; initialPoints: BezierPoint[]; - proxyRefs: React.MutableRefObject<{ [key: number]: Konva.Rect | null }>; + proxyRefs: React.MutableRefObject<{ [key: number]: Konva.Circle | null }>; } export const ProxyNodes: React.FC = ({ selectedPoints, initialPoints, proxyRefs }) => { @@ -18,15 +18,16 @@ export const ProxyNodes: React.FC = ({ selectedPoints, initialP if (!point) return null; return ( - { 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}`} diff --git a/web/libs/editor/src/components/KonvaVector/components/VectorPoints.tsx b/web/libs/editor/src/components/KonvaVector/components/VectorPoints.tsx index 74b0e9189e8e..c51fea451c16 100644 --- a/web/libs/editor/src/components/KonvaVector/components/VectorPoints.tsx +++ b/web/libs/editor/src/components/KonvaVector/components/VectorPoints.tsx @@ -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[]; @@ -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; @@ -19,6 +21,8 @@ interface VectorPointsProps { pointStroke?: string; pointStrokeSelected?: string; pointStrokeWidth?: number; + activePointId?: string | null; + maxPoints?: number; onPointClick?: (e: Konva.KonvaEventObject, pointIndex: number) => void; } @@ -30,13 +34,22 @@ export const VectorPoints: React.FC = ({ 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) => { @@ -46,26 +59,91 @@ export const VectorPoints: React.FC = ({ 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 ( - { - 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 && ( + + )} + {/* Main point circle with colored stroke */} + { + 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 + } + /> + ); })} diff --git a/web/libs/editor/src/components/KonvaVector/components/VectorShape.tsx b/web/libs/editor/src/components/KonvaVector/components/VectorShape.tsx index deace4ab7ceb..3bdd345c4af5 100644 --- a/web/libs/editor/src/components/KonvaVector/components/VectorShape.tsx +++ b/web/libs/editor/src/components/KonvaVector/components/VectorShape.tsx @@ -17,6 +17,9 @@ interface VectorShapeProps { onClick?: (e: KonvaEventObject) => void; onMouseEnter?: (e: any) => void; onMouseLeave?: (e: any) => void; + onMouseDown?: (e: KonvaEventObject) => void; + onMouseMove?: (e: KonvaEventObject) => void; + onMouseUp?: (e: KonvaEventObject) => void; } // Convert Bezier segments to SVG path data for a single continuous path @@ -214,6 +217,9 @@ export const VectorShape: React.FC = ({ onClick, onMouseEnter, onMouseLeave, + onMouseDown, + onMouseMove, + onMouseUp, }) => { if (segments.length === 0) return null; @@ -272,6 +278,9 @@ export const VectorShape: React.FC = ({ onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} + onMouseDown={onMouseDown} + onMouseMove={onMouseMove} + onMouseUp={onMouseUp} /> ); })} @@ -301,6 +310,9 @@ export const VectorShape: React.FC = ({ onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} + onMouseDown={onMouseDown} + onMouseMove={onMouseMove} + onMouseUp={onMouseUp} /> ); })} diff --git a/web/libs/editor/src/components/KonvaVector/components/VectorTransformer.tsx b/web/libs/editor/src/components/KonvaVector/components/VectorTransformer.tsx index 9fcf3b3516fb..f2f4328a1fff 100644 --- a/web/libs/editor/src/components/KonvaVector/components/VectorTransformer.tsx +++ b/web/libs/editor/src/components/KonvaVector/components/VectorTransformer.tsx @@ -16,7 +16,7 @@ interface VectorTransformerProps { selectedPoints: Set; initialPoints: BezierPoint[]; transformerRef: React.RefObject; - proxyRefs?: React.MutableRefObject<{ [key: number]: Konva.Rect | null }>; + proxyRefs?: React.MutableRefObject<{ [key: number]: Konva.Circle | null }>; onPointsChange?: (points: BezierPoint[]) => void; onTransformStateChange?: (state: { rotation: number; @@ -33,6 +33,8 @@ interface VectorTransformerProps { scaleY?: number; transform?: { zoom: number; offsetX: number; offsetY: number }; fitScale?: number; + updateCurrentPointsRef?: (points: BezierPoint[]) => void; + getCurrentPointsRef?: () => BezierPoint[]; } export const VectorTransformer: React.FC = ({ @@ -50,6 +52,8 @@ export const VectorTransformer: React.FC = ({ scaleY = 1, transform = { zoom: 1, offsetX: 0, offsetY: 0 }, fitScale = 1, + updateCurrentPointsRef, + getCurrentPointsRef, }) => { const transformerStateRef = React.useRef<{ rotation: number; @@ -143,8 +147,15 @@ export const VectorTransformer: React.FC = ({ originalPositionsRef.current, transformerCenter, bounds, + getCurrentPointsRef, + updateCurrentPointsRef, ); + // Update the ref immediately so next transformation tick uses latest points + if (updateCurrentPointsRef) { + updateCurrentPointsRef(newPoints); + } + // Skip control point transformations on the first tick to avoid jumping if (isFirstTransformTickRef.current) { isFirstTransformTickRef.current = false; @@ -366,8 +377,15 @@ export const VectorTransformer: React.FC = ({ originalPositionsRef.current, transformerCenter, bounds, + getCurrentPointsRef, + updateCurrentPointsRef, ); + // Update the ref immediately so next transformation tick uses latest points + if (updateCurrentPointsRef) { + updateCurrentPointsRef(newPoints); + } + // Apply transformation to control points using RAF if (rafIdRef.current) { cancelAnimationFrame(rafIdRef.current); @@ -412,8 +430,15 @@ export const VectorTransformer: React.FC = ({ originalPositionsRef.current, transformerCenter, bounds, + getCurrentPointsRef, + updateCurrentPointsRef, ); + // Update the ref immediately so next transformation uses latest points + if (updateCurrentPointsRef) { + updateCurrentPointsRef(newPoints); + } + // Apply control point transformations const updatedPoints = applyTransformationToControlPoints( newPoints, @@ -467,7 +492,14 @@ export const VectorTransformer: React.FC = ({ originalPositionsRef.current, transformerCenter, bounds, + getCurrentPointsRef, + updateCurrentPointsRef, ); + + // Update the ref immediately so next transformation uses latest points + if (updateCurrentPointsRef) { + updateCurrentPointsRef(newPoints); + } // Apply control point transformations const isActualRotation = Math.abs(transformer.rotation()) > 1.0; const updatedPoints = applyTransformationToControlPoints( diff --git a/web/libs/editor/src/components/KonvaVector/components/index.ts b/web/libs/editor/src/components/KonvaVector/components/index.ts index 2b25efb435da..206257fd1d10 100644 --- a/web/libs/editor/src/components/KonvaVector/components/index.ts +++ b/web/libs/editor/src/components/KonvaVector/components/index.ts @@ -4,5 +4,5 @@ export { VectorPoints } from "./VectorPoints"; export { ProxyNodes } from "./ProxyNodes"; export { VectorTransformer } from "./VectorTransformer"; export { ControlPoints } from "./ControlPoints"; -export { GhostPoint } from "./GhostPoint"; +export { GhostPoint, type GhostPointRef } from "./GhostPoint"; export { ConnectionLines } from "./ConnectionLines"; diff --git a/web/libs/editor/src/components/KonvaVector/constants.ts b/web/libs/editor/src/components/KonvaVector/constants.ts index d20bdc70b2d0..b914c6eb7a3a 100644 --- a/web/libs/editor/src/components/KonvaVector/constants.ts +++ b/web/libs/editor/src/components/KonvaVector/constants.ts @@ -34,7 +34,7 @@ export const DEFAULT_FILL_COLOR = "rgba(239, 68, 68, 0.3)"; // Default point styling export const DEFAULT_POINT_FILL = "#ffffff"; export const DEFAULT_POINT_STROKE = "#3b82f6"; -export const DEFAULT_POINT_STROKE_SELECTED = "#fbbf24"; +export const DEFAULT_POINT_STROKE_SELECTED = "#ffffff"; export const DEFAULT_POINT_STROKE_WIDTH = 2; // Default point radius values @@ -52,7 +52,7 @@ export const KEYPOINT_POINT_RADIUS = { // Hit detection radii (in pixels) export const HIT_RADIUS = { CONTROL_POINT: 6, - SELECTION: 5, + SELECTION: 20, // Increased from 5 to 20 to make points easier to click SEGMENT: 8, } as const; diff --git a/web/libs/editor/src/components/KonvaVector/eventHandlers/drawing.ts b/web/libs/editor/src/components/KonvaVector/eventHandlers/drawing.ts index 62b296f08cd5..36b9c6ed0736 100644 --- a/web/libs/editor/src/components/KonvaVector/eventHandlers/drawing.ts +++ b/web/libs/editor/src/components/KonvaVector/eventHandlers/drawing.ts @@ -1,6 +1,7 @@ import type { KonvaEventObject } from "konva/lib/Node"; import type { EventHandlerProps } from "./types"; import { PointType } from "../types"; +import { HIT_RADIUS } from "../constants"; import { getDistance, isPointInCanvasBounds, snapToPixel, stageToImageCoordinates } from "./utils"; export interface AddPointOptions { @@ -256,7 +257,7 @@ export function handleShiftClickPointConversion(e: KonvaEventObject, for (let i = 0; i < props.initialPoints.length; i++) { const point = props.initialPoints[i]; const distance = getDistance(imagePos, point); - const hitRadius = 10 / (props.transform.zoom * props.fitScale); + const hitRadius = HIT_RADIUS.SELECTION / (props.transform.zoom * props.fitScale); if (distance <= hitRadius && distance < closestDistance) { closestDistance = distance; diff --git a/web/libs/editor/src/components/KonvaVector/eventHandlers/mouseHandlers.ts b/web/libs/editor/src/components/KonvaVector/eventHandlers/mouseHandlers.ts index 871feda2f2f4..b53c29ea90e4 100644 --- a/web/libs/editor/src/components/KonvaVector/eventHandlers/mouseHandlers.ts +++ b/web/libs/editor/src/components/KonvaVector/eventHandlers/mouseHandlers.ts @@ -8,13 +8,9 @@ import { closePathBetweenFirstAndLast, } from "./drawing"; import { deletePoint } from "../pointManagement"; -import { - handlePointDeselection, - handlePointSelection, - shouldClosePathOnPointClick, - isActivePointEligibleForClosing, -} from "./pointSelection"; +import { handlePointSelection, shouldClosePathOnPointClick, isActivePointEligibleForClosing } from "./pointSelection"; import type { EventHandlerProps } from "./types"; +import { HIT_RADIUS } from "../constants"; import { continueBezierDrag, findClosestPointOnPath, @@ -35,11 +31,12 @@ export function createMouseDownHandler(props: EventHandlerProps, handledSelectio let shiftClickHandled = false; - // Only run Shift+click logic if Shift key is actually held + // Set up ghost point drag info when Shift is held (for UI feedback) + // This works even when internal point addition is disabled if (e.evt.shiftKey === true && props.cursorPosition && props.initialPoints.length >= 2) { // Check if cursor is over an existing point const scale = props.transform.zoom * props.fitScale; - const hitRadius = 10 / scale; + const hitRadius = HIT_RADIUS.SELECTION / scale; let isOverPoint = false; for (let i = 0; i < props.initialPoints.length; i++) { @@ -81,7 +78,8 @@ export function createMouseDownHandler(props: EventHandlerProps, handledSelectio nextPointId = props.initialPoints[closestPathPoint.segmentIndex]?.id || ""; } - // Store ghost point info for potential drag + // Store ghost point info for potential drag (UI feedback) + // This is stored even when internal point addition is disabled props.setGhostPointDragInfo({ ghostPoint: { x: closestPathPoint.point.x, @@ -92,46 +90,25 @@ export function createMouseDownHandler(props: EventHandlerProps, handledSelectio isDragging: false, dragDistance: 0, }); - shiftClickHandled = true; // Mark that Shift+click was handled + + // Only mark as handled if internal point addition is enabled + // This prevents other handlers from interfering when we want to add points + if (!props.disableInternalPointAddition) { + shiftClickHandled = true; // Mark that Shift+click was handled + } } } // If we're over a point while holding Shift, allow normal point interactions to continue } - // Skip the rest of mouse down logic if Shift+click was handled - if (shiftClickHandled) { - return; - } - - // Handle drawing mode setup (only when path is not closed) - if (props.isDrawingMode && !props.isPathClosed) { - // Handle Shift+panning even in drawing mode - if (e.evt.shiftKey) { - props.isDragging.current = true; - props.lastPos.current = { x: e.evt.clientX, y: e.evt.clientY }; - document.body.style.cursor = "grabbing"; - return; - } - - // For drawing mode, we'll wait to see if this becomes a drag - props.lastPos.current = { x: e.evt.clientX, y: e.evt.clientY }; - - // Set up for potential Bezier curve creation (only if not in shift-click mode) - if (!e.evt.shiftKey) { - props.setIsDraggingNewBezier(false); - props.setNewPointDragIndex(null); - } - - // Don't return here - let the handler continue to set up drawing state - } - - // Check if transformer is active first - this blocks most point interactions - if (props.selectedPoints.size > 1) { + // Check if transformer is active first - this blocks drawing and most point interactions + // But allow shift-click for ghost point insertion even when transformer is active + if (props.selectedPoints.size > 1 && !e.evt.shiftKey) { const pos = e.target.getStage()?.getPointerPosition(); if (pos) { const imagePos = stageToImageCoordinates(pos, props.transform, props.fitScale, props.x, props.y); const scale = props.transform.zoom * props.fitScale; - const hitRadius = 10 / scale; + const hitRadius = HIT_RADIUS.SELECTION / scale; // Check if we're clicking on a point for (let i = 0; i < props.initialPoints.length; i++) { @@ -139,23 +116,17 @@ export function createMouseDownHandler(props: EventHandlerProps, handledSelectio const distance = Math.sqrt((imagePos.x - point.x) ** 2 + (imagePos.y - point.y) ** 2); if (distance <= hitRadius) { - // If cmd-click, handle multi-selection management + // If cmd-click, don't handle it here - let the onClick handler on the Circle component handle it + // The onClick handler has the correct pointIndex, while this handler would need to find it by distance + // which could select the wrong point when multiple points are close together if (e.evt.ctrlKey || e.evt.metaKey) { - // Try deselection first - if (handlePointDeselection(e, props)) { - handledSelectionInMouseDown.current = true; - return; - } - // If not deselection, try selection (adding to multi-selection) - if (handlePointSelection(e, props)) { - handledSelectionInMouseDown.current = true; - return; - } - } else { - // Regular click on point when transformer is active - do nothing - // This prevents the click from falling through to deselection logic + // Just mark that we're over a point, but let onClick handle the selection + handledSelectionInMouseDown.current = true; return; } + // Regular click on point when transformer is active - do nothing + // This prevents the click from falling through to deselection logic + return; } } } @@ -165,6 +136,28 @@ export function createMouseDownHandler(props: EventHandlerProps, handledSelectio return; } + // Handle drawing mode setup (only when path is not closed and transformer is not active) + if (props.isDrawingMode && !props.isPathClosed && props.selectedPoints.size <= 1) { + // Handle Shift+panning even in drawing mode + if (e.evt.shiftKey) { + props.isDragging.current = true; + props.lastPos.current = { x: e.evt.clientX, y: e.evt.clientY }; + document.body.style.cursor = "grabbing"; + return; + } + + // For drawing mode, we'll wait to see if this becomes a drag + props.lastPos.current = { x: e.evt.clientX, y: e.evt.clientY }; + + // Set up for potential Bezier curve creation (only if not in shift-click mode) + if (!e.evt.shiftKey) { + props.setIsDraggingNewBezier(false); + props.setNewPointDragIndex(null); + } + + // Don't return here - let the handler continue to set up drawing state + } + // Handle point interactions (selection, dragging) regardless of drawing mode // This allows point interaction even when drawing is disabled due to hovering const pos = e.target.getStage()?.getPointerPosition(); @@ -172,7 +165,7 @@ export function createMouseDownHandler(props: EventHandlerProps, handledSelectio const imagePos = stageToImageCoordinates(pos, props.transform, props.fitScale, props.x, props.y); const scale = props.transform.zoom * props.fitScale; - const hitRadius = 10 / scale; + const hitRadius = HIT_RADIUS.SELECTION / scale; // Check if we're clicking on a point to select or drag it (only when transformer is not active) for (let i = 0; i < props.initialPoints.length; i++) { @@ -180,31 +173,26 @@ export function createMouseDownHandler(props: EventHandlerProps, handledSelectio const distance = Math.sqrt((imagePos.x - point.x) ** 2 + (imagePos.y - point.y) ** 2); if (distance <= hitRadius) { - // If cmd-click, handle selection immediately and don't set up dragging + // If cmd-click, don't handle it here - let the onClick handler on the Circle component handle it + // The onClick handler has the correct pointIndex, while this handler would need to find it by distance + // which could select the wrong point when multiple points are close together if (e.evt.ctrlKey || e.evt.metaKey) { - // Try deselection first - if (handlePointDeselection(e, props)) { - handledSelectionInMouseDown.current = true; - return; - } - // If not deselection, try selection (adding to multi-selection) - if (handlePointSelection(e, props)) { - handledSelectionInMouseDown.current = true; - return; - } - } else { - // Normal click - store the potential drag target but don't start dragging yet - // We'll start dragging only if the mouse moves beyond a threshold - props.setDraggedPointIndex(i); - props.lastPos.current = { - x: e.evt.clientX, - y: e.evt.clientY, - originalX: point.x, - originalY: point.y, - originalControlPoint1: point.isBezier ? point.controlPoint1 : undefined, - originalControlPoint2: point.isBezier ? point.controlPoint2 : undefined, - }; + // Just mark that we're over a point, but let onClick handle the selection + handledSelectionInMouseDown.current = true; + return; } + + // Normal click - store the potential drag target but don't start dragging yet + // We'll start dragging only if the mouse moves beyond a threshold + props.setDraggedPointIndex(i); + props.lastPos.current = { + x: e.evt.clientX, + y: e.evt.clientY, + originalX: point.x, + originalY: point.y, + originalControlPoint1: point.isBezier ? point.controlPoint1 : undefined, + originalControlPoint2: point.isBezier ? point.controlPoint2 : undefined, + }; return; } } @@ -296,11 +284,15 @@ export function createMouseMoveHandler(props: EventHandlerProps, handledSelectio if (!pos) return; // Update cursor position + // Note: cursor position is now handled by stage-level events when disableInternalPointAddition is true const imagePos = stageToImageCoordinates(pos, props.transform, props.fitScale, props.x, props.y); - props.setCursorPosition(imagePos); + // props.setCursorPosition(imagePos); // Removed - handled elsewhere // Set ghost point when Shift is held - snap to path (but not when dragging or creating bezier points) + // When disableInternalPointAddition is true, ghost point is handled by stage-level events + // So we skip this logic to avoid conflicts if ( + !props.disableInternalPointAddition && e.evt.shiftKey && props.cursorPosition && props.initialPoints.length >= 2 && @@ -310,7 +302,7 @@ export function createMouseMoveHandler(props: EventHandlerProps, handledSelectio ) { // Check if cursor is over an existing point const scale = props.transform.zoom * props.fitScale; - const hitRadius = 10 / scale; + const hitRadius = HIT_RADIUS.SELECTION / scale; let isOverPoint = false; for (let i = 0; i < props.initialPoints.length; i++) { @@ -373,8 +365,8 @@ export function createMouseMoveHandler(props: EventHandlerProps, handledSelectio props.setGhostPoint(null); } } - } else if (!e.evt.shiftKey) { - // Clear ghost point when Shift is not held + } else if (!props.disableInternalPointAddition && !e.evt.shiftKey) { + // Clear ghost point when Shift is not held (only when not using stage-level events) props.setGhostPoint(null); } @@ -609,11 +601,12 @@ export function createMouseMoveHandler(props: EventHandlerProps, handledSelectio return; } - // Handle Bezier curve creation in drawing mode (click-drag without shift key) - only when path is not closed + // Handle Bezier curve creation in drawing mode (click-drag without shift key) - only when path is not closed and transformer is not active // Skip if PointCreationManager is currently creating a point if ( props.isDrawingMode && !props.isPathClosed && + props.selectedPoints.size <= 1 && props.lastPos.current && !e.evt.shiftKey && props.allowBezier && @@ -645,6 +638,7 @@ export function createMouseMoveHandler(props: EventHandlerProps, handledSelectio } // Handle shift-click-drag bezier creation (start dragging detection) - only when shift key is held + // Ghost point drag info is tracked for UI feedback even when internal point addition is disabled if (props.ghostPointDragInfo && !props.ghostPointDragInfo.isDragging && e.evt.shiftKey && props.allowBezier) { // Check if we should start dragging (mouse moved enough) const imagePos = stageToImageCoordinates(pos, props.transform, props.fitScale, props.x, props.y); @@ -656,46 +650,50 @@ export function createMouseMoveHandler(props: EventHandlerProps, handledSelectio // Start dragging if we've moved more than 5 pixels if (dragDistance > 5) { - // Create a bezier point at the ghost point location - const ghostPoint = props.ghostPointDragInfo.ghostPoint; - const prevPoint = props.initialPoints.find((p) => p.id === ghostPoint.prevPointId); - const nextPoint = props.initialPoints.find((p) => p.id === ghostPoint.nextPointId); - - if (prevPoint && nextPoint) { - // Snap to pixel grid if enabled - const snappedPos = snapToPixel(imagePos, props.pixelSnapping); - - // Create initial control points - control point 1 will follow cursor, control point 2 will be opposite - const controlPoint1 = { x: snappedPos.x, y: snappedPos.y }; - const controlPoint2 = { - x: ghostPoint.x - (snappedPos.x - ghostPoint.x), - y: ghostPoint.y - (snappedPos.y - ghostPoint.y), - }; + // Only create actual bezier point if internal point addition is enabled + if (!props.disableInternalPointAddition) { + // Create a bezier point at the ghost point location + const ghostPoint = props.ghostPointDragInfo.ghostPoint; + const prevPoint = props.initialPoints.find((p) => p.id === ghostPoint.prevPointId); + const nextPoint = props.initialPoints.find((p) => p.id === ghostPoint.nextPointId); + + if (prevPoint && nextPoint) { + // Snap to pixel grid if enabled + const snappedPos = snapToPixel(imagePos, props.pixelSnapping); + + // Create initial control points - control point 1 will follow cursor, control point 2 will be opposite + const controlPoint1 = { x: snappedPos.x, y: snappedPos.y }; + const controlPoint2 = { + x: ghostPoint.x - (snappedPos.x - ghostPoint.x), + y: ghostPoint.y - (snappedPos.y - ghostPoint.y), + }; - // Insert the bezier point - const result = props.pointCreationManager?.insertPointBetween( - ghostPoint.x, - ghostPoint.y, - prevPoint.id, - nextPoint.id, - PointType.BEZIER, - controlPoint1, - controlPoint2, - ) || { success: false }; - - if (result.success && result.newPointIndex !== undefined) { - // Store the index of the newly created bezier point - props.setNewPointDragIndex(result.newPointIndex); - // Set dragging state for bezier control point manipulation - props.setIsDraggingNewBezier(true); - // Mark that we've handled this interaction to prevent click handler from running - handledSelectionInMouseDown.current = true; - } else { - // Failed to insert bezier point + // Insert the bezier point + const result = props.pointCreationManager?.insertPointBetween( + ghostPoint.x, + ghostPoint.y, + prevPoint.id, + nextPoint.id, + PointType.BEZIER, + controlPoint1, + controlPoint2, + ) || { success: false }; + + if (result.success && result.newPointIndex !== undefined) { + // Store the index of the newly created bezier point + props.setNewPointDragIndex(result.newPointIndex); + // Set dragging state for bezier control point manipulation + props.setIsDraggingNewBezier(true); + // Mark that we've handled this interaction to prevent click handler from running + handledSelectionInMouseDown.current = true; + } else { + // Failed to insert bezier point + } } } - // Update ghost point drag info to indicate we're now dragging + // Update ghost point drag info to indicate we're now dragging (for UI feedback) + // This happens even when internal point addition is disabled props.setGhostPointDragInfo({ ...props.ghostPointDragInfo, isDragging: true, @@ -707,12 +705,15 @@ export function createMouseMoveHandler(props: EventHandlerProps, handledSelectio } } else if (props.ghostPointDragInfo?.isDragging && props.isDraggingNewBezier) { // Continue shift-click-drag bezier point creation - update control points to follow cursor - const imagePos = stageToImageCoordinates(pos, props.transform, props.fitScale, props.x, props.y); - - // Use the shared utility for continuing bezier drag - continueBezierDrag(props); + // Only update actual control points if internal point addition is enabled + if (!props.disableInternalPointAddition) { + const imagePos = stageToImageCoordinates(pos, props.transform, props.fitScale, props.x, props.y); + // Use the shared utility for continuing bezier drag + continueBezierDrag(props); + } - // Update ghost point drag info + // Update ghost point drag info (for UI feedback) + const imagePos = stageToImageCoordinates(pos, props.transform, props.fitScale, props.x, props.y); props.setGhostPointDragInfo({ ...props.ghostPointDragInfo, dragDistance: Math.sqrt( @@ -791,7 +792,8 @@ export function createMouseUpHandler(props: EventHandlerProps) { } // Handle ghost point drag completion - if (wasGhostDrag && props.ghostPointDragInfo?.ghostPoint) { + // Skip if internal point addition is disabled + if (!props.disableInternalPointAddition && wasGhostDrag && props.ghostPointDragInfo?.ghostPoint) { const { ghostPoint, dragDistance } = props.ghostPointDragInfo; // If we were creating a bezier point, it was already created during the drag @@ -857,16 +859,31 @@ export function createClickHandler(props: EventHandlerProps, handledSelectionInM const clickRadius = 15 / (props.transform.zoom * props.fitScale); if (distance <= clickRadius) { - // Insert a regular point between the two points that form the segment - const insertResult = insertPointBetween( - props, - ghostPoint.x, - ghostPoint.y, - ghostPoint.prevPointId, - ghostPoint.nextPointId, - ); - if (insertResult.success) { - return; // Successfully added point + // If internal point addition is disabled, call the callback for programmatic handling + if (props.disableInternalPointAddition && props.onGhostPointClick) { + props.onGhostPointClick({ + x: ghostPoint.x, + y: ghostPoint.y, + prevPointId: ghostPoint.prevPointId, + nextPointId: ghostPoint.nextPointId, + }); + return; // Let parent handle point addition + } + + // Otherwise, insert a regular point internally between the two points that form the segment + if (!props.disableInternalPointAddition) { + const insertResult = insertPointBetween( + props, + ghostPoint.x, + ghostPoint.y, + ghostPoint.prevPointId, + ghostPoint.nextPointId, + ); + if (insertResult.success) { + // Clear ghost point immediately after adding a real point + props.setGhostPoint(null); + return; // Successfully added point + } } } } @@ -880,7 +897,7 @@ export function createClickHandler(props: EventHandlerProps, handledSelectionInM const imagePos = stageToImageCoordinates(pos, props.transform, props.fitScale, props.x, props.y); const scale = props.transform.zoom * props.fitScale; - const hitRadius = 10 / scale; + const hitRadius = HIT_RADIUS.SELECTION / scale; // Check if we clicked on any point to delete it for (let i = 0; i < props.initialPoints.length; i++) { @@ -900,27 +917,59 @@ export function createClickHandler(props: EventHandlerProps, handledSelectionInM props.setLastAddedPointId, props.lastAddedPointId, ); + // Stop event propagation to prevent point addition + e.evt.stopPropagation(); + e.evt.preventDefault(); + e.cancelBubble = true; return; } } - // If we didn't click on a point, check if we clicked on a segment to break the path - if (props.isPathClosed && props.allowClose) { - const segmentHitRadius = 15 / scale; // Slightly larger than point hit radius + // If we didn't click on a point, check if we clicked on a segment to break/delete it + const segmentHitRadius = 15 / scale; // Slightly larger than point hit radius - // Find the closest point on the path - const closestPathPoint = findClosestPointOnPath( - imagePos, - props.initialPoints, - props.allowClose, - props.isPathClosed, - ); + // Find the closest point on the path + const closestPathPoint = findClosestPointOnPath( + imagePos, + props.initialPoints, + props.allowClose, + props.isPathClosed, + ); - if (closestPathPoint && getDistance(imagePos, closestPathPoint.point) <= segmentHitRadius) { - // We clicked on a segment, break the closed path + if (closestPathPoint && getDistance(imagePos, closestPathPoint.point) <= segmentHitRadius) { + // For closed paths, break the path at the segment + if (props.isPathClosed && props.allowClose) { if (breakPathAtSegment(props, closestPathPoint.segmentIndex)) { + e.evt.stopPropagation(); + e.evt.preventDefault(); + e.cancelBubble = true; return; } + } else { + // For unclosed paths, delete the segment by removing the connection + // This splits the path into two separate paths + const segmentIndex = closestPathPoint.segmentIndex; + if (segmentIndex >= 0 && segmentIndex < props.initialPoints.length) { + const pointToBreak = props.initialPoints[segmentIndex]; + if (pointToBreak.prevPointId) { + // Remove the prevPointId to break the connection + const updatedPoints = props.initialPoints.map((point, idx) => { + if (idx === segmentIndex) { + return { + ...point, + prevPointId: undefined, + }; + } + return point; + }); + + props.onPointsChange?.(updatedPoints); + e.evt.stopPropagation(); + e.evt.preventDefault(); + e.cancelBubble = true; + return; + } + } } } } @@ -943,7 +992,7 @@ export function createClickHandler(props: EventHandlerProps, handledSelectionInM const imagePos = stageToImageCoordinates(pos, props.transform, props.fitScale, props.x, props.y); const scale = props.transform.zoom * props.fitScale; - const hitRadius = 10 / scale; + const hitRadius = HIT_RADIUS.SELECTION / scale; // Check if we clicked on any existing point for (let i = 0; i < props.initialPoints.length; i++) { @@ -958,9 +1007,16 @@ export function createClickHandler(props: EventHandlerProps, handledSelectionInM } } - // Handle drawing mode clicks (only when path is not closed) + // Handle drawing mode clicks (only when path is not closed and transformer is not active) // Skip if PointCreationManager is currently creating a point - if (props.isDrawingMode && !props.isPathClosed && !props.pointCreationManager?.isCreating()) { + // Skip if internal point addition is disabled + if ( + !props.disableInternalPointAddition && + props.isDrawingMode && + !props.isPathClosed && + props.selectedPoints.size <= 1 && + !props.pointCreationManager?.isCreating() + ) { // Handle regular click (add regular point) if (handleDrawingModeClick(e, props)) { return; @@ -976,21 +1032,34 @@ export function createClickHandler(props: EventHandlerProps, handledSelectionInM } // Helper function to select a point by index -function handlePointSelectionFromIndex( +export function handlePointSelectionFromIndex( pointIndex: number, props: EventHandlerProps, event: KonvaEventObject, ) { // Check if this is the active point (the one user is currently drawing from) + // Only trigger onFinish if: + // 1. We're in drawing mode (isDrawingMode is true) + // 2. No modifiers are pressed (ctrl, meta, shift, alt) + // 3. Point was already selected before this click (to prevent firing when selecting region) if ( + !props.transformMode && props.activePointId && pointIndex < props.initialPoints.length && !(event.evt.ctrlKey || event.evt.shiftKey || event.evt.metaKey || event.evt.altKey) ) { const point = props.initialPoints[pointIndex]; if (point.id === props.activePointId) { - props.onFinish?.(event!); - return; // Don't proceed with selection + const isDrawingMode = props.isDrawingMode === true; + const wasPointAlreadySelected = props.selectedPoints?.has(pointIndex) ?? false; + + // Only fire onFinish if we're in drawing mode AND point was already selected + // This prevents onFinish from firing when clicking on a point to select the region + if (isDrawingMode && wasPointAlreadySelected) { + props.onFinish?.(event!); + return; // Don't proceed with selection + } + // If not in drawing mode or point wasn't selected, skip onFinish and proceed with selection } } diff --git a/web/libs/editor/src/components/KonvaVector/eventHandlers/pointSelection.ts b/web/libs/editor/src/components/KonvaVector/eventHandlers/pointSelection.ts index b76089995a08..d36b6d377832 100644 --- a/web/libs/editor/src/components/KonvaVector/eventHandlers/pointSelection.ts +++ b/web/libs/editor/src/components/KonvaVector/eventHandlers/pointSelection.ts @@ -1,5 +1,6 @@ import type { KonvaEventObject } from "konva/lib/Node"; import type { EventHandlerProps } from "./types"; +import { HIT_RADIUS } from "../constants"; import { isPointInHitRadius, stageToImageCoordinates } from "./utils"; import { closePathBetweenFirstAndLast } from "./drawing"; import { VectorSelectionTracker } from "../VectorSelectionTracker"; @@ -97,7 +98,7 @@ export function handlePointSelection(e: KonvaEventObject, props: Eve const imagePos = stageToImageCoordinates(pos, props.transform, props.fitScale, props.x, props.y); const scale = props.transform.zoom * props.fitScale; - const hitRadius = 10 / scale; + const hitRadius = HIT_RADIUS.SELECTION / scale; // Get the tracker instance const tracker = VectorSelectionTracker.getInstance(); @@ -137,14 +138,23 @@ export function handlePointSelection(e: KonvaEventObject, props: Eve } // Check if this is the active point (the one user is currently drawing from) - // Only trigger onFinish if no modifiers are pressed (ctrl, meta, shift, alt) and component is not disabled - if (props.activePointId && point.id === props.activePointId && !props.disabled) { + // Only trigger onFinish if: + // 1. We're in drawing mode (isDrawingMode is true) + // 2. No modifiers are pressed (ctrl, meta, shift, alt) + // 3. Component is not disabled + // 4. Point was already selected before this click (to prevent firing when selecting region) + if (props.activePointId && point.id === props.activePointId && !props.disabled && !props.transformMode) { const hasModifiers = e.evt.ctrlKey || e.evt.metaKey || e.evt.shiftKey || e.evt.altKey; - if (!hasModifiers) { + const isDrawingMode = props.isDrawingMode === true; + const wasPointAlreadySelected = props.selectedPoints?.has(i) ?? false; + + // Only fire onFinish if we're in drawing mode AND point was already selected + // This prevents onFinish from firing when clicking on a point to select the region + if (!hasModifiers && isDrawingMode && wasPointAlreadySelected) { props.onFinish?.(e); return true; // Don't proceed with selection } - // If modifiers are held, skip onFinish entirely and let normal modifier handling take over + // If modifiers are held or not in drawing mode or point wasn't selected, skip onFinish return false; } @@ -187,7 +197,7 @@ export function handlePointDeselection(e: KonvaEventObject, props: E const imagePos = stageToImageCoordinates(pos, props.transform, props.fitScale, props.x, props.y); const scale = props.transform.zoom * props.fitScale; - const hitRadius = 10 / scale; + const hitRadius = HIT_RADIUS.SELECTION / scale; // Get the tracker instance const tracker = VectorSelectionTracker.getInstance(); diff --git a/web/libs/editor/src/components/KonvaVector/eventHandlers/types.ts b/web/libs/editor/src/components/KonvaVector/eventHandlers/types.ts index 220a71f3ab1d..f30645665e16 100644 --- a/web/libs/editor/src/components/KonvaVector/eventHandlers/types.ts +++ b/web/libs/editor/src/components/KonvaVector/eventHandlers/types.ts @@ -39,7 +39,7 @@ export interface EventHandlerProps { dragDistance: number; } | null), ) => void; - setCursorPosition: (position: Point | null) => void; + // setCursorPosition: (position: Point | null) => void; // Removed - now handled via ref setVisibleControlPoints: (points: Set | ((prev: Set) => Set)) => void; setIsPathClosed: (closed: boolean) => void; isDragging: React.MutableRefObject; @@ -81,6 +81,7 @@ export interface EventHandlerProps { onPathShapeChanged?: (points: BezierPoint[]) => void; onPointSelected?: (pointIndex: number | null) => void; onFinish?: (e: KonvaEventObject) => void; + onGhostPointClick?: (ghostPoint: { x: number; y: number; prevPointId: string; nextPointId: string }) => void; onMouseDown?: (e: KonvaEventObject) => void; onMouseMove?: (e: KonvaEventObject) => void; onMouseUp?: (e?: KonvaEventObject) => void; @@ -104,6 +105,8 @@ export interface EventHandlerProps { setLastAddedPointId?: (pointId: string | null) => void; isTransforming?: boolean; disabled?: boolean; + transformMode?: boolean; + disableInternalPointAddition?: boolean; pointCreationManager?: { isCreating: () => boolean; createRegularPointAt: (x: number, y: number, prevPointId?: string) => boolean; diff --git a/web/libs/editor/src/components/KonvaVector/pointCreationManager.ts b/web/libs/editor/src/components/KonvaVector/pointCreationManager.ts index 66830c37d2e1..17cc5dae03e7 100644 --- a/web/libs/editor/src/components/KonvaVector/pointCreationManager.ts +++ b/web/libs/editor/src/components/KonvaVector/pointCreationManager.ts @@ -1,6 +1,7 @@ -import type { BezierPoint } from "./types"; +import type { BezierPoint, GhostPoint } from "./types"; import { PointType } from "./types"; -import { snapToPixel } from "./eventHandlers/utils"; +import { HIT_RADIUS } from "./constants"; +import { snapToPixel, getDistance } from "./eventHandlers/utils"; import { generatePointId } from "./utils"; export interface PointCreationState { @@ -18,6 +19,8 @@ export interface PointCreationManagerProps { pixelSnapping?: boolean; width?: number; height?: number; + transform?: { zoom: number; offsetX: number; offsetY: number }; + fitScale?: number; onPointsChange?: (points: BezierPoint[]) => void; onPointAdded?: (point: BezierPoint, index: number) => void; onPointEdited?: (point: BezierPoint, index: number) => void; @@ -30,6 +33,10 @@ export interface PointCreationManagerProps { setVisibleControlPoints?: (points: Set | ((prev: Set) => Set)) => void; setNewPointDragIndex?: (index: number | null) => void; setIsDraggingNewBezier?: (dragging: boolean) => void; + ghostPoint?: GhostPoint | null; + isShiftKeyHeld?: boolean; + setGhostPoint?: (point: GhostPoint | null) => void; + selectedPoints?: Set; } export class PointCreationManager { @@ -55,6 +62,11 @@ export class PointCreationManager { return false; } + // Don't allow drawing when transformer is active (two or more points are selected) + if (this.props.selectedPoints && this.props.selectedPoints.size > 1) { + return false; + } + // Check if we can add more points if (this.props.canAddMorePoints && !this.props.canAddMorePoints()) { return false; @@ -75,6 +87,20 @@ export class PointCreationManager { } } + // Check if hovering over an existing point - if so, don't create a new point + if (this.props.initialPoints.length > 0 && this.props.transform && this.props.fitScale !== undefined) { + const scale = this.props.transform.zoom * this.props.fitScale; + const hitRadius = HIT_RADIUS.SELECTION / scale; + + for (const point of this.props.initialPoints) { + const distance = getDistance(snappedCoords, point); + if (distance <= hitRadius) { + // Hovering over an existing point - don't create a new one + return false; + } + } + } + // Initialize state this.state = { isCreating: true, @@ -93,6 +119,11 @@ export class PointCreationManager { return false; } + // Don't allow drawing when transformer is active (two or more points are selected) + if (this.props.selectedPoints && this.props.selectedPoints.size > 1) { + return false; + } + // Snap to pixel grid if enabled const snappedCoords = snapToPixel({ x, y }, this.props.pixelSnapping); @@ -144,8 +175,29 @@ export class PointCreationManager { return false; } + // Don't allow drawing when transformer is active (two or more points are selected) + if (this.props.selectedPoints && this.props.selectedPoints.size > 1) { + return false; + } + + // Check if Shift is held and we have a ghost point + let finalX = x; + let finalY = y; + let insertBetween = false; + let prevPointId: string | undefined; + let nextPointId: string | undefined; + + if (this.props.isShiftKeyHeld && this.props.ghostPoint) { + // Use ghost point coordinates instead of provided coordinates + finalX = this.props.ghostPoint.x; + finalY = this.props.ghostPoint.y; + insertBetween = true; + prevPointId = this.props.ghostPoint.prevPointId; + nextPointId = this.props.ghostPoint.nextPointId; + } + // Snap to pixel grid if enabled - const snappedCoords = snapToPixel({ x, y }, this.props.pixelSnapping); + const snappedCoords = snapToPixel({ x: finalX, y: finalY }, this.props.pixelSnapping); // Check if we're within canvas bounds (only if bounds checking is enabled) if (this.props.width && this.props.height) { @@ -159,6 +211,44 @@ export class PointCreationManager { } } + // If we need to insert between points (shift-click on segment) + if (insertBetween && prevPointId && nextPointId) { + // Cancel the current creation state + this.state = { + isCreating: false, + startX: 0, + startY: 0, + currentPointIndex: null, + isBezier: false, + hasCreatedPoint: false, + }; + + // Clear dragging state + if (this.props.setNewPointDragIndex) { + this.props.setNewPointDragIndex(null); + } + if (this.props.setIsDraggingNewBezier) { + this.props.setIsDraggingNewBezier(false); + } + + // Insert the point between the two points + const result = this.insertPointBetween( + snappedCoords.x, + snappedCoords.y, + prevPointId, + nextPointId, + PointType.REGULAR, + ); + + // Clear ghost point immediately after adding a real point + if (result.success && this.props.setGhostPoint) { + this.props.setGhostPoint(null); + } + + return result.success; + } + + // Normal point creation flow // If we haven't created a point yet (no point was created during updatePoint), create a regular point if (!this.state.hasCreatedPoint) { this.createRegularPoint(snappedCoords.x, snappedCoords.y); diff --git a/web/libs/editor/src/components/KonvaVector/pointManagement.ts b/web/libs/editor/src/components/KonvaVector/pointManagement.ts index 3dbd183fc72f..1c21710fb0e8 100644 --- a/web/libs/editor/src/components/KonvaVector/pointManagement.ts +++ b/web/libs/editor/src/components/KonvaVector/pointManagement.ts @@ -192,20 +192,21 @@ export const deletePoint = ( // If this point was connected to the deleted point, reconnect it if (point.prevPointId === deletedPoint.id) { - // Find the new previous point (the one that was before the deleted point) - let newPrevPointId: string | undefined; + // Find the new previous point + // CRITICAL: Use the deleted point's prevPointId (the point it was connected FROM) + // This ensures correct reconnection in skeleton mode where branches exist + // For example: C -> E -> F, when deleting E, F should reconnect to C (E's prevPointId) + let newPrevPointId: string | undefined = deletedPoint.prevPointId; + // Edge cases: if (index === 0) { - // If we deleted the first point, this point becomes the new first point + // If we deleted the first point (no prevPointId), this point becomes the new first point + newPrevPointId = undefined; + } else if (!deletedPoint.prevPointId) { + // If deleted point had no prevPointId (was a root point), this point becomes a root newPrevPointId = undefined; - } else if (index === initialPoints.length - 1) { - // If we deleted the last point, this point should connect to the second-to-last point - newPrevPointId = newPoints[newPoints.length - 1]?.id; - } else { - // Find the point that was before the deleted point - const prevPoint = initialPoints[index - 1]; - newPrevPointId = prevPoint.id; } + // Otherwise, use deletedPoint.prevPointId which is correct for both linear and skeleton modes newPoints[i] = { ...point, diff --git a/web/libs/editor/src/components/KonvaVector/types.ts b/web/libs/editor/src/components/KonvaVector/types.ts index 0ee8ebbb0626..536a88170938 100644 --- a/web/libs/editor/src/components/KonvaVector/types.ts +++ b/web/libs/editor/src/components/KonvaVector/types.ts @@ -94,6 +94,8 @@ export interface KonvaVectorRef { }; // Hit testing method isPointOverShape: (x: number, y: number, hitRadius?: number) => boolean; + // Delete multiple points by their IDs + deletePointsByIds: (pointIds: string[]) => void; } /** @@ -134,6 +136,8 @@ export interface KonvaVectorProps { onPointSelected?: (pointIndex: number | null) => void; /** Called when drawing is finished (click on last point or double click on empty space) */ onFinish?: (e: KonvaEventObject) => void; + /** Called when shift-clicking on a ghost point (for programmatic point insertion when disableInternalPointAddition is true) */ + onGhostPointClick?: (ghostPoint: { x: number; y: number; prevPointId: string; nextPointId: string }) => void; /** Canvas width */ width: number; /** Canvas height */ @@ -146,6 +150,8 @@ export interface KonvaVectorProps { x: number; /** Y offset */ y: number; + /** Disable ghost line rendering */ + disableGhostLine?: boolean; /** Enable image smoothing */ imageSmoothingEnabled?: boolean; @@ -203,8 +209,11 @@ export interface KonvaVectorProps { onClick?: (e: KonvaEventObject) => void; /** Double click event handler */ onDblClick?: (e: KonvaEventObject) => void; - /** Transform end event handler */ - onTransformEnd?: (e: KonvaEventObject) => void; + /** Called when transformation starts (point dragging, multi-point transformation, etc.) */ + onTransformStart?: () => void; + /** Called when transformation ends (point dragging, multi-point transformation, etc.) */ + /** Can be called with an optional event parameter for backward compatibility */ + onTransformEnd?: (e?: KonvaEventObject) => void; /** Mouse enter event handler */ onMouseEnter?: (e: KonvaEventObject) => void; /** Mouse leave event handler */ @@ -215,6 +224,8 @@ export interface KonvaVectorProps { transformMode?: boolean; /** Whether multiple regions are currently selected (disables internal transformer) */ isMultiRegionSelected?: boolean; + /** Disable internal point addition - when true, prevents KonvaVector from adding points internally and disables the invisible shape */ + disableInternalPointAddition?: boolean; /** Name attribute for the component */ name?: string; /** Ref to access component methods */ diff --git a/web/libs/editor/src/components/KonvaVector/utils.ts b/web/libs/editor/src/components/KonvaVector/utils.ts index 2a66ac165dcc..6be6cbb6d1e6 100644 --- a/web/libs/editor/src/components/KonvaVector/utils.ts +++ b/web/libs/editor/src/components/KonvaVector/utils.ts @@ -90,7 +90,7 @@ export const isSimplePoint = (point: PointInput): point is SimplePoint => { * @returns True if point is a BezierPoint object */ export const isBezierPoint = (point: PointInput): point is BezierPoint => { - return typeof point === "object" && point !== null && "x" in point && "y" in point && "id" in point; + return typeof point === "object" && point !== null && "x" in point && "y" in point; }; /** @@ -121,13 +121,14 @@ export const normalizePoints = (points: PointInput[]): BezierPoint[] => { // Already in BezierPoint format, ensure it has proper prevPointId const newPoint = { ...point, + id: point.id || generatePointId(), prevPointId: point.prevPointId || (index > 0 ? lastPointId : undefined), }; lastPointId = newPoint.id; return newPoint; } // Fallback for any other format - throw new Error(`Invalid point format at index ${index}`); + throw new Error(`Invalid point format at index ${index}: ${JSON.stringify(point)}`); }); }; diff --git a/web/libs/editor/src/components/KonvaVector/utils/transformUtils.ts b/web/libs/editor/src/components/KonvaVector/utils/transformUtils.ts index 50fa5c5390f2..f536e5ae2e8e 100644 --- a/web/libs/editor/src/components/KonvaVector/utils/transformUtils.ts +++ b/web/libs/editor/src/components/KonvaVector/utils/transformUtils.ts @@ -139,7 +139,7 @@ export function updateOriginalPositions( export function applyTransformationToPoints( transformer: Konva.Transformer, initialPoints: BezierPoint[], - proxyRefs?: React.MutableRefObject<{ [key: number]: Konva.Rect | null }>, + proxyRefs?: React.MutableRefObject<{ [key: number]: Konva.Circle | null }>, updateControlPoints = true, originalPositions?: { [key: number]: { @@ -151,9 +151,15 @@ export function applyTransformationToPoints( }, transformerCenter?: { x: number; y: number }, bounds?: { width: number; height: number }, + getCurrentPointsRef?: () => BezierPoint[], + updateCurrentPointsRef?: (points: BezierPoint[]) => void, ): TransformResult { const nodes = transformer.nodes(); - const newPoints = [...initialPoints]; + + // Use current points ref if available, otherwise use initialPoints prop + // This ensures we always use the latest points during transformation + const currentPoints = getCurrentPointsRef ? getCurrentPointsRef() : initialPoints; + const newPoints = currentPoints.map((point) => ({ ...point })); // Create new objects to avoid mutation // Safety check - ensure we have valid nodes if (!nodes || nodes.length === 0) { @@ -175,7 +181,8 @@ export function applyTransformationToPoints( const pointIndex = Number.parseInt(node.name().split("-")[1]); // proxy-{index} const point = newPoints[pointIndex]; - const originalPoint = initialPoints[pointIndex]; + // Use currentPoints to get original point, not initialPoints prop (which might be stale) + const originalPoint = currentPoints[pointIndex]; if (point && originalPoint) { // Get the node's transformed position - trust the transformer diff --git a/web/libs/editor/src/core/settings/keymap.json b/web/libs/editor/src/core/settings/keymap.json index be242317c85b..0154791bb172 100644 --- a/web/libs/editor/src/core/settings/keymap.json +++ b/web/libs/editor/src/core/settings/keymap.json @@ -76,16 +76,6 @@ "description": "Redo" }, - "vector:undo": { - "key": "ctrl+z", - "mac": "command+z", - "description": "Undo" - }, - "vector:redo": { - "key": "ctrl+shift+z", - "mac": "command+shift+z", - "description": "Redo" - }, "region:delete-all": { "key": "ctrl+backspace", "mac": "command+backspace", diff --git a/web/libs/editor/src/regions/VectorRegion.jsx b/web/libs/editor/src/regions/VectorRegion.jsx index 967455fd80fe..dcd3f54c90f8 100644 --- a/web/libs/editor/src/regions/VectorRegion.jsx +++ b/web/libs/editor/src/regions/VectorRegion.jsx @@ -58,7 +58,7 @@ const Model = types // There are two modes: transform and edit // transform -- user can transform the shape as a whole (rotate, translate, resize) // edit -- user works with individual points - transformMode: true, + transformMode: false, }) .volatile(() => ({ mouseOverStartPoint: false, @@ -233,7 +233,7 @@ const Model = types _selectArea(additiveMode = false) { const annotation = self.annotation; - self.setTransformMode(true); + self.setTransformMode(false); if (!annotation) return; if (additiveMode) { @@ -333,15 +333,6 @@ const Model = types }); }, - isHovered() { - const stage = self.groupRef.getStage(); - const pointer = stage.getPointerPosition(); - - // Convert to pixel coords in the canvas backing the image - const { x, y } = self.parent?.layerZoomScalePosition ?? { x: 0, y: 0 }; - return self.vectorRef.isPointOverShape(pointer.x, pointer.y); - }, - // Checks is the region is being transformed or at least in // transformable state (has at least 2 points selected) isTransforming() { @@ -438,6 +429,43 @@ const Model = types self.vectorRef = ref; }, + /** + * Override selectRegion to reset transform mode when selecting from sidebar + * This ensures transform mode is reset whether selecting by clicking on the shape + * or selecting from the sidebar/outliner + */ + selectRegion() { + // Reset transform mode when region is selected (from sidebar or elsewhere) + self.setTransformMode(false); + // Call parent selectRegion to handle scrolling + self.scrollToRegion(); + }, + + addPoint(x, y) { + const image = self.parent.currentImageEntity; + const width = image.naturalWidth; + const height = image.naturalHeight; + + const realX = (x / 100) * width; + const realY = (y / 100) * height; + + if (!self.vectorRef) { + return; + } + if (self.closed) { + return; + } + + // Use KonvaVector's programmatic point creation methods + // Start a point, then immediately commit it to create a regular point + const startResult = self.vectorRef.startPoint(realX, realY); + if (startResult) { + const commitResult = self.vectorRef.commitPoint(realX, realY); + return commitResult; + } + return null; + }, + // Uses KonvaVector startPoint to start drawing // This will only initiate point drawing, but won't create actual point startPoint(x, y) { @@ -469,7 +497,7 @@ const Model = types const annotation = self.parent?.annotation; annotation?.toggleRegionSelection(self); } - tool?.complete(); + tool?.complete?.(); }, toggleTransformMode() { self.setTransformMode(!self.transformMode); @@ -497,6 +525,49 @@ const Model = types console.error("📊 commitMultiRegionTransform method not available"); } }, + + /** + * Override deleteRegion to handle selected points deletion + * If points are selected (but not all), delete only those points + * If all points are selected or none, delete the entire region + * If region is part of multi-selection, always delete the entire region + */ + deleteRegion() { + // Check if this region is part of multi-selection + // If so, always delete the entire region (don't check for selected points) + const isMultiRegionSelected = self.object?.selectedRegions?.length > 1; + + if (!isMultiRegionSelected) { + // Only check for selected points if NOT part of multi-selection + // Check if we have selected points and if vectorRef is available + if (self.vectorRef && typeof self.vectorRef.getSelectedPointIds === "function") { + const selectedPointIds = self.vectorRef.getSelectedPointIds(); + const totalPoints = self.vertices.length; + + // If we have selected points AND not all points are selected, delete only those points + if (selectedPointIds.length > 0 && selectedPointIds.length < totalPoints) { + // Delete only the selected points + if (typeof self.vectorRef.deletePointsByIds === "function") { + self.vectorRef.deletePointsByIds(selectedPointIds); + return; // Don't delete the entire region + } + } + // Otherwise, fall through to delete the entire region + } + } + + // Delete the entire region (original behavior) + // Call parent deleteRegion from KonvaRegionMixin + const selectedTool = self.parent?.getToolsManager().findSelectedTool(); + selectedTool?.enable?.(); + // Call the parent deleteRegion which eventually calls annotation.deleteRegion(self) + // We need to call it through the mixin chain + if (self.annotation.isReadOnly()) return; + if (self.isReadOnly()) return; + if (self.selected) self.annotation.unselectAll(true); + if (self.destroyRegion) self.destroyRegion(); + self.annotation.deleteRegion(self); + }, }; }); @@ -528,6 +599,10 @@ const HtxVectorView = observer(({ item, suggestion }) => { return null; } + // Check if move tool is selected (disable ghost line when move tool is active) + const selectedTool = item.parent?.getToolsManager()?.findSelectedTool(); + const disableGhostLine = selectedTool?.fullName === "MoveTool"; + return ( item.segGroupRef(ref)} name={item.id}> @@ -535,12 +610,23 @@ const HtxVectorView = observer(({ item, suggestion }) => { ref={(kv) => item.setKonvaVectorRef(kv)} initialPoints={Array.from(item.vertices)} isMultiRegionSelected={item.object?.selectedRegions?.length > 1} + disableGhostLine={disableGhostLine} onFinish={(e) => { + console.log("on finish"); + if (disabled) return; e.evt.stopPropagation(); e.evt.preventDefault(); item.handleFinish(); }} + onTransformStart={() => { + item.parent.annotation.history.freeze(); + }} onTransformEnd={(e) => { + item.parent.annotation.history.unfreeze(); + + // Handle case where event might be undefined (e.g., from onTransformationEnd) + if (!e || !e.target || !e.currentTarget) return; + if (e.target !== e.currentTarget) return; const t = e.target; @@ -624,10 +710,25 @@ const HtxVectorView = observer(({ item, suggestion }) => { onPathClosedChange={(isClosed) => { item.onPathClosedChange(isClosed); }} + onGhostPointClick={(ghostPoint) => { + // Only handle if we're drawing + if (!item.isDrawing) { + return; + } + + if (item.vectorRef) { + // Start and immediately commit to insert the point at ghost location + const startResult = item.vectorRef.startPoint(ghostPoint.x, ghostPoint.y); + if (startResult) { + item.vectorRef.commitPoint(ghostPoint.x, ghostPoint.y); + } + } + }} onClick={(e) => { if (e.evt.defaultPrevented) { return; } + // Handle region selection if (item.isReadOnly()) return; if (item.parent.getSkipInteractions()) return; @@ -663,7 +764,6 @@ const HtxVectorView = observer(({ item, suggestion }) => { e.evt.preventDefault(); item.toggleTransformMode(); }} - transformMode={!disabled && item.transformMode} closed={item.closed} width={stageWidth} height={stageHeight} @@ -671,6 +771,7 @@ const HtxVectorView = observer(({ item, suggestion }) => { scaleY={item.parent.stageZoom} x={0} y={0} + transformMode={item.selected && item.transformMode} transform={{ zoom: item.parent.stageZoom, offsetX, offsetY }} fitScale={item.parent.zoomScale} allowClose={item.control?.closable ?? false} @@ -679,7 +780,7 @@ const HtxVectorView = observer(({ item, suggestion }) => { maxPoints={item.maxPoints} skeletonEnabled={item.control?.skeleton ?? false} stroke={item.selected ? "#ff0000" : regionStyles.strokeColor} - fill={item.selected ? "rgba(255, 0, 0, 0.3)" : regionStyles.fillColor} + fill={regionStyles.fillColor} strokeWidth={regionStyles.strokeWidth} opacity={Number.parseFloat(item.control?.opacity || "1")} pixelSnapping={item.control?.snap === "pixel"} @@ -690,6 +791,7 @@ const HtxVectorView = observer(({ item, suggestion }) => { pointStroke={item.selected ? "#ff0000" : regionStyles.strokeColor} pointStrokeSelected="#ff6b35" pointStrokeWidth={item.selected ? 2 : 1} + disableInternalPointAddition={true} /> {item.vertices.length > 0 && ( diff --git a/web/libs/editor/src/tags/control/Vector.js b/web/libs/editor/src/tags/control/Vector.js index f3d7b73b4010..fa417a80adb1 100644 --- a/web/libs/editor/src/tags/control/Vector.js +++ b/web/libs/editor/src/tags/control/Vector.js @@ -79,32 +79,7 @@ const Model = types }) .volatile(() => ({ toolNames: ["Vector"], - })) - .actions((self) => { - return { - initializeHotkeys() { - hotkeys.addNamed("vector:undo", () => { - if (self.annotation?.selected && self.annotation.isDrawing) self.annotation.undo(); - }); - hotkeys.addNamed("vector:redo", () => { - if (self.annotation?.selected && self.annotation.isDrawing) self.annotation.redo(); - }); - }, - - disposeHotkeys() { - hotkeys.removeNamed("vector:undo"); - hotkeys.removeNamed("vector:redo"); - }, - - afterCreate() { - self.initializeHotkeys(); - }, - - beforeDestroy() { - self.disposeHotkeys(); - }, - }; - }); + })); const VectorModel = types.compose( "VectorModel", diff --git a/web/libs/editor/src/tools/Vector.js b/web/libs/editor/src/tools/Vector.js index 3e45cdb9157e..750036d7e243 100644 --- a/web/libs/editor/src/tools/Vector.js +++ b/web/libs/editor/src/tools/Vector.js @@ -65,26 +65,140 @@ const _Tool = types return !self.current() && Super.isIncorrectLabel(); }, canStart() { - return self.current() === null; + // Allow starting if no current region, OR if current region is closed (finished) + const currentRegion = self.current(); + return currentRegion === null || (currentRegion && currentRegion.closed); }, current() { + // First check self.currentArea + if (self.currentArea) { + return self.getActiveVector; + } + + // If currentArea is null, try to find an active drawing vector region + // This handles the case when continuing to draw an existing region + const obj = self.obj; + + // Try obj.regs first + let regionsToSearch = []; + if (obj?.regs && obj.regs.length > 0) { + regionsToSearch = Array.from(obj.regs); + } else if (self.annotation?.regions && self.annotation.regions.length > 0) { + regionsToSearch = Array.from(self.annotation.regions); + } + + if (regionsToSearch.length > 0) { + // Priority 1: Check for highlighted/selected vector region that's not closed + const highlighted = self.annotation?.regionStore?.selection?.highlighted; + if (highlighted && highlighted.type === "vectorregion" && !highlighted.closed && isAlive(highlighted)) { + return highlighted; + } + + // Priority 2: Check selected regions - if only one vector region is selected and not closed + const selectedRegions = self.annotation?.selectedRegions || []; + const selectedVectorRegions = selectedRegions.filter( + (reg) => reg.type === "vectorregion" && !reg.closed && isAlive(reg), + ); + if (selectedVectorRegions.length === 1) { + return selectedVectorRegions[0]; + } + + // Priority 3: Try to find a region that's actively drawing + // Only allow continuing to draw if the region is actively being drawn (isDrawing: true) + // This prevents drawing on unselected regions that are just not closed + const activeDrawingVector = regionsToSearch.find( + (reg) => reg.type === "vectorregion" && reg.isDrawing && !reg.closed && isAlive(reg), + ); + + if (activeDrawingVector) { + return activeDrawingVector; + } + } + return self.getActiveVector; }, + + getCurrentArea() { + // Override to use current() which finds regions even when self.currentArea is null + const currentRegion = self.current(); + if (currentRegion) { + return currentRegion; + } + // Fallback to parent implementation + return self.currentArea; + }, }; }) .actions((self) => { + // Store the MultipleClicksDrawingTool's canStartDrawing before we override it + const MultipleClicksCanStartDrawing = self.canStartDrawing; + const Super = { startDrawing: self.startDrawing, _finishDrawing: self._finishDrawing, deleteRegion: self.deleteRegion, + event: self.event, }; const disposers = []; let down = false; let initialCursorPosition = null; + let lastClick = { + ts: 0, + x: 0, + y: 0, + }; return { + // Override event() to allow shift-key events through for ghost point insertion + event(name, ev, [x, y, canvasX, canvasY]) { + // For Vector tool, allow shift-key events to pass through + // This enables shift-click for inserting points on segments + if (ev.button > 0) return; // Still filter right clicks and middle clicks + + let fn = `${name}Ev`; + + if (typeof self[fn] !== "undefined") self[fn].call(self, ev, [x, y], [canvasX, canvasY]); + + // Emulating of dblclick event + if (name === "click") { + const ts = ev.timeStamp; + + if (ts - lastClick.ts < 300 && self.comparePointsWithThreshold(lastClick, { x, y })) { + fn = `dbl${fn}`; + if (typeof self[fn] !== "undefined") self[fn].call(self, ev, [x, y], [canvasX, canvasY]); + } + lastClick = { ts, x, y }; + } + }, + canStartDrawing() { + // Override to allow continuing to draw on selected/highlighted regions even if there's a selection + // This is Vector-specific behavior - other tools should use the default behavior from MultipleClicksDrawingTool + // First call the MultipleClicksDrawingTool's canStartDrawing (which includes selection check) + const mixinResult = MultipleClicksCanStartDrawing(); + + // If mixin allows drawing, we're good + if (mixinResult) return true; + + // Otherwise, check if we have a current drawing region that should allow continuing + const currentRegion = self.current(); + const hasCurrentDrawing = currentRegion && (currentRegion.isDrawing || !currentRegion.closed); + + // Allow continuing to draw if there's a current drawing region, even with selection + if (hasCurrentDrawing) { + // Still need to check base conditions + return ( + !self.disabled && + !self.isIncorrectControl() && + !self.isIncorrectLabel() && + self.canStart() && + !self.annotation.isDrawing + ); + } + + return false; + }, handleToolSwitch(tool) { self.stopListening(); if (self.getCurrentArea()?.isDrawing && tool.toolName !== "ZoomPanTool") { @@ -113,11 +227,6 @@ const _Tool = types } }, - clickEv() { - // override parent method - return; - }, - realCoordsFromCursor(x, y) { const image = self.obj.currentImageEntity; const width = image.naturalWidth; @@ -136,9 +245,35 @@ const _Tool = types initialCursorPosition = { x: rx, y: ry }; - const area = self.getCurrentArea(); + // Try to find existing drawing region first + let area = self.getCurrentArea(); + + // If no currentArea but there's an active drawing region, use it + if (!area) { + const obj = self.obj; + if (obj && obj.regs) { + const activeDrawingVector = obj.regs.find( + (reg) => reg.type === "vectorregion" && reg.isDrawing && !reg.closed && isAlive(reg), + ); + if (activeDrawingVector) { + area = activeDrawingVector; + self.currentArea = area; + } + } + } + const currentArea = area && isAlive(area) ? area : null; - self.currentArea = currentArea ?? self.createRegion(self.createRegionOptions(), true); + + // Only create new region if we don't have an existing one + if (!currentArea) { + self.currentArea = self.createRegion(self.createRegionOptions(), true); + } else { + self.currentArea = currentArea; + // If reusing an existing region, make sure it's marked as drawing + if (!currentArea.isDrawing) { + currentArea.setDrawing(true); + } + } self.mode = "drawing"; self.setDrawing(true); @@ -148,11 +283,15 @@ const _Tool = types // Start listening for path closure self.listenForClose(); - // we must skip one frame before starting a line - // to make sure KonvaVector was fully initialized - setTimeout(() => { - self.currentArea.startPoint(rx, ry); - }); + // Only call startPoint if this is a new region (no existing points) + // If continuing an existing region, we'll just add points via addPoint + if (!currentArea || currentArea.vertices.length === 0) { + // we must skip one frame before starting a line + // to make sure KonvaVector was fully initialized + setTimeout(() => { + self.currentArea.startPoint(rx, ry); + }); + } }, mousedownEv(e, [x, y]) { @@ -193,6 +332,8 @@ const _Tool = types _finishDrawing() { const { currentArea, control } = self; + if (currentArea === null) return; + down = false; self.currentArea?.notifyDrawingFinished(); self.setDrawing(false); @@ -220,7 +361,29 @@ const _Tool = types // Add point to current vector addPoint(x, y) { - // KonvaVector handles point addition itself + // Convert from percentage (0-100) to real coordinates using the same formula as startDrawing + const { x: rx, y: ry } = self.realCoordsFromCursor(x, y); + + // Try to find the area - first check getCurrentArea, then look in annotation store + let area = self.getCurrentArea(); + + // If no currentArea but there's an active drawing region, use it + if (!area) { + const obj = self.obj; + if (obj && obj.regs) { + const activeDrawingVector = obj.regs.find( + (reg) => reg.type === "vectorregion" && reg.isDrawing && !reg.closed && isAlive(reg), + ); + if (activeDrawingVector) { + area = activeDrawingVector; + self.currentArea = area; + } + } + } + + if (area) { + area.addPoint(rx, ry); + } }, // Finish drawing the current vector