From 65b2aa42bf6284ff7312c2e2fc654a80da8dfcf7 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Thu, 9 Oct 2025 18:37:56 +0100 Subject: [PATCH 01/32] Remove cmd-click on a shape to select everything --- .../components/KonvaVector/KonvaVector.tsx | 23 ------------------- 1 file changed, 23 deletions(-) diff --git a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx index 4061d59d92c4..8f4426e57cb8 100644 --- a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx +++ b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx @@ -1522,19 +1522,6 @@ export const KonvaVector = forwardRef((props, transform={transform} fitScale={fitScale} onClick={(e) => { - // Handle cmd-click to select all points - if ((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 - } - - // Select all points in the path - const allPointIndices = Array.from({ length: initialPoints.length }, (_, i) => i); - tracker.selectPoints(instanceId, new Set(allPointIndices)); - return; - } - // Check if click is on the last added point by checking cursor position if (cursorPosition && lastAddedPointId) { const lastAddedPoint = initialPoints.find((p) => p.id === lastAddedPointId); @@ -1657,16 +1644,6 @@ export const KonvaVector = forwardRef((props, } } - // Handle cmd-click to select all points - if ((e.evt.ctrlKey || e.evt.metaKey) && !e.evt.altKey && !e.evt.shiftKey) { - // Select all points in the path - const allPointIndices = Array.from({ length: initialPoints.length }, (_, i) => i); - tracker.selectPoints(instanceId, new Set(allPointIndices)); - pointSelectionHandled.current = true; // Mark that we handled selection - e.evt.stopImmediatePropagation(); // Prevent all other handlers from running - return; - } - // Check if this is the last added point and already selected (second click) const isLastAddedPoint = lastAddedPointId && initialPoints[pointIndex]?.id === lastAddedPointId; const isAlreadySelected = selectedPoints.has(pointIndex); From 34f2f87011cb6e166427f2839257d13ec0aa8cc9 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Thu, 9 Oct 2025 19:02:19 +0100 Subject: [PATCH 02/32] Support double click --- .../components/KonvaVector/KonvaVector.tsx | 130 ++++++++++++++---- .../KonvaVector/components/VectorShape.tsx | 6 +- .../src/components/KonvaVector/constants.ts | 1 + .../src/components/KonvaVector/types.ts | 2 + web/libs/editor/src/regions/VectorRegion.jsx | 3 + 5 files changed, 115 insertions(+), 27 deletions(-) diff --git a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx index 8f4426e57cb8..37dd04aa9cd9 100644 --- a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx +++ b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx @@ -35,6 +35,7 @@ import { HIT_RADIUS, TRANSFORMER_SETUP_DELAY, TRANSFORMER_CLEAR_DELAY, + CLICK_DELAY, MIN_POINTS_FOR_CLOSING, MIN_POINTS_FOR_BEZIER_CLOSING, INVISIBLE_SHAPE_OPACITY, @@ -229,6 +230,7 @@ export const KonvaVector = forwardRef((props, onMouseMove, onMouseUp, onClick, + onDblClick, onMouseEnter, onMouseLeave, allowClose = false, @@ -341,6 +343,21 @@ export const KonvaVector = forwardRef((props, // Flag to track if point selection was handled in VectorPoints onClick const pointSelectionHandled = useRef(false); + // Flag to track if VectorShape already handled the click/double-click + const shapeHandledClick = useRef(false); + + // Timeout ref for delayed click execution (to prevent clicks during double-click) + const clickTimeoutRef = useRef | null>(null); + + // Cleanup click timeout on unmount + useEffect(() => { + return () => { + if (clickTimeoutRef.current) { + clearTimeout(clickTimeoutRef.current); + } + }; + }, []); + // Initialize PointCreationManager instance const pointCreationManager = useMemo(() => new PointCreationManager(), []); @@ -1494,7 +1511,42 @@ export const KonvaVector = forwardRef((props, pointSelectionHandled.current = false; return; } - eventHandlers.handleLayerClick(e); + + // Skip if VectorShape already handled the click + if (shapeHandledClick.current) { + shapeHandledClick.current = false; + return; + } + + // Clear any existing click timeout + if (clickTimeoutRef.current) { + clearTimeout(clickTimeoutRef.current); + } + + // Delay click execution to check if it's part of a double-click + clickTimeoutRef.current = setTimeout(() => { + eventHandlers.handleLayerClick(e); + }, CLICK_DELAY); + } + } + onDblClick={ + disabled + ? undefined + : (e) => { + // Skip if VectorShape already handled the double-click + if (shapeHandledClick.current) { + shapeHandledClick.current = false; + return; + } + + // Clear the pending click timeout to prevent single click from executing + if (clickTimeoutRef.current) { + clearTimeout(clickTimeoutRef.current); + clickTimeoutRef.current = null; + } + + // Execute double click handler + onDblClick?.(e); } } > @@ -1522,38 +1574,64 @@ export const KonvaVector = forwardRef((props, transform={transform} fitScale={fitScale} onClick={(e) => { - // Check if click is on the last added point by checking cursor position - if (cursorPosition && 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 distance = Math.sqrt( - (cursorPosition.x - lastAddedPoint.x) ** 2 + (cursorPosition.y - lastAddedPoint.y) ** 2, - ); - - if (distance <= hitRadius) { - // Find the index of the last added point - const lastAddedPointIndex = initialPoints.findIndex((p) => p.id === lastAddedPointId); - - // 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 && selectedPoints.has(lastAddedPointIndex) && !disabled) { - const hasModifiers = e.evt.ctrlKey || e.evt.metaKey || e.evt.shiftKey || e.evt.altKey; - if (!hasModifiers) { - e.evt.preventDefault(); - onFinish?.(e); + // Mark that VectorShape handled the click + shapeHandledClick.current = true; + + // Clear any existing click timeout + if (clickTimeoutRef.current) { + clearTimeout(clickTimeoutRef.current); + } + + // Delay click execution to check if it's part of a double-click + clickTimeoutRef.current = setTimeout(() => { + // Check if click is on the last added point by checking cursor position + if (cursorPosition && 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 distance = Math.sqrt( + (cursorPosition.x - lastAddedPoint.x) ** 2 + (cursorPosition.y - lastAddedPoint.y) ** 2, + ); + + if (distance <= hitRadius) { + // Find the index of the last added point + const lastAddedPointIndex = initialPoints.findIndex((p) => p.id === lastAddedPointId); + + // 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 && selectedPoints.has(lastAddedPointIndex) && !disabled) { + const hasModifiers = e.evt.ctrlKey || e.evt.metaKey || e.evt.shiftKey || e.evt.altKey; + if (!hasModifiers) { + onFinish?.(e); + 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; } } } + + // Call the event handler (for drawing/interaction logic) + eventHandlers.handleLayerClick(e); + + // Call the original onClick handler (custom callback) + onClick?.(e); + }, CLICK_DELAY); + }} + onDblClick={(e) => { + // Mark that VectorShape handled the double-click + shapeHandledClick.current = true; + + // Clear the pending click timeout to prevent single click from executing + if (clickTimeoutRef.current) { + clearTimeout(clickTimeoutRef.current); + clickTimeoutRef.current = null; } - // Call the original onClick handler - onClick?.(e); + // Execute double click handler + onDblClick?.(e); }} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} diff --git a/web/libs/editor/src/components/KonvaVector/components/VectorShape.tsx b/web/libs/editor/src/components/KonvaVector/components/VectorShape.tsx index deace4ab7ceb..3a92e3df93e0 100644 --- a/web/libs/editor/src/components/KonvaVector/components/VectorShape.tsx +++ b/web/libs/editor/src/components/KonvaVector/components/VectorShape.tsx @@ -4,7 +4,7 @@ import type { BezierPoint } from "../types"; import chroma from "chroma-js"; import type { KonvaEventObject } from "konva/lib/Node"; -interface VectorShapeProps { +export interface VectorShapeProps { segments: Array<{ from: BezierPoint; to: BezierPoint }>; allowClose?: boolean; isPathClosed?: boolean; @@ -15,6 +15,7 @@ interface VectorShapeProps { transform?: { zoom: number; offsetX: number; offsetY: number }; fitScale?: number; onClick?: (e: KonvaEventObject) => void; + onDblClick?: (e: KonvaEventObject) => void; onMouseEnter?: (e: any) => void; onMouseLeave?: (e: any) => void; } @@ -212,6 +213,7 @@ export const VectorShape: React.FC = ({ transform = { zoom: 1, offsetX: 0, offsetY: 0 }, fitScale = 1, onClick, + onDblClick, onMouseEnter, onMouseLeave, }) => { @@ -270,6 +272,7 @@ export const VectorShape: React.FC = ({ fill={undefined} // No fill for individual segments hitStrokeWidth={20} onClick={onClick} + onDblClick={onDblClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} /> @@ -299,6 +302,7 @@ export const VectorShape: React.FC = ({ fill={fillWithOpacity} hitStrokeWidth={20} onClick={onClick} + onDblClick={onDblClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} /> diff --git a/web/libs/editor/src/components/KonvaVector/constants.ts b/web/libs/editor/src/components/KonvaVector/constants.ts index d20bdc70b2d0..005541ddf798 100644 --- a/web/libs/editor/src/components/KonvaVector/constants.ts +++ b/web/libs/editor/src/components/KonvaVector/constants.ts @@ -59,6 +59,7 @@ export const HIT_RADIUS = { // Timing constants export const TRANSFORMER_SETUP_DELAY = 0; export const TRANSFORMER_CLEAR_DELAY = 10; +export const CLICK_DELAY = 200; // Delay for distinguishing click from double-click // Point count constraints export const MIN_POINTS_FOR_CLOSING = 2; diff --git a/web/libs/editor/src/components/KonvaVector/types.ts b/web/libs/editor/src/components/KonvaVector/types.ts index a336c8df3d77..075df1b082c2 100644 --- a/web/libs/editor/src/components/KonvaVector/types.ts +++ b/web/libs/editor/src/components/KonvaVector/types.ts @@ -201,6 +201,8 @@ export interface KonvaVectorProps { onMouseUp?: (e?: KonvaEventObject) => void; /** Click event handler */ onClick?: (e: KonvaEventObject) => void; + /** Double click event handler */ + onDblClick?: (e: KonvaEventObject) => void; /** Mouse enter event handler */ onMouseEnter?: (e: KonvaEventObject) => void; /** Mouse leave event handler */ diff --git a/web/libs/editor/src/regions/VectorRegion.jsx b/web/libs/editor/src/regions/VectorRegion.jsx index 663894e345fa..b6a823812276 100644 --- a/web/libs/editor/src/regions/VectorRegion.jsx +++ b/web/libs/editor/src/regions/VectorRegion.jsx @@ -570,6 +570,9 @@ const HtxVectorView = observer(({ item, suggestion }) => { } item.updateCursor(); }} + onDblClick={(e) => { + console.log("double click"); + }} closed={item.closed} width={stageWidth} height={stageHeight} From 6327bc8ed933b9b59565dbdff6b7c5fd30746abe Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Fri, 10 Oct 2025 13:37:47 +0100 Subject: [PATCH 03/32] Toggle transformation mode --- .../components/KonvaVector/KonvaVector.tsx | 17 +++++++++++- .../src/components/KonvaVector/types.ts | 2 ++ web/libs/editor/src/regions/VectorRegion.jsx | 27 ++++++++++++++++--- web/libs/editor/src/tools/Vector.js | 1 + 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx index 37dd04aa9cd9..e3ce6e0046f9 100644 --- a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx +++ b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx @@ -245,6 +245,7 @@ export const KonvaVector = forwardRef((props, pixelSnapping = false, disabled = false, constrainToBounds = false, + transformMode = false, pointRadius, pointFill = DEFAULT_POINT_FILL, pointStroke = DEFAULT_POINT_STROKE, @@ -405,9 +406,10 @@ export const KonvaVector = forwardRef((props, // Determine if drawing should be disabled based on current interaction context const isDrawingDisabled = () => { // Disable all interactions when disabled prop is true + // Disable drawing when in transform mode // Disable drawing when Shift is held (for Shift+click functionality) // Disable drawing when multiple points are selected - if (disabled || isShiftKeyHeld || selectedPoints.size > SELECTION_SIZE.MULTI_SELECTION_MIN) { + if (disabled || transformMode || isShiftKeyHeld || selectedPoints.size > SELECTION_SIZE.MULTI_SELECTION_MIN) { return true; } @@ -574,6 +576,19 @@ export const KonvaVector = forwardRef((props, setVisibleControlPoints(new Set()); } }, [disabled]); + + // Handle transform mode - automatically select all points + useEffect(() => { + if (transformMode && initialPoints.length > 0) { + // Select all points using the tracker + const allPointIndices = new Set(Array.from({ length: initialPoints.length }, (_, i) => i)); + tracker.selectPoints(instanceId, allPointIndices); + } else if (!transformMode) { + // Clear selection when exiting transform mode + tracker.selectPoints(instanceId, new Set()); + } + }, [transformMode, initialPoints.length, tracker, instanceId]); + const lastPos = useRef<{ x: number; y: number } | null>(null); // Set up Transformer nodes once when selection changes diff --git a/web/libs/editor/src/components/KonvaVector/types.ts b/web/libs/editor/src/components/KonvaVector/types.ts index 075df1b082c2..92fec48845b9 100644 --- a/web/libs/editor/src/components/KonvaVector/types.ts +++ b/web/libs/editor/src/components/KonvaVector/types.ts @@ -211,6 +211,8 @@ export interface KonvaVectorProps { disabled?: boolean; /** Constrain points to stay within image bounds */ constrainToBounds?: boolean; + /** Enable transform mode - automatically selects all points and shows transformer */ + transformMode?: boolean; /** Ref to access component methods */ ref?: React.RefObject; } diff --git a/web/libs/editor/src/regions/VectorRegion.jsx b/web/libs/editor/src/regions/VectorRegion.jsx index b6a823812276..547b8c95113b 100644 --- a/web/libs/editor/src/regions/VectorRegion.jsx +++ b/web/libs/editor/src/regions/VectorRegion.jsx @@ -54,6 +54,12 @@ const Model = types // Internal flag to detect if we converted data back from relative points converted: false, + + // Transform mode -- virtual mode to allow transforming the shape as whole (rotate, resize, translate) + // - when transforming -- user can resize, translate or rotate entire shape (all points at once) + // - when NOT transforming -- user works on individual points, moving them, adding, removing, etc. + // Every shape is in transform mode by default except for newly drawn one + transformMode: true, }) .volatile(() => ({ mouseOverStartPoint: false, @@ -256,6 +262,7 @@ const Model = types }, _selectArea(additiveMode = false) { + self.transformMode = true; const annotation = self.annotation; if (!annotation) return; @@ -491,6 +498,14 @@ const Model = types } tool?.complete(); }, + + toggleTransformMode() { + self.setTransformMode(!self.transformMode); + }, + + setTransformMode(transformMode) { + self.transformMode = transformMode; + }, }; }); @@ -515,6 +530,7 @@ const HtxVectorView = observer(({ item, suggestion }) => { const stageWidth = image?.naturalWidth ?? 0; const stageHeight = image?.naturalHeight ?? 0; const { x: offsetX, y: offsetY } = item.parent?.layerZoomScalePosition ?? { x: 0, y: 0 }; + const disabled = item.disabled || suggestion || store.annotationStore.selected.isLinkingMode; // Wait for stage to be properly initialized if (!item.parent?.stageWidth || !item.parent?.stageHeight) { @@ -555,8 +571,10 @@ const HtxVectorView = observer(({ item, suggestion }) => { stage.container().style.cursor = Constants.DEFAULT_CURSOR; } - item.setHighlight(false); - item.onClickRegion(e); + if (!item.selected) { + item.setHighlight(false); + item.onClickRegion(e); + } }} onMouseEnter={() => { if (store.annotationStore.selected.isLinkingMode) { @@ -571,7 +589,7 @@ const HtxVectorView = observer(({ item, suggestion }) => { item.updateCursor(); }} onDblClick={(e) => { - console.log("double click"); + item.toggleTransformMode(); }} closed={item.closed} width={stageWidth} @@ -593,7 +611,8 @@ const HtxVectorView = observer(({ item, suggestion }) => { opacity={Number.parseFloat(item.control?.opacity || "1")} pixelSnapping={item.control?.snap === "pixel"} constrainToBounds={item.control?.constrainToBounds ?? true} - disabled={item.disabled || suggestion || store.annotationStore.selected.isLinkingMode} + disabled={disabled} + transformMode={!disabled && item.transformMode} // Point styling - customize point appearance based on control settings pointRadius={item.pointRadiusFromSize} pointFill={item.selected ? "#ffffff" : "#f8fafc"} diff --git a/web/libs/editor/src/tools/Vector.js b/web/libs/editor/src/tools/Vector.js index 1464414197c6..3e45cdb9157e 100644 --- a/web/libs/editor/src/tools/Vector.js +++ b/web/libs/editor/src/tools/Vector.js @@ -54,6 +54,7 @@ const _Tool = types vertices: [], converted: true, closed: false, + transformMode: false, }); }, From 404fa3f1306cc6dff0b2e47a0d2e9ec752a108d8 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Fri, 10 Oct 2025 14:01:03 +0100 Subject: [PATCH 04/32] Allow selecting multiple shapes --- .../components/KonvaVector/KonvaVector.tsx | 102 +++------- .../KonvaVector/VectorSelectionTracker.ts | 181 ------------------ .../eventHandlers/mouseHandlers.ts | 15 +- .../eventHandlers/pointSelection.ts | 41 ++-- .../KonvaVector/eventHandlers/types.ts | 1 - 5 files changed, 46 insertions(+), 294 deletions(-) delete mode 100644 web/libs/editor/src/components/KonvaVector/VectorSelectionTracker.ts diff --git a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx index e3ce6e0046f9..f74eb43e4263 100644 --- a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx +++ b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx @@ -15,7 +15,6 @@ import { convertPoint } from "./pointManagement"; import { normalizePoints, convertBezierToSimplePoints, isPointInPolygon } from "./utils"; import { findClosestPointOnPath, getDistance } 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 type { BezierPoint, GhostPoint as GhostPointType, KonvaVectorProps, KonvaVectorRef } from "./types"; @@ -362,9 +361,6 @@ export const KonvaVector = forwardRef((props, // Initialize PointCreationManager instance const pointCreationManager = useMemo(() => new PointCreationManager(), []); - // Initialize VectorSelectionTracker - const tracker = useMemo(() => VectorSelectionTracker.getInstance(), []); - // Compute if path is closed based on point references // A path is closed if the first point's prevPointId points to the last point const isPathClosed = useMemo(() => { @@ -506,61 +502,6 @@ export const KonvaVector = forwardRef((props, } }, [drawingDisabled]); - // Stabilize functions for tracker registration - const getPoints = useCallback(() => initialPoints, [initialPoints]); - const updatePoints = useCallback( - (points: BezierPoint[]) => { - setInitialPoints(points); - onPointsChange?.(points); - }, - [onPointsChange], - ); - const setSelectedPointsStable = useCallback((selectedPoints: Set) => { - setSelectedPoints(selectedPoints); - }, []); - const setSelectedPointIndexStable = useCallback((index: number | null) => { - setSelectedPointIndex(index); - }, []); - const getTransformStable = useCallback(() => transform, [transform]); - const getFitScaleStable = useCallback(() => fitScale, [fitScale]); - const getBoundsStable = useCallback(() => ({ width, height }), [width, height]); - - // Register instance with tracker - useEffect(() => { - const vectorInstance: VectorInstance = { - id: instanceId, - getPoints, - updatePoints, - setSelectedPoints: setSelectedPointsStable, - setSelectedPointIndex: setSelectedPointIndexStable, - onPointSelected, - onTransformationComplete, - getTransform: getTransformStable, - getFitScale: getFitScaleStable, - getBounds: getBoundsStable, - constrainToBounds, - }; - - tracker.registerInstance(vectorInstance); - - return () => { - tracker.unregisterInstance(instanceId); - }; - }, [ - instanceId, - tracker, - getPoints, - updatePoints, - setSelectedPointsStable, - setSelectedPointIndexStable, - onPointSelected, - onTransformationComplete, - getTransformStable, - getFitScaleStable, - getBoundsStable, - constrainToBounds, - ]); - // Clear selection when component is disabled useEffect(() => { if (disabled) { @@ -580,14 +521,18 @@ export const KonvaVector = forwardRef((props, // Handle transform mode - automatically select all points useEffect(() => { if (transformMode && initialPoints.length > 0) { - // Select all points using the tracker + // Select all points const allPointIndices = new Set(Array.from({ length: initialPoints.length }, (_, i) => i)); - tracker.selectPoints(instanceId, allPointIndices); + setSelectedPoints(allPointIndices); + setSelectedPointIndex(null); // Multiple points selected + onPointSelected?.(null); } else if (!transformMode) { // Clear selection when exiting transform mode - tracker.selectPoints(instanceId, new Set()); + setSelectedPoints(new Set()); + setSelectedPointIndex(null); + onPointSelected?.(null); } - }, [transformMode, initialPoints.length, tracker, instanceId]); + }, [transformMode, initialPoints.length, onPointSelected]); const lastPos = useRef<{ x: number; y: number } | null>(null); @@ -947,11 +892,6 @@ export const KonvaVector = forwardRef((props, return true; }, selectPointsByIds: (pointIds: string[]) => { - // Check if this instance can have selection - if (!tracker.canInstanceHaveSelection(instanceId)) { - return; // Block the selection - } - // Find the indices of the points with the given IDs const selectedIndices = new Set(); let primarySelectedIndex: number | null = null; @@ -966,12 +906,16 @@ export const KonvaVector = forwardRef((props, } } - // Use tracker for global selection management - tracker.selectPoints(instanceId, selectedIndices); + // Update local selection state + setSelectedPoints(selectedIndices); + setSelectedPointIndex(selectedIndices.size === 1 ? primarySelectedIndex : null); + onPointSelected?.(selectedIndices.size === 1 ? primarySelectedIndex : null); }, clearSelection: () => { - // Use tracker for global selection management - tracker.selectPoints(instanceId, new Set()); + // Clear local selection state + setSelectedPoints(new Set()); + setSelectedPointIndex(null); + onPointSelected?.(null); }, getSelectedPointIds: () => { const selectedIds: string[] = []; @@ -1436,7 +1380,6 @@ export const KonvaVector = forwardRef((props, // Create event handlers const eventHandlers = createEventHandlers({ - instanceId, initialPoints, width, height, @@ -1706,11 +1649,6 @@ export const KonvaVector = forwardRef((props, onPointClick={(e, pointIndex) => { // Handle point selection even when disabled (similar to shape clicks) if (disabled) { - // Check if this instance can have selection - if (!tracker.canInstanceHaveSelection(instanceId)) { - return; // Block the selection - } - // Check if we're about to close the path - prevent point selection in this case if ( shouldClosePathOnPointClick( @@ -1760,10 +1698,14 @@ export const KonvaVector = forwardRef((props, // Add to multi-selection const newSelection = new Set(selectedPoints); newSelection.add(pointIndex); - tracker.selectPoints(instanceId, newSelection); + setSelectedPoints(newSelection); + setSelectedPointIndex(null); + onPointSelected?.(null); } else { // Select only this point - tracker.selectPoints(instanceId, new Set([pointIndex])); + setSelectedPoints(new Set([pointIndex])); + setSelectedPointIndex(pointIndex); + onPointSelected?.(pointIndex); } // Call the original onClick handler if provided diff --git a/web/libs/editor/src/components/KonvaVector/VectorSelectionTracker.ts b/web/libs/editor/src/components/KonvaVector/VectorSelectionTracker.ts deleted file mode 100644 index a3a81ed472cd..000000000000 --- a/web/libs/editor/src/components/KonvaVector/VectorSelectionTracker.ts +++ /dev/null @@ -1,181 +0,0 @@ -import type { BezierPoint } from "./types"; - -export interface GlobalSelectionState { - selectedInstances: Map>; // instanceId -> selected point indices - activeInstanceId: string | null; - isTransforming: boolean; - transformerState: { - rotation: number; - scaleX: number; - scaleY: number; - centerX: number; - centerY: number; - } | null; -} - -export interface VectorInstance { - id: string; - getPoints: () => BezierPoint[]; - updatePoints: (points: BezierPoint[]) => void; - setSelectedPoints: (selectedPoints: Set) => void; - setSelectedPointIndex: (index: number | null) => void; - onPointSelected?: (index: number | null) => void; - onTransformationComplete?: (data: any) => void; - getTransform: () => { zoom: number; offsetX: number; offsetY: number }; - getFitScale: () => number; - getBounds: () => { width: number; height: number } | undefined; - constrainToBounds?: boolean; -} - -export class VectorSelectionTracker { - private static instance: VectorSelectionTracker | null = null; - private state: GlobalSelectionState = { - selectedInstances: new Map(), - activeInstanceId: null, - isTransforming: false, - transformerState: null, - }; - private instances: Map = new Map(); - private listeners: Set<(state: GlobalSelectionState) => void> = new Set(); - - private constructor() {} - - static getInstance(): VectorSelectionTracker { - if (!VectorSelectionTracker.instance) { - VectorSelectionTracker.instance = new VectorSelectionTracker(); - } - return VectorSelectionTracker.instance; - } - - // Instance Management - registerInstance(instance: VectorInstance): void { - this.instances.set(instance.id, instance); - } - - unregisterInstance(instanceId: string): void { - this.instances.delete(instanceId); - this.state.selectedInstances.delete(instanceId); - - // If this was the active instance, clear it - if (this.state.activeInstanceId === instanceId) { - this.state.activeInstanceId = null; - } - - this.notifyListeners(); - } - - // Selection Management - selectPoints(instanceId: string, pointIndices: Set): void { - // If trying to select points and there's already an active instance that's different - if (pointIndices.size > 0 && this.state.activeInstanceId && this.state.activeInstanceId !== instanceId) { - return; // Block the selection - } - - if (pointIndices.size === 0) { - this.state.selectedInstances.delete(instanceId); - // If this was the active instance and we're clearing selection, clear active instance - if (this.state.activeInstanceId === instanceId) { - this.state.activeInstanceId = null; - } - } else { - this.state.selectedInstances.set(instanceId, new Set(pointIndices)); - // Set this as the active instance (first to select wins) - this.state.activeInstanceId = instanceId; - } - - this.notifyListeners(); - - // Update the instance's local selection state - const instance = this.instances.get(instanceId); - if (instance) { - instance.setSelectedPoints(pointIndices); - instance.setSelectedPointIndex(pointIndices.size === 1 ? Array.from(pointIndices)[0] : null); - instance.onPointSelected?.(pointIndices.size === 1 ? Array.from(pointIndices)[0] : null); - } - } - - // Check if an instance can have selection - canInstanceHaveSelection(instanceId: string): boolean { - return this.state.activeInstanceId === null || this.state.activeInstanceId === instanceId; - } - - // Get the currently active instance ID - getActiveInstanceId(): string | null { - return this.state.activeInstanceId; - } - - clearSelection(): void { - // Clear all instance selections - for (const [instanceId, instance] of this.instances) { - instance.setSelectedPoints(new Set()); - instance.setSelectedPointIndex(null); - instance.onPointSelected?.(null); - } - - this.state.selectedInstances.clear(); - this.state.activeInstanceId = null; - this.notifyListeners(); - } - - getGlobalSelection(): GlobalSelectionState { - return { ...this.state }; - } - - getSelectedPoints(): Array<{ instanceId: string; pointIndex: number; point: BezierPoint }> { - const selectedPoints: Array<{ instanceId: string; pointIndex: number; point: BezierPoint }> = []; - - for (const [instanceId, pointIndices] of this.state.selectedInstances) { - const instance = this.instances.get(instanceId); - if (instance) { - const points = instance.getPoints(); - for (const pointIndex of pointIndices) { - if (pointIndex < points.length) { - selectedPoints.push({ - instanceId, - pointIndex, - point: points[pointIndex], - }); - } - } - } - } - - return selectedPoints; - } - - // Event Listeners - subscribe(listener: (state: GlobalSelectionState) => void): () => void { - this.listeners.add(listener); - return () => { - this.listeners.delete(listener); - }; - } - - private notifyListeners(): void { - const globalState = this.getGlobalSelection(); - for (const listener of this.listeners) { - listener(globalState); - } - } - - // Utility Methods - hasSelection(): boolean { - return this.state.selectedInstances.size > 0; - } - - getSelectionCount(): number { - let count = 0; - for (const pointIndices of this.state.selectedInstances.values()) { - count += pointIndices.size; - } - return count; - } - - isInstanceSelected(instanceId: string): boolean { - return this.state.selectedInstances.has(instanceId); - } - - getInstanceSelection(instanceId: string): Set | undefined { - return this.state.selectedInstances.get(instanceId); - } -} diff --git a/web/libs/editor/src/components/KonvaVector/eventHandlers/mouseHandlers.ts b/web/libs/editor/src/components/KonvaVector/eventHandlers/mouseHandlers.ts index 990ca7783301..071828292d47 100644 --- a/web/libs/editor/src/components/KonvaVector/eventHandlers/mouseHandlers.ts +++ b/web/libs/editor/src/components/KonvaVector/eventHandlers/mouseHandlers.ts @@ -24,7 +24,6 @@ import { stageToImageCoordinates, } from "./utils"; import { constrainPointToBounds } from "../utils/boundsChecking"; -import { VectorSelectionTracker } from "../VectorSelectionTracker"; import { PointType } from "../types"; export function createMouseDownHandler(props: EventHandlerProps, handledSelectionInMouseDown: { current: boolean }) { @@ -270,9 +269,10 @@ export function createMouseDownHandler(props: EventHandlerProps, handledSelectio // If we get here, we're not clicking on anything specific // Handle deselection based on transformer state if (!e.evt.ctrlKey && !e.evt.metaKey) { - // Use tracker for global selection management - const tracker = VectorSelectionTracker.getInstance(); - tracker.selectPoints(props.instanceId || "unknown", new Set()); + // Clear local selection state + props.setSelectedPoints?.(new Set()); + props.setSelectedPointIndex?.(null); + props.onPointSelected?.(null); // Reset active point to the last physically added point when deselecting if (props.skeletonEnabled && props.initialPoints.length > 0) { @@ -991,9 +991,10 @@ function handlePointSelectionFromIndex( // For now, just do single selection since we don't have access to modifier keys in mouse up // Multi-selection will be handled by the existing point selection logic in mouse down - // Use tracker for global selection management - const tracker = VectorSelectionTracker.getInstance(); - tracker.selectPoints(props.instanceId || "unknown", new Set([pointIndex])); + // Update local selection state + props.setSelectedPoints?.(new Set([pointIndex])); + props.setSelectedPointIndex?.(pointIndex); + props.onPointSelected?.(pointIndex); // Update activePointId for skeleton mode - set the selected point as the active point if (pointIndex >= 0 && pointIndex < props.initialPoints.length) { diff --git a/web/libs/editor/src/components/KonvaVector/eventHandlers/pointSelection.ts b/web/libs/editor/src/components/KonvaVector/eventHandlers/pointSelection.ts index b76089995a08..ba16476107db 100644 --- a/web/libs/editor/src/components/KonvaVector/eventHandlers/pointSelection.ts +++ b/web/libs/editor/src/components/KonvaVector/eventHandlers/pointSelection.ts @@ -2,7 +2,6 @@ import type { KonvaEventObject } from "konva/lib/Node"; import type { EventHandlerProps } from "./types"; import { isPointInHitRadius, stageToImageCoordinates } from "./utils"; import { closePathBetweenFirstAndLast } from "./drawing"; -import { VectorSelectionTracker } from "../VectorSelectionTracker"; // Helper function to check if a point click should trigger path closing export function shouldClosePathOnPointClick( @@ -99,14 +98,6 @@ export function handlePointSelection(e: KonvaEventObject, props: Eve const scale = props.transform.zoom * props.fitScale; const hitRadius = 10 / scale; - // Get the tracker instance - const tracker = VectorSelectionTracker.getInstance(); - - // Check if this instance can have selection - if (!tracker.canInstanceHaveSelection(props.instanceId || "unknown")) { - return false; // Block selection in this instance - } - // Check if we clicked on any point for (let i = 0; i < props.initialPoints.length; i++) { const point = props.initialPoints[i]; @@ -154,15 +145,19 @@ export function handlePointSelection(e: KonvaEventObject, props: Eve const newSelection = new Set(currentSelection); newSelection.add(i); - // Use tracker for global selection management - tracker.selectPoints(props.instanceId || "unknown", newSelection); + // Update local selection state + props.setSelectedPoints?.(newSelection); + props.setSelectedPointIndex?.(null); // Multiple points selected + props.onPointSelected?.(null); return true; } // Handle skeleton mode point selection (when not multi-selecting) if (props.skeletonEnabled) { - // Use tracker for global selection management - tracker.selectPoints(props.instanceId || "unknown", new Set([i])); + // Update local selection state + props.setSelectedPoints?.(new Set([i])); + props.setSelectedPointIndex?.(i); + props.onPointSelected?.(i); // In skeleton mode, update the active point when selecting a different point // This ensures onFinish only fires for the currently selected point props.setActivePointId?.(point.id); @@ -170,8 +165,10 @@ export function handlePointSelection(e: KonvaEventObject, props: Eve } // If no Cmd/Ctrl and not skeleton mode, clear multi-selection and select only this point - // Use tracker for global selection management - tracker.selectPoints(props.instanceId || "unknown", new Set([i])); + // Update local selection state + props.setSelectedPoints?.(new Set([i])); + props.setSelectedPointIndex?.(i); + props.onPointSelected?.(i); // Return true to indicate we handled the selection return true; } @@ -189,14 +186,6 @@ export function handlePointDeselection(e: KonvaEventObject, props: E const scale = props.transform.zoom * props.fitScale; const hitRadius = 10 / scale; - // Get the tracker instance - const tracker = VectorSelectionTracker.getInstance(); - - // Check if this instance can have selection (deselection is allowed for the active instance) - if (!tracker.canInstanceHaveSelection(props.instanceId || "unknown")) { - return false; // Block deselection in this instance - } - // Check if we clicked on a selected point to unselect it for (let i = 0; i < props.initialPoints.length; i++) { if (props.selectedPoints.has(i)) { @@ -206,8 +195,10 @@ export function handlePointDeselection(e: KonvaEventObject, props: E const newSet = new Set(props.selectedPoints); newSet.delete(i); - // Use tracker for global selection management - tracker.selectPoints(props.instanceId || "unknown", newSet); + // Update local selection state + props.setSelectedPoints?.(newSet); + props.setSelectedPointIndex?.(newSet.size === 1 ? Array.from(newSet)[0] : null); + props.onPointSelected?.(newSet.size === 1 ? Array.from(newSet)[0] : null); // Handle skeleton mode reset if (newSet.size <= 1 && props.skeletonEnabled && props.initialPoints.length > 0) { diff --git a/web/libs/editor/src/components/KonvaVector/eventHandlers/types.ts b/web/libs/editor/src/components/KonvaVector/eventHandlers/types.ts index cbb4e7ee6a40..0427b74e83ee 100644 --- a/web/libs/editor/src/components/KonvaVector/eventHandlers/types.ts +++ b/web/libs/editor/src/components/KonvaVector/eventHandlers/types.ts @@ -3,7 +3,6 @@ import type { BezierPoint, Point, GhostPoint } from "../types"; import type { PointType } from "../types"; export interface EventHandlerProps { - instanceId?: string; // Add instanceId for tracker integration initialPoints: BezierPoint[]; width: number; height: number; From 2d9761dcebe080444ecf577cd1bc572d50d3b784 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Mon, 13 Oct 2025 10:51:26 +0100 Subject: [PATCH 05/32] Multi-selection --- .../components/KonvaVector/KonvaVector.tsx | 4 ++ .../KonvaVector/components/VectorShape.tsx | 6 +++ .../src/components/KonvaVector/types.ts | 4 ++ web/libs/editor/src/regions/VectorRegion.jsx | 48 ++++++++++++++++++- 4 files changed, 61 insertions(+), 1 deletion(-) diff --git a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx index f74eb43e4263..b22f6f0a1429 100644 --- a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx +++ b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx @@ -232,6 +232,7 @@ export const KonvaVector = forwardRef((props, onDblClick, onMouseEnter, onMouseLeave, + onTransformEnd, allowClose = false, closed, allowBezier = true, @@ -245,6 +246,7 @@ export const KonvaVector = forwardRef((props, disabled = false, constrainToBounds = false, transformMode = false, + name, pointRadius, pointFill = DEFAULT_POINT_FILL, pointStroke = DEFAULT_POINT_STROKE, @@ -1531,6 +1533,8 @@ export const KonvaVector = forwardRef((props, opacity={props.opacity} transform={transform} fitScale={fitScale} + name={name} + onTransformEnd={onTransformEnd} onClick={(e) => { // Mark that VectorShape handled the click shapeHandledClick.current = true; diff --git a/web/libs/editor/src/components/KonvaVector/components/VectorShape.tsx b/web/libs/editor/src/components/KonvaVector/components/VectorShape.tsx index 3a92e3df93e0..eb3d8434fc6b 100644 --- a/web/libs/editor/src/components/KonvaVector/components/VectorShape.tsx +++ b/web/libs/editor/src/components/KonvaVector/components/VectorShape.tsx @@ -14,10 +14,12 @@ export interface VectorShapeProps { opacity?: number; transform?: { zoom: number; offsetX: number; offsetY: number }; fitScale?: number; + name?: string; onClick?: (e: KonvaEventObject) => void; onDblClick?: (e: KonvaEventObject) => void; onMouseEnter?: (e: any) => void; onMouseLeave?: (e: any) => void; + onTransformEnd?: (e: KonvaEventObject) => void; } // Convert Bezier segments to SVG path data for a single continuous path @@ -212,10 +214,12 @@ export const VectorShape: React.FC = ({ opacity = 1, transform = { zoom: 1, offsetX: 0, offsetY: 0 }, fitScale = 1, + name, onClick, onDblClick, onMouseEnter, onMouseLeave, + onTransformEnd, }) => { if (segments.length === 0) return null; @@ -295,6 +299,7 @@ export const VectorShape: React.FC = ({ return ( = ({ onDblClick={onDblClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} + onTransformEnd={index === 0 ? onTransformEnd : undefined} /> ); })} diff --git a/web/libs/editor/src/components/KonvaVector/types.ts b/web/libs/editor/src/components/KonvaVector/types.ts index 92fec48845b9..f0fc8bad6d79 100644 --- a/web/libs/editor/src/components/KonvaVector/types.ts +++ b/web/libs/editor/src/components/KonvaVector/types.ts @@ -207,12 +207,16 @@ export interface KonvaVectorProps { onMouseEnter?: (e: KonvaEventObject) => void; /** Mouse leave event handler */ onMouseLeave?: (e: KonvaEventObject) => void; + /** Transform end event handler (for multi-region transformation) */ + onTransformEnd?: (e: KonvaEventObject) => void; /** Disable all interactions when true */ disabled?: boolean; /** Constrain points to stay within image bounds */ constrainToBounds?: boolean; /** Enable transform mode - automatically selects all points and shows transformer */ transformMode?: boolean; + /** Name to assign to the vector shape (for transformer identification) */ + name?: string; /** Ref to access component methods */ ref?: React.RefObject; } diff --git a/web/libs/editor/src/regions/VectorRegion.jsx b/web/libs/editor/src/regions/VectorRegion.jsx index 547b8c95113b..a304f2b78f54 100644 --- a/web/libs/editor/src/regions/VectorRegion.jsx +++ b/web/libs/editor/src/regions/VectorRegion.jsx @@ -537,11 +537,14 @@ const HtxVectorView = observer(({ item, suggestion }) => { return null; } + const isMultiRegionSelected = item.inSelection && item.parent?.selectedRegions?.length > 1; + return ( item.segGroupRef(ref)}> item.setKonvaVectorRef(kv)} + name={isMultiRegionSelected ? `${item.id} _transformable` : undefined} initialPoints={Array.from(item.vertices)} onFinish={(e) => { e.evt.stopPropagation(); @@ -591,6 +594,49 @@ const HtxVectorView = observer(({ item, suggestion }) => { onDblClick={(e) => { item.toggleTransformMode(); }} + onTransformEnd={(e) => { + if (!isMultiRegionSelected) return; + if (e.target !== e.currentTarget) return; + + const t = e.target; + const dx = t.getAttr("x", 0); + const dy = t.getAttr("y", 0); + const scaleX = t.getAttr("scaleX", 1); + const scaleY = t.getAttr("scaleY", 1); + const rotation = t.getAttr("rotation", 0); + + // Get the bounding box center for rotation/scale origin + const bbox = item.bbox; + if (!bbox) return; + + const centerX = (bbox.left + bbox.right) / 2; + const centerY = (bbox.top + bbox.bottom) / 2; + + // Apply transformation using KonvaVector ref methods + if (item.vectorRef) { + // First apply rotation if any + if (rotation !== 0) { + item.vectorRef.rotatePoints(rotation, centerX, centerY); + } + + // Then apply scaling if any + if (scaleX !== 1 || scaleY !== 1) { + item.vectorRef.scalePoints(scaleX, scaleY, centerX, centerY); + } + + // Finally apply translation if any + if (dx !== 0 || dy !== 0) { + item.vectorRef.translatePoints(dx, dy); + } + } + + // Reset transform attributes + t.setAttr("x", 0); + t.setAttr("y", 0); + t.setAttr("scaleX", 1); + t.setAttr("scaleY", 1); + t.setAttr("rotation", 0); + }} closed={item.closed} width={stageWidth} height={stageHeight} @@ -612,7 +658,7 @@ const HtxVectorView = observer(({ item, suggestion }) => { pixelSnapping={item.control?.snap === "pixel"} constrainToBounds={item.control?.constrainToBounds ?? true} disabled={disabled} - transformMode={!disabled && item.transformMode} + transformMode={!disabled && !item.inSelection && item.transformMode && !isMultiRegionSelected} // Point styling - customize point appearance based on control settings pointRadius={item.pointRadiusFromSize} pointFill={item.selected ? "#ffffff" : "#f8fafc"} From f9a146b6b932cb5301e2483bf8729f97d83b3fc2 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Mon, 13 Oct 2025 12:24:15 +0100 Subject: [PATCH 06/32] Selection --- .../components/KonvaVector/KonvaVector.tsx | 99 +++++++++++-------- .../src/components/KonvaVector/types.ts | 4 + web/libs/editor/src/regions/VectorRegion.jsx | 64 +++++++----- 3 files changed, 100 insertions(+), 67 deletions(-) diff --git a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx index b22f6f0a1429..233f3611e3d1 100644 --- a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx +++ b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx @@ -215,6 +215,8 @@ export const KonvaVector = forwardRef((props, onPathClosedChange, onTransformationComplete, onPointSelected, + selectedPoints: externalSelectedPoints, + onSelectionChange, onFinish, scaleX, scaleY, @@ -274,9 +276,39 @@ export const KonvaVector = forwardRef((props, } }, [initialPoints.length, skeletonEnabled]); // Only run when the number of points changes or skeleton mode changes - // Use initialPoints directly - this will update when the parent re-renders - const [selectedPointIndex, setSelectedPointIndex] = useState(null); - const [selectedPoints, setSelectedPoints] = useState>(new Set()); + // Internal selection state + const [internalSelectedPointIndex, setInternalSelectedPointIndex] = useState(null); + const [internalSelectedPoints, setInternalSelectedPoints] = useState>(new Set()); + + // Use external selection if provided, otherwise use internal state + const selectedPointIndex = externalSelectedPoints ? + (externalSelectedPoints.length === 1 ? + initialPoints.findIndex(p => p.id === externalSelectedPoints[0]) : null) : + internalSelectedPointIndex; + + const selectedPoints = externalSelectedPoints ? + new Set(externalSelectedPoints.map(id => initialPoints.findIndex(p => p.id === id)).filter(i => i !== -1)) : + internalSelectedPoints; + + // Transform mode creates virtual selection (doesn't affect real selection state) + const virtualSelectedPoints = transformMode && initialPoints.length > 0 ? + new Set(Array.from({ length: initialPoints.length }, (_, i) => i)) : + selectedPoints; + + // Helper functions to update selection (works with both internal and external state) + const updateSelection = useCallback((newSelectedPoints: Set, newSelectedPointIndex: number | null) => { + if (externalSelectedPoints) { + // External control - notify parent + const selectedIds = Array.from(newSelectedPoints).map(i => initialPoints[i]?.id).filter(Boolean); + onSelectionChange?.(selectedIds, newSelectedPointIndex); + } else { + // Internal control - update internal state + setInternalSelectedPoints(newSelectedPoints); + setInternalSelectedPointIndex(newSelectedPointIndex); + } + onPointSelected?.(newSelectedPointIndex); + }, [externalSelectedPoints, initialPoints, onSelectionChange, onPointSelected]); + const [lastAddedPointId, setLastAddedPointId] = useState(null); const transformerRef = useRef(null); @@ -507,8 +539,7 @@ export const KonvaVector = forwardRef((props, // Clear selection when component is disabled useEffect(() => { if (disabled) { - setSelectedPointIndex(null); - setSelectedPoints(new Set()); + updateSelection(new Set(), null); setVisibleControlPoints(new Set()); setDraggedControlPoint(null); setGhostPoint(null); @@ -518,37 +549,23 @@ export const KonvaVector = forwardRef((props, // Hide all Bezier control points when disabled setVisibleControlPoints(new Set()); } - }, [disabled]); + }, [disabled, updateSelection]); - // Handle transform mode - automatically select all points - useEffect(() => { - if (transformMode && initialPoints.length > 0) { - // Select all points - const allPointIndices = new Set(Array.from({ length: initialPoints.length }, (_, i) => i)); - setSelectedPoints(allPointIndices); - setSelectedPointIndex(null); // Multiple points selected - onPointSelected?.(null); - } else if (!transformMode) { - // Clear selection when exiting transform mode - setSelectedPoints(new Set()); - setSelectedPointIndex(null); - onPointSelected?.(null); - } - }, [transformMode, initialPoints.length, onPointSelected]); + // Transform mode is now virtual - no need to actually select points const lastPos = useRef<{ x: number; y: number } | null>(null); // Set up Transformer nodes once when selection changes useEffect(() => { if (transformerRef.current) { - if (selectedPoints.size > SELECTION_SIZE.MULTI_SELECTION_MIN) { + if (virtualSelectedPoints.size > SELECTION_SIZE.MULTI_SELECTION_MIN) { // Use setTimeout to ensure proxy nodes are rendered first setTimeout(() => { if (transformerRef.current) { // Set up proxy nodes once - transformer will manage them independently // Use getAllPoints() to get the correct proxy nodes for all points const allPoints = getAllPoints(); - const nodes = Array.from(selectedPoints) + const nodes = Array.from(virtualSelectedPoints) .map((index) => { // Ensure the index is within bounds of all points if (index < allPoints.length) { @@ -575,7 +592,7 @@ export const KonvaVector = forwardRef((props, }, TRANSFORMER_CLEAR_DELAY); } } - }, [selectedPoints]); // Only depend on selectedPoints, not initialPoints + }, [virtualSelectedPoints]); // Only depend on virtualSelectedPoints, not initialPoints // Note: We don't update proxy node positions during transformation // The transformer handles positioning the proxy nodes itself @@ -908,16 +925,12 @@ export const KonvaVector = forwardRef((props, } } - // Update local selection state - setSelectedPoints(selectedIndices); - setSelectedPointIndex(selectedIndices.size === 1 ? primarySelectedIndex : null); - onPointSelected?.(selectedIndices.size === 1 ? primarySelectedIndex : null); + // Update selection using the unified system + updateSelection(selectedIndices, selectedIndices.size === 1 ? primarySelectedIndex : null); }, clearSelection: () => { - // Clear local selection state - setSelectedPoints(new Set()); - setSelectedPointIndex(null); - onPointSelected?.(null); + // Clear selection using the unified system + updateSelection(new Set(), null); }, getSelectedPointIds: () => { const selectedIds: string[] = []; @@ -1388,8 +1401,14 @@ export const KonvaVector = forwardRef((props, pixelSnapping, selectedPoints, selectedPointIndex, - setSelectedPointIndex, - setSelectedPoints, + setSelectedPointIndex: (index) => { + const newSelectedPoints = index !== null ? new Set([index]) : new Set(); + updateSelection(newSelectedPoints, index); + }, + setSelectedPoints: (points) => { + const newSelectedPointIndex = points.size === 1 ? Array.from(points)[0] : null; + updateSelection(points, newSelectedPointIndex); + }, setDraggedPointIndex, setDraggedControlPoint, setIsDisconnectedMode, @@ -1702,14 +1721,10 @@ export const KonvaVector = forwardRef((props, // Add to multi-selection const newSelection = new Set(selectedPoints); newSelection.add(pointIndex); - setSelectedPoints(newSelection); - setSelectedPointIndex(null); - onPointSelected?.(null); + updateSelection(newSelection, null); } else { // Select only this point - setSelectedPoints(new Set([pointIndex])); - setSelectedPointIndex(pointIndex); - onPointSelected?.(pointIndex); + updateSelection(new Set([pointIndex]), pointIndex); } // Call the original onClick handler if provided @@ -1729,13 +1744,13 @@ export const KonvaVector = forwardRef((props, {/* Proxy nodes for Transformer (positioned at exact point centers) - only show when not in drawing mode */} {drawingDisabled && ( - + )} {/* Transformer for multiselection - only show when not in drawing mode */} {drawingDisabled && ( void; /** Called when a point is selected */ onPointSelected?: (pointIndex: number | null) => void; + /** Array of selected point IDs (controlled selection) */ + selectedPoints?: string[]; + /** Called when selection changes */ + onSelectionChange?: (selectedPointIds: string[], selectedPointIndex: number | null) => void; /** Called when drawing is finished (click on last point or double click on empty space) */ onFinish?: (e: KonvaEventObject) => void; /** Canvas width */ diff --git a/web/libs/editor/src/regions/VectorRegion.jsx b/web/libs/editor/src/regions/VectorRegion.jsx index a304f2b78f54..f4353bba77ed 100644 --- a/web/libs/editor/src/regions/VectorRegion.jsx +++ b/web/libs/editor/src/regions/VectorRegion.jsx @@ -73,6 +73,7 @@ const Model = types isDrawing: false, vectorRef: null, groupRef: null, + selectedVertices: [], })) .views((self) => ({ get store() { @@ -88,13 +89,21 @@ const Model = types get bbox() { if (!self.vertices?.length || !isAlive(self)) return {}; - // Calculate bounding box from vector points - const bbox = self.vectorRef?.getShapeBoundingBox() ?? {}; - - // Ensure we have valid coordinates - if (bbox.left === undefined || bbox.top === undefined) { - return {}; - } + // Calculate bounding box directly from vertices (similar to PolygonRegion) + const bbox = self.vertices.reduce( + (bboxCoords, point) => ({ + left: Math.min(bboxCoords.left, point.x), + top: Math.min(bboxCoords.top, point.y), + right: Math.max(bboxCoords.right, point.x), + bottom: Math.max(bboxCoords.bottom, point.y), + }), + { + left: self.vertices[0].x, + top: self.vertices[0].y, + right: self.vertices[0].x, + bottom: self.vertices[0].y, + }, + ); return bbox; }, @@ -258,7 +267,9 @@ const Model = types .map((p) => p.id); const vector = self.vectorRef; - vector?.selectPointsByIds(selectedPoints); + const selectionSize = self.annotation.regionStore.selection.size; + + self.selectedVertices = selectionSize > 1 ? [] : selectedPoints; }, _selectArea(additiveMode = false) { @@ -609,25 +620,27 @@ const HtxVectorView = observer(({ item, suggestion }) => { const bbox = item.bbox; if (!bbox) return; - const centerX = (bbox.left + bbox.right) / 2; - const centerY = (bbox.top + bbox.bottom) / 2; + // Convert bbox center to image coordinates (KonvaVector uses image coords) + const centerX = item.parent.internalToImageX((bbox.left + bbox.right) / 2); + const centerY = item.parent.internalToImageY((bbox.top + bbox.bottom) / 2); // Apply transformation using KonvaVector ref methods if (item.vectorRef) { - // First apply rotation if any - if (rotation !== 0) { - item.vectorRef.rotatePoints(rotation, centerX, centerY); - } - - // Then apply scaling if any - if (scaleX !== 1 || scaleY !== 1) { - item.vectorRef.scalePoints(scaleX, scaleY, centerX, centerY); - } - - // Finally apply translation if any - if (dx !== 0 || dy !== 0) { - item.vectorRef.translatePoints(dx, dy); - } + // Convert canvas coordinates to image coordinates (KonvaVector uses image coords) + const imageDx = item.parent.canvasToImageX(dx) - item.parent.canvasToImageX(0); + const imageDy = item.parent.canvasToImageY(dy) - item.parent.canvasToImageY(0); + + // Use transformPoints method to apply all transformations at once + // This ensures onPointsChange is called only once with the final result + item.vectorRef.transformPoints({ + dx: imageDx, + dy: imageDy, + rotation: rotation, + scaleX: scaleX, + scaleY: scaleY, + centerX: centerX, + centerY: centerY, + }); } // Reset transform attributes @@ -637,6 +650,7 @@ const HtxVectorView = observer(({ item, suggestion }) => { t.setAttr("scaleY", 1); t.setAttr("rotation", 0); }} + selectedPoints={item.annotation.regionStore.selection.size > 1 ? [] : item.selectedVertices} closed={item.closed} width={stageWidth} height={stageHeight} @@ -658,7 +672,7 @@ const HtxVectorView = observer(({ item, suggestion }) => { pixelSnapping={item.control?.snap === "pixel"} constrainToBounds={item.control?.constrainToBounds ?? true} disabled={disabled} - transformMode={!disabled && !item.inSelection && item.transformMode && !isMultiRegionSelected} + transformMode={!disabled && item.transformMode && !isMultiRegionSelected} // Point styling - customize point appearance based on control settings pointRadius={item.pointRadiusFromSize} pointFill={item.selected ? "#ffffff" : "#f8fafc"} From 6707960df5c4324b4e0d213a62a106430f1309c9 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Mon, 13 Oct 2025 12:32:46 +0100 Subject: [PATCH 07/32] Transformation --- .../components/KonvaVector/KonvaVector.tsx | 454 ++++++++++++------ 1 file changed, 312 insertions(+), 142 deletions(-) diff --git a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx index 233f3611e3d1..a5859ef50bb6 100644 --- a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx +++ b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx @@ -1542,82 +1542,250 @@ export const KonvaVector = forwardRef((props, )} {/* Unified vector shape - renders all lines based on id-prevPointId relationships */} - { - // Mark that VectorShape handled the click - shapeHandledClick.current = true; - - // Clear any existing click timeout - if (clickTimeoutRef.current) { - clearTimeout(clickTimeoutRef.current); - } + {name?.includes('_transformable') ? ( + + { + // Mark that VectorShape handled the click + shapeHandledClick.current = true; + + // Clear any existing click timeout + if (clickTimeoutRef.current) { + clearTimeout(clickTimeoutRef.current); + } - // Delay click execution to check if it's part of a double-click - clickTimeoutRef.current = setTimeout(() => { - // Check if click is on the last added point by checking cursor position - if (cursorPosition && 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 distance = Math.sqrt( - (cursorPosition.x - lastAddedPoint.x) ** 2 + (cursorPosition.y - lastAddedPoint.y) ** 2, - ); - - if (distance <= hitRadius) { - // Find the index of the last added point - const lastAddedPointIndex = initialPoints.findIndex((p) => p.id === lastAddedPointId); - - // 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 && selectedPoints.has(lastAddedPointIndex) && !disabled) { - const hasModifiers = e.evt.ctrlKey || e.evt.metaKey || e.evt.shiftKey || e.evt.altKey; - if (!hasModifiers) { - onFinish?.(e); - return; + // Delay click execution to check if it's part of a double-click + clickTimeoutRef.current = setTimeout(() => { + // Check if click is on the last added point by checking cursor position + if (cursorPosition && 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 distance = Math.sqrt( + (cursorPosition.x - lastAddedPoint.x) ** 2 + (cursorPosition.y - lastAddedPoint.y) ** 2, + ); + + if (distance <= hitRadius) { + // Find the index of the last added point + const lastAddedPointIndex = initialPoints.findIndex((p) => p.id === lastAddedPointId); + + // 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 && selectedPoints.has(lastAddedPointIndex) && !disabled) { + const hasModifiers = e.evt.ctrlKey || e.evt.metaKey || e.evt.shiftKey || e.evt.altKey; + if (!hasModifiers) { + onFinish?.(e); + 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 + } + } + + // Call the event handler (for drawing/interaction logic) + eventHandlers.handleLayerClick(e); + + // Call the original onClick handler (custom callback) + onClick?.(e); + }, CLICK_DELAY); + }} + onDblClick={(e) => { + // Mark that VectorShape handled the double-click + shapeHandledClick.current = true; + + // Clear the pending click timeout to prevent single click from executing + if (clickTimeoutRef.current) { + clearTimeout(clickTimeoutRef.current); + clickTimeoutRef.current = null; + } + + // Execute double click handler + onDblClick?.(e); + }} + onMouseEnter={onMouseEnter} + onMouseLeave={onMouseLeave} + key={`vector-shape-${initialPoints.length}-${initialPoints.map((p) => p.id).join("-")}`} + /> + + {/* All vector points - inside the same _transformable group */} + { + // Handle point selection even when disabled (similar to shape clicks) + if (disabled) { + // Check if we're about to close the path - prevent point selection in this case + if ( + shouldClosePathOnPointClick( + pointIndex, + { + initialPoints, + allowClose, + isPathClosed: finalIsPathClosed, + skeletonEnabled, + activePointId, + } as any, + e, + ) && + isActivePointEligibleForClosing({ + initialPoints, + skeletonEnabled, + activePointId, + } as any) + ) { + // Use the bidirectional closePath function + const success = (ref as React.MutableRefObject)?.current?.close(); + if (success) { + return; // Path was closed, don't select the point + } + } + + // Check if this is the last added point and already selected (second click) + const isLastAddedPoint = lastAddedPointId && initialPoints[pointIndex]?.id === lastAddedPointId; + const isAlreadySelected = selectedPoints.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 return; } + // If modifiers are held, skip onFinish entirely and let normal modifier handling take over + return; + } + + // Handle regular point selection + if (e.evt.ctrlKey || e.evt.metaKey) { + // Add to multi-selection + const newSelection = new Set(selectedPoints); + newSelection.add(pointIndex); + updateSelection(newSelection, null); + } else { + // Select only this point + updateSelection(new Set([pointIndex]), pointIndex); } + + // Call the original onClick handler if provided + onClick?.(e); + + // Mark that we handled selection and prevent all other handlers from running + pointSelectionHandled.current = true; + e.evt.stopImmediatePropagation(); + return; } + + // When not disabled, let the normal event handlers handle it + // The point click will be detected by the layer-level handlers + // + }} + /> + + ) : ( + { + // Mark that VectorShape handled the click + shapeHandledClick.current = true; + + // Clear any existing click timeout + if (clickTimeoutRef.current) { + clearTimeout(clickTimeoutRef.current); } - // Call the event handler (for drawing/interaction logic) - eventHandlers.handleLayerClick(e); - - // Call the original onClick handler (custom callback) - onClick?.(e); - }, CLICK_DELAY); - }} - onDblClick={(e) => { - // Mark that VectorShape handled the double-click - shapeHandledClick.current = true; - - // Clear the pending click timeout to prevent single click from executing - if (clickTimeoutRef.current) { - clearTimeout(clickTimeoutRef.current); - clickTimeoutRef.current = null; - } + // Delay click execution to check if it's part of a double-click + clickTimeoutRef.current = setTimeout(() => { + // Check if click is on the last added point by checking cursor position + if (cursorPosition && 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 distance = Math.sqrt( + (cursorPosition.x - lastAddedPoint.x) ** 2 + (cursorPosition.y - lastAddedPoint.y) ** 2, + ); + + if (distance <= hitRadius) { + // Find the index of the last added point + const lastAddedPointIndex = initialPoints.findIndex((p) => p.id === lastAddedPointId); + + // 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 && selectedPoints.has(lastAddedPointIndex) && !disabled) { + const hasModifiers = e.evt.ctrlKey || e.evt.metaKey || e.evt.shiftKey || e.evt.altKey; + if (!hasModifiers) { + onFinish?.(e); + return; + } + // If modifiers are held, skip onFinish entirely and let normal modifier handling take over + return; + } + } + } + } - // Execute double click handler - onDblClick?.(e); - }} - onMouseEnter={onMouseEnter} - onMouseLeave={onMouseLeave} - key={`vector-shape-${initialPoints.length}-${initialPoints.map((p) => p.id).join("-")}`} - /> + // Call the event handler (for drawing/interaction logic) + eventHandlers.handleLayerClick(e); + + // Call the original onClick handler (custom callback) + onClick?.(e); + }, CLICK_DELAY); + }} + onDblClick={(e) => { + // Mark that VectorShape handled the double-click + shapeHandledClick.current = true; + + // Clear the pending click timeout to prevent single click from executing + if (clickTimeoutRef.current) { + clearTimeout(clickTimeoutRef.current); + clickTimeoutRef.current = null; + } + + // Execute double click handler + onDblClick?.(e); + }} + onMouseEnter={onMouseEnter} + onMouseLeave={onMouseLeave} + key={`vector-shape-${initialPoints.length}-${initialPoints.map((p) => p.id).join("-")}`} + /> + )} {/* Ghost line - preview from last point to cursor */} ((props, /> )} - {/* All vector points */} - { - // Handle point selection even when disabled (similar to shape clicks) - if (disabled) { - // Check if we're about to close the path - prevent point selection in this case - if ( - shouldClosePathOnPointClick( - pointIndex, - { + {/* All vector points - only render when not in multi-region mode */} + {!name?.includes('_transformable') && ( + { + // Handle point selection even when disabled (similar to shape clicks) + if (disabled) { + // Check if we're about to close the path - prevent point selection in this case + if ( + shouldClosePathOnPointClick( + pointIndex, + { + initialPoints, + allowClose, + isPathClosed: finalIsPathClosed, + skeletonEnabled, + activePointId, + } as any, + e, + ) && + isActivePointEligibleForClosing({ initialPoints, - allowClose, - isPathClosed: finalIsPathClosed, skeletonEnabled, activePointId, - } as any, - e, - ) && - isActivePointEligibleForClosing({ - initialPoints, - skeletonEnabled, - activePointId, - } as any) - ) { - // Use the bidirectional closePath function - const success = (ref as React.MutableRefObject)?.current?.close(); - if (success) { - return; // Path was closed, don't select the point + } as any) + ) { + // Use the bidirectional closePath function + const success = (ref as React.MutableRefObject)?.current?.close(); + if (success) { + return; // Path was closed, don't select the point + } } - } - // Check if this is the last added point and already selected (second click) - const isLastAddedPoint = lastAddedPointId && initialPoints[pointIndex]?.id === lastAddedPointId; - const isAlreadySelected = selectedPoints.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 + // Check if this is the last added point and already selected (second click) + const isLastAddedPoint = lastAddedPointId && initialPoints[pointIndex]?.id === lastAddedPointId; + const isAlreadySelected = selectedPoints.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 + 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 - if (e.evt.ctrlKey || e.evt.metaKey) { - // Add to multi-selection - const newSelection = new Set(selectedPoints); - newSelection.add(pointIndex); - updateSelection(newSelection, null); - } else { - // Select only this point - updateSelection(new Set([pointIndex]), pointIndex); - } + // Handle regular point selection + if (e.evt.ctrlKey || e.evt.metaKey) { + // Add to multi-selection + const newSelection = new Set(selectedPoints); + newSelection.add(pointIndex); + updateSelection(newSelection, null); + } else { + // Select only this point + updateSelection(new Set([pointIndex]), pointIndex); + } - // Call the original onClick handler if provided - onClick?.(e); + // Call the original onClick handler if provided + onClick?.(e); - // Mark that we handled selection and prevent all other handlers from running - pointSelectionHandled.current = true; - e.evt.stopImmediatePropagation(); - return; - } + // Mark that we handled selection and prevent all other handlers from running + pointSelectionHandled.current = true; + e.evt.stopImmediatePropagation(); + return; + } - // When not disabled, let the normal event handlers handle it - // The point click will be detected by the layer-level handlers - // - }} - /> + // When not disabled, let the normal event handlers handle it + // The point click will be detected by the layer-level handlers + // + }} + /> + )} {/* Proxy nodes for Transformer (positioned at exact point centers) - only show when not in drawing mode */} {drawingDisabled && ( From 907d954be4924c4f63782c92cf46717fe6382916 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Mon, 13 Oct 2025 12:52:59 +0100 Subject: [PATCH 08/32] Translation --- .../ImageTransformer/ImageTransformer.jsx | 160 +++++++++++++++++- .../components/KonvaVector/KonvaVector.tsx | 3 +- web/libs/editor/src/regions/VectorRegion.jsx | 50 +++++- 3 files changed, 205 insertions(+), 8 deletions(-) diff --git a/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx b/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx index 987b03211ab7..a4fe0fc908d9 100644 --- a/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx +++ b/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx @@ -213,11 +213,87 @@ export default class TransformerComponent extends Component { }; }} dragBoundFunc={this.dragBoundFunc} - onDragEnd={() => { + onDragEnd={(e) => { + console.log("ImageTransformer onDragEnd triggered"); + // Apply translation to each selected region + const { item: { selectedRegions } } = this.props; + + if (selectedRegions && selectedRegions.length > 0) { + selectedRegions.forEach((region) => { + if (region.applyTransform) { + // Get the transformation data from the transformer + const nodes = this.transformer.nodes(); + const node = nodes.find(n => n.hasName(region.id)); + + if (node) { + const dx = node.getAttr("x", 0); + const dy = node.getAttr("y", 0); + const scaleX = node.getAttr("scaleX", 1); + const scaleY = node.getAttr("scaleY", 1); + const rotation = node.getAttr("rotation", 0); + + // Call the region's applyTransform method + region.applyTransform({ + dx, + dy, + scaleX, + scaleY, + rotation, + }); + + // Reset transform attributes + node.setAttr("x", 0); + node.setAttr("y", 0); + node.setAttr("scaleX", 1); + node.setAttr("scaleY", 1); + node.setAttr("rotation", 0); + } + } + }); + } + this.unfreeze(); setTimeout(this.checkNode); }} - onTransformEnd={() => { + onTransformEnd={(e) => { + console.log("ImageTransformer onTransformEnd triggered"); + // Apply transformation to each selected region + const { item: { selectedRegions } } = this.props; + + if (selectedRegions && selectedRegions.length > 0) { + selectedRegions.forEach((region) => { + if (region.applyTransform) { + // Get the transformation data from the transformer + const nodes = this.transformer.nodes(); + const node = nodes.find(n => n.hasName(region.id)); + + if (node) { + const dx = node.getAttr("x", 0); + const dy = node.getAttr("y", 0); + const scaleX = node.getAttr("scaleX", 1); + const scaleY = node.getAttr("scaleY", 1); + const rotation = node.getAttr("rotation", 0); + + // Call the region's applyTransform method + region.applyTransform({ + dx, + dy, + scaleX, + scaleY, + rotation, + }); + + // Reset transform attributes + node.setAttr("x", 0); + node.setAttr("y", 0); + node.setAttr("scaleX", 1); + node.setAttr("scaleY", 1); + node.setAttr("rotation", 0); + } + } + }); + } + setTimeout(this.checkNode); }} backSelector={this.props.draggableBackgroundSelector} @@ -261,11 +337,87 @@ export default class TransformerComponent extends Component { }; }} dragBoundFunc={this.dragBoundFunc} - onDragEnd={() => { + onDragEnd={(e) => { + console.log("ImageTransformer onDragEnd triggered"); + // Apply translation to each selected region + const { item: { selectedRegions } } = this.props; + + if (selectedRegions && selectedRegions.length > 0) { + selectedRegions.forEach((region) => { + if (region.applyTransform) { + // Get the transformation data from the transformer + const nodes = this.transformer.nodes(); + const node = nodes.find(n => n.hasName(region.id)); + + if (node) { + const dx = node.getAttr("x", 0); + const dy = node.getAttr("y", 0); + const scaleX = node.getAttr("scaleX", 1); + const scaleY = node.getAttr("scaleY", 1); + const rotation = node.getAttr("rotation", 0); + + // Call the region's applyTransform method + region.applyTransform({ + dx, + dy, + scaleX, + scaleY, + rotation, + }); + + // Reset transform attributes + node.setAttr("x", 0); + node.setAttr("y", 0); + node.setAttr("scaleX", 1); + node.setAttr("scaleY", 1); + node.setAttr("rotation", 0); + } + } + }); + } + this.unfreeze(); setTimeout(this.checkNode); }} - onTransformEnd={() => { + onTransformEnd={(e) => { + console.log("ImageTransformer onTransformEnd triggered"); + // Apply transformation to each selected region + const { item: { selectedRegions } } = this.props; + + if (selectedRegions && selectedRegions.length > 0) { + selectedRegions.forEach((region) => { + if (region.applyTransform) { + // Get the transformation data from the transformer + const nodes = this.transformer.nodes(); + const node = nodes.find(n => n.hasName(region.id)); + + if (node) { + const dx = node.getAttr("x", 0); + const dy = node.getAttr("y", 0); + const scaleX = node.getAttr("scaleX", 1); + const scaleY = node.getAttr("scaleY", 1); + const rotation = node.getAttr("rotation", 0); + + // Call the region's applyTransform method + region.applyTransform({ + dx, + dy, + scaleX, + scaleY, + rotation, + }); + + // Reset transform attributes + node.setAttr("x", 0); + node.setAttr("y", 0); + node.setAttr("scaleX", 1); + node.setAttr("scaleY", 1); + node.setAttr("rotation", 0); + } + } + }); + } + setTimeout(this.checkNode); }} backSelector={this.props.draggableBackgroundSelector} diff --git a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx index a5859ef50bb6..20e0dd3fd5f0 100644 --- a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx +++ b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx @@ -1543,7 +1543,7 @@ export const KonvaVector = forwardRef((props, {/* Unified vector shape - renders all lines based on id-prevPointId relationships */} {name?.includes('_transformable') ? ( - + ((props, transform={transform} fitScale={fitScale} name={undefined} - onTransformEnd={onTransformEnd} onClick={(e) => { // Mark that VectorShape handled the click shapeHandledClick.current = true; diff --git a/web/libs/editor/src/regions/VectorRegion.jsx b/web/libs/editor/src/regions/VectorRegion.jsx index f4353bba77ed..3023a54a60c2 100644 --- a/web/libs/editor/src/regions/VectorRegion.jsx +++ b/web/libs/editor/src/regions/VectorRegion.jsx @@ -390,6 +390,51 @@ const Model = types return selection.length > 1; }, + // Apply transformation from ImageTransformer + applyTransform(transformation) { + console.log("applying transfomations", transformation); + if (!self.vectorRef) return; + + const { dx, dy, scaleX, scaleY, rotation } = transformation; + + // Check if transformation values are reasonable (not already applied) + // If dx/dy are very large, it means the transformation was already applied by Konva + const isTranslationReasonable = Math.abs(dx) < 1000 && Math.abs(dy) < 1000; + const isScaleReasonable = scaleX > 0.1 && scaleX < 10 && scaleY > 0.1 && scaleY < 10; + + console.log("transformation check:", { isTranslationReasonable, isScaleReasonable }); + + // Only apply transformation if values are reasonable + if (!isTranslationReasonable || !isScaleReasonable) { + console.log("Skipping transformation - values seem already applied"); + return; + } + + // Get the bounding box center for rotation/scale origin + const bbox = self.bboxCoords || self.bbox; + if (!bbox) return; + + // Use the same coordinate system as translation - no conversion needed + // The bbox is already in the correct coordinate system for KonvaVector + const centerX = (bbox.left + bbox.right) / 2; + const centerY = (bbox.top + bbox.bottom) / 2; + + console.log("bbox center:", { centerX, centerY }); + console.log("transformation values:", { dx, dy, scaleX, scaleY, rotation }); + + // Apply transformations using the same approach as translation + // Use raw values directly - Konva transformer already handled coordinate conversion + self.vectorRef.transformPoints({ + dx: dx, + dy: dy, + rotation: rotation, + scaleX: scaleX, + scaleY: scaleY, + centerX: centerX, + centerY: centerY, + }); + }, + segGroupRef(ref) { self.groupRef = ref; }, @@ -606,6 +651,7 @@ const HtxVectorView = observer(({ item, suggestion }) => { item.toggleTransformMode(); }} onTransformEnd={(e) => { + console.log("transform end"); if (!isMultiRegionSelected) return; if (e.target !== e.currentTarget) return; @@ -627,8 +673,8 @@ const HtxVectorView = observer(({ item, suggestion }) => { // Apply transformation using KonvaVector ref methods if (item.vectorRef) { // Convert canvas coordinates to image coordinates (KonvaVector uses image coords) - const imageDx = item.parent.canvasToImageX(dx) - item.parent.canvasToImageX(0); - const imageDy = item.parent.canvasToImageY(dy) - item.parent.canvasToImageY(0); + const imageDx = item.parent.canvasToInternalX(dx); + const imageDy = item.parent.canvasToInternalY(dy); // Use transformPoints method to apply all transformations at once // This ensures onPointsChange is called only once with the final result From 3abb78d29b8b1d0207753630a74c06d1fe5c0f21 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Mon, 13 Oct 2025 13:11:50 +0100 Subject: [PATCH 09/32] Applying transformation from global transformer --- .../ImageTransformer/ImageTransformer.jsx | 25 ++++++---- web/libs/editor/src/regions/VectorRegion.jsx | 46 ------------------- 2 files changed, 16 insertions(+), 55 deletions(-) diff --git a/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx b/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx index a4fe0fc908d9..619cca214203 100644 --- a/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx +++ b/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx @@ -216,14 +216,16 @@ export default class TransformerComponent extends Component { onDragEnd={(e) => { console.log("ImageTransformer onDragEnd triggered"); // Apply translation to each selected region - const { item: { selectedRegions } } = this.props; + const { + item: { selectedRegions }, + } = this.props; if (selectedRegions && selectedRegions.length > 0) { selectedRegions.forEach((region) => { if (region.applyTransform) { // Get the transformation data from the transformer const nodes = this.transformer.nodes(); - const node = nodes.find(n => n.hasName(region.id)); + const node = nodes.find((n) => n.hasName(region.id)); if (node) { const dx = node.getAttr("x", 0); @@ -256,16 +258,17 @@ export default class TransformerComponent extends Component { setTimeout(this.checkNode); }} onTransformEnd={(e) => { - console.log("ImageTransformer onTransformEnd triggered"); // Apply transformation to each selected region - const { item: { selectedRegions } } = this.props; + const { + item: { selectedRegions }, + } = this.props; if (selectedRegions && selectedRegions.length > 0) { selectedRegions.forEach((region) => { if (region.applyTransform) { // Get the transformation data from the transformer const nodes = this.transformer.nodes(); - const node = nodes.find(n => n.hasName(region.id)); + const node = nodes.find((n) => n.hasName(region.id)); if (node) { const dx = node.getAttr("x", 0); @@ -340,14 +343,16 @@ export default class TransformerComponent extends Component { onDragEnd={(e) => { console.log("ImageTransformer onDragEnd triggered"); // Apply translation to each selected region - const { item: { selectedRegions } } = this.props; + const { + item: { selectedRegions }, + } = this.props; if (selectedRegions && selectedRegions.length > 0) { selectedRegions.forEach((region) => { if (region.applyTransform) { // Get the transformation data from the transformer const nodes = this.transformer.nodes(); - const node = nodes.find(n => n.hasName(region.id)); + const node = nodes.find((n) => n.hasName(region.id)); if (node) { const dx = node.getAttr("x", 0); @@ -382,14 +387,16 @@ export default class TransformerComponent extends Component { onTransformEnd={(e) => { console.log("ImageTransformer onTransformEnd triggered"); // Apply transformation to each selected region - const { item: { selectedRegions } } = this.props; + const { + item: { selectedRegions }, + } = this.props; if (selectedRegions && selectedRegions.length > 0) { selectedRegions.forEach((region) => { if (region.applyTransform) { // Get the transformation data from the transformer const nodes = this.transformer.nodes(); - const node = nodes.find(n => n.hasName(region.id)); + const node = nodes.find((n) => n.hasName(region.id)); if (node) { const dx = node.getAttr("x", 0); diff --git a/web/libs/editor/src/regions/VectorRegion.jsx b/web/libs/editor/src/regions/VectorRegion.jsx index 3023a54a60c2..46dc451e52b1 100644 --- a/web/libs/editor/src/regions/VectorRegion.jsx +++ b/web/libs/editor/src/regions/VectorRegion.jsx @@ -650,52 +650,6 @@ const HtxVectorView = observer(({ item, suggestion }) => { onDblClick={(e) => { item.toggleTransformMode(); }} - onTransformEnd={(e) => { - console.log("transform end"); - if (!isMultiRegionSelected) return; - if (e.target !== e.currentTarget) return; - - const t = e.target; - const dx = t.getAttr("x", 0); - const dy = t.getAttr("y", 0); - const scaleX = t.getAttr("scaleX", 1); - const scaleY = t.getAttr("scaleY", 1); - const rotation = t.getAttr("rotation", 0); - - // Get the bounding box center for rotation/scale origin - const bbox = item.bbox; - if (!bbox) return; - - // Convert bbox center to image coordinates (KonvaVector uses image coords) - const centerX = item.parent.internalToImageX((bbox.left + bbox.right) / 2); - const centerY = item.parent.internalToImageY((bbox.top + bbox.bottom) / 2); - - // Apply transformation using KonvaVector ref methods - if (item.vectorRef) { - // Convert canvas coordinates to image coordinates (KonvaVector uses image coords) - const imageDx = item.parent.canvasToInternalX(dx); - const imageDy = item.parent.canvasToInternalY(dy); - - // Use transformPoints method to apply all transformations at once - // This ensures onPointsChange is called only once with the final result - item.vectorRef.transformPoints({ - dx: imageDx, - dy: imageDy, - rotation: rotation, - scaleX: scaleX, - scaleY: scaleY, - centerX: centerX, - centerY: centerY, - }); - } - - // Reset transform attributes - t.setAttr("x", 0); - t.setAttr("y", 0); - t.setAttr("scaleX", 1); - t.setAttr("scaleY", 1); - t.setAttr("rotation", 0); - }} selectedPoints={item.annotation.regionStore.selection.size > 1 ? [] : item.selectedVertices} closed={item.closed} width={stageWidth} From 534c70b203dae7fb9b8e1b04423349ec0c1bcc6e Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Tue, 14 Oct 2025 15:01:27 +0100 Subject: [PATCH 10/32] Reset everything --- .../ImageTransformer/ImageTransformer.jsx | 167 +---- .../components/KonvaVector/KonvaVector.tsx | 642 ++++++------------ .../KonvaVector/VectorSelectionTracker.ts | 181 +++++ .../KonvaVector/components/VectorShape.tsx | 12 +- .../src/components/KonvaVector/constants.ts | 1 - .../eventHandlers/mouseHandlers.ts | 15 +- .../eventHandlers/pointSelection.ts | 41 +- .../KonvaVector/eventHandlers/types.ts | 1 + .../src/components/KonvaVector/types.ts | 12 - web/libs/editor/src/regions/VectorRegion.jsx | 104 +-- web/libs/editor/src/tools/Vector.js | 1 - 11 files changed, 451 insertions(+), 726 deletions(-) create mode 100644 web/libs/editor/src/components/KonvaVector/VectorSelectionTracker.ts diff --git a/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx b/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx index 619cca214203..987b03211ab7 100644 --- a/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx +++ b/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx @@ -213,90 +213,11 @@ export default class TransformerComponent extends Component { }; }} dragBoundFunc={this.dragBoundFunc} - onDragEnd={(e) => { - console.log("ImageTransformer onDragEnd triggered"); - // Apply translation to each selected region - const { - item: { selectedRegions }, - } = this.props; - - if (selectedRegions && selectedRegions.length > 0) { - selectedRegions.forEach((region) => { - if (region.applyTransform) { - // Get the transformation data from the transformer - const nodes = this.transformer.nodes(); - const node = nodes.find((n) => n.hasName(region.id)); - - if (node) { - const dx = node.getAttr("x", 0); - const dy = node.getAttr("y", 0); - const scaleX = node.getAttr("scaleX", 1); - const scaleY = node.getAttr("scaleY", 1); - const rotation = node.getAttr("rotation", 0); - - // Call the region's applyTransform method - region.applyTransform({ - dx, - dy, - scaleX, - scaleY, - rotation, - }); - - // Reset transform attributes - node.setAttr("x", 0); - node.setAttr("y", 0); - node.setAttr("scaleX", 1); - node.setAttr("scaleY", 1); - node.setAttr("rotation", 0); - } - } - }); - } - + onDragEnd={() => { this.unfreeze(); setTimeout(this.checkNode); }} - onTransformEnd={(e) => { - // Apply transformation to each selected region - const { - item: { selectedRegions }, - } = this.props; - - if (selectedRegions && selectedRegions.length > 0) { - selectedRegions.forEach((region) => { - if (region.applyTransform) { - // Get the transformation data from the transformer - const nodes = this.transformer.nodes(); - const node = nodes.find((n) => n.hasName(region.id)); - - if (node) { - const dx = node.getAttr("x", 0); - const dy = node.getAttr("y", 0); - const scaleX = node.getAttr("scaleX", 1); - const scaleY = node.getAttr("scaleY", 1); - const rotation = node.getAttr("rotation", 0); - - // Call the region's applyTransform method - region.applyTransform({ - dx, - dy, - scaleX, - scaleY, - rotation, - }); - - // Reset transform attributes - node.setAttr("x", 0); - node.setAttr("y", 0); - node.setAttr("scaleX", 1); - node.setAttr("scaleY", 1); - node.setAttr("rotation", 0); - } - } - }); - } - + onTransformEnd={() => { setTimeout(this.checkNode); }} backSelector={this.props.draggableBackgroundSelector} @@ -340,91 +261,11 @@ export default class TransformerComponent extends Component { }; }} dragBoundFunc={this.dragBoundFunc} - onDragEnd={(e) => { - console.log("ImageTransformer onDragEnd triggered"); - // Apply translation to each selected region - const { - item: { selectedRegions }, - } = this.props; - - if (selectedRegions && selectedRegions.length > 0) { - selectedRegions.forEach((region) => { - if (region.applyTransform) { - // Get the transformation data from the transformer - const nodes = this.transformer.nodes(); - const node = nodes.find((n) => n.hasName(region.id)); - - if (node) { - const dx = node.getAttr("x", 0); - const dy = node.getAttr("y", 0); - const scaleX = node.getAttr("scaleX", 1); - const scaleY = node.getAttr("scaleY", 1); - const rotation = node.getAttr("rotation", 0); - - // Call the region's applyTransform method - region.applyTransform({ - dx, - dy, - scaleX, - scaleY, - rotation, - }); - - // Reset transform attributes - node.setAttr("x", 0); - node.setAttr("y", 0); - node.setAttr("scaleX", 1); - node.setAttr("scaleY", 1); - node.setAttr("rotation", 0); - } - } - }); - } - + onDragEnd={() => { this.unfreeze(); setTimeout(this.checkNode); }} - onTransformEnd={(e) => { - console.log("ImageTransformer onTransformEnd triggered"); - // Apply transformation to each selected region - const { - item: { selectedRegions }, - } = this.props; - - if (selectedRegions && selectedRegions.length > 0) { - selectedRegions.forEach((region) => { - if (region.applyTransform) { - // Get the transformation data from the transformer - const nodes = this.transformer.nodes(); - const node = nodes.find((n) => n.hasName(region.id)); - - if (node) { - const dx = node.getAttr("x", 0); - const dy = node.getAttr("y", 0); - const scaleX = node.getAttr("scaleX", 1); - const scaleY = node.getAttr("scaleY", 1); - const rotation = node.getAttr("rotation", 0); - - // Call the region's applyTransform method - region.applyTransform({ - dx, - dy, - scaleX, - scaleY, - rotation, - }); - - // Reset transform attributes - node.setAttr("x", 0); - node.setAttr("y", 0); - node.setAttr("scaleX", 1); - node.setAttr("scaleY", 1); - node.setAttr("rotation", 0); - } - } - }); - } - + onTransformEnd={() => { setTimeout(this.checkNode); }} backSelector={this.props.draggableBackgroundSelector} diff --git a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx index 20e0dd3fd5f0..4061d59d92c4 100644 --- a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx +++ b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx @@ -15,6 +15,7 @@ import { convertPoint } from "./pointManagement"; import { normalizePoints, convertBezierToSimplePoints, isPointInPolygon } from "./utils"; import { findClosestPointOnPath, getDistance } 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 type { BezierPoint, GhostPoint as GhostPointType, KonvaVectorProps, KonvaVectorRef } from "./types"; @@ -34,7 +35,6 @@ import { HIT_RADIUS, TRANSFORMER_SETUP_DELAY, TRANSFORMER_CLEAR_DELAY, - CLICK_DELAY, MIN_POINTS_FOR_CLOSING, MIN_POINTS_FOR_BEZIER_CLOSING, INVISIBLE_SHAPE_OPACITY, @@ -215,8 +215,6 @@ export const KonvaVector = forwardRef((props, onPathClosedChange, onTransformationComplete, onPointSelected, - selectedPoints: externalSelectedPoints, - onSelectionChange, onFinish, scaleX, scaleY, @@ -231,10 +229,8 @@ export const KonvaVector = forwardRef((props, onMouseMove, onMouseUp, onClick, - onDblClick, onMouseEnter, onMouseLeave, - onTransformEnd, allowClose = false, closed, allowBezier = true, @@ -247,8 +243,6 @@ export const KonvaVector = forwardRef((props, pixelSnapping = false, disabled = false, constrainToBounds = false, - transformMode = false, - name, pointRadius, pointFill = DEFAULT_POINT_FILL, pointStroke = DEFAULT_POINT_STROKE, @@ -276,39 +270,9 @@ export const KonvaVector = forwardRef((props, } }, [initialPoints.length, skeletonEnabled]); // Only run when the number of points changes or skeleton mode changes - // Internal selection state - const [internalSelectedPointIndex, setInternalSelectedPointIndex] = useState(null); - const [internalSelectedPoints, setInternalSelectedPoints] = useState>(new Set()); - - // Use external selection if provided, otherwise use internal state - const selectedPointIndex = externalSelectedPoints ? - (externalSelectedPoints.length === 1 ? - initialPoints.findIndex(p => p.id === externalSelectedPoints[0]) : null) : - internalSelectedPointIndex; - - const selectedPoints = externalSelectedPoints ? - new Set(externalSelectedPoints.map(id => initialPoints.findIndex(p => p.id === id)).filter(i => i !== -1)) : - internalSelectedPoints; - - // Transform mode creates virtual selection (doesn't affect real selection state) - const virtualSelectedPoints = transformMode && initialPoints.length > 0 ? - new Set(Array.from({ length: initialPoints.length }, (_, i) => i)) : - selectedPoints; - - // Helper functions to update selection (works with both internal and external state) - const updateSelection = useCallback((newSelectedPoints: Set, newSelectedPointIndex: number | null) => { - if (externalSelectedPoints) { - // External control - notify parent - const selectedIds = Array.from(newSelectedPoints).map(i => initialPoints[i]?.id).filter(Boolean); - onSelectionChange?.(selectedIds, newSelectedPointIndex); - } else { - // Internal control - update internal state - setInternalSelectedPoints(newSelectedPoints); - setInternalSelectedPointIndex(newSelectedPointIndex); - } - onPointSelected?.(newSelectedPointIndex); - }, [externalSelectedPoints, initialPoints, onSelectionChange, onPointSelected]); - + // Use initialPoints directly - this will update when the parent re-renders + const [selectedPointIndex, setSelectedPointIndex] = useState(null); + const [selectedPoints, setSelectedPoints] = useState>(new Set()); const [lastAddedPointId, setLastAddedPointId] = useState(null); const transformerRef = useRef(null); @@ -377,24 +341,12 @@ export const KonvaVector = forwardRef((props, // Flag to track if point selection was handled in VectorPoints onClick const pointSelectionHandled = useRef(false); - // Flag to track if VectorShape already handled the click/double-click - const shapeHandledClick = useRef(false); - - // Timeout ref for delayed click execution (to prevent clicks during double-click) - const clickTimeoutRef = useRef | null>(null); - - // Cleanup click timeout on unmount - useEffect(() => { - return () => { - if (clickTimeoutRef.current) { - clearTimeout(clickTimeoutRef.current); - } - }; - }, []); - // Initialize PointCreationManager instance const pointCreationManager = useMemo(() => new PointCreationManager(), []); + // Initialize VectorSelectionTracker + const tracker = useMemo(() => VectorSelectionTracker.getInstance(), []); + // Compute if path is closed based on point references // A path is closed if the first point's prevPointId points to the last point const isPathClosed = useMemo(() => { @@ -436,10 +388,9 @@ export const KonvaVector = forwardRef((props, // Determine if drawing should be disabled based on current interaction context const isDrawingDisabled = () => { // Disable all interactions when disabled prop is true - // Disable drawing when in transform mode // Disable drawing when Shift is held (for Shift+click functionality) // Disable drawing when multiple points are selected - if (disabled || transformMode || isShiftKeyHeld || selectedPoints.size > SELECTION_SIZE.MULTI_SELECTION_MIN) { + if (disabled || isShiftKeyHeld || selectedPoints.size > SELECTION_SIZE.MULTI_SELECTION_MIN) { return true; } @@ -536,10 +487,66 @@ export const KonvaVector = forwardRef((props, } }, [drawingDisabled]); + // Stabilize functions for tracker registration + const getPoints = useCallback(() => initialPoints, [initialPoints]); + const updatePoints = useCallback( + (points: BezierPoint[]) => { + setInitialPoints(points); + onPointsChange?.(points); + }, + [onPointsChange], + ); + const setSelectedPointsStable = useCallback((selectedPoints: Set) => { + setSelectedPoints(selectedPoints); + }, []); + const setSelectedPointIndexStable = useCallback((index: number | null) => { + setSelectedPointIndex(index); + }, []); + const getTransformStable = useCallback(() => transform, [transform]); + const getFitScaleStable = useCallback(() => fitScale, [fitScale]); + const getBoundsStable = useCallback(() => ({ width, height }), [width, height]); + + // Register instance with tracker + useEffect(() => { + const vectorInstance: VectorInstance = { + id: instanceId, + getPoints, + updatePoints, + setSelectedPoints: setSelectedPointsStable, + setSelectedPointIndex: setSelectedPointIndexStable, + onPointSelected, + onTransformationComplete, + getTransform: getTransformStable, + getFitScale: getFitScaleStable, + getBounds: getBoundsStable, + constrainToBounds, + }; + + tracker.registerInstance(vectorInstance); + + return () => { + tracker.unregisterInstance(instanceId); + }; + }, [ + instanceId, + tracker, + getPoints, + updatePoints, + setSelectedPointsStable, + setSelectedPointIndexStable, + onPointSelected, + onTransformationComplete, + getTransformStable, + getFitScaleStable, + getBoundsStable, + constrainToBounds, + ]); + // Clear selection when component is disabled useEffect(() => { if (disabled) { - updateSelection(new Set(), null); + setSelectedPointIndex(null); + setSelectedPoints(new Set()); setVisibleControlPoints(new Set()); setDraggedControlPoint(null); setGhostPoint(null); @@ -549,23 +556,20 @@ export const KonvaVector = forwardRef((props, // Hide all Bezier control points when disabled setVisibleControlPoints(new Set()); } - }, [disabled, updateSelection]); - - // Transform mode is now virtual - no need to actually select points - + }, [disabled]); const lastPos = useRef<{ x: number; y: number } | null>(null); // Set up Transformer nodes once when selection changes useEffect(() => { if (transformerRef.current) { - if (virtualSelectedPoints.size > SELECTION_SIZE.MULTI_SELECTION_MIN) { + if (selectedPoints.size > SELECTION_SIZE.MULTI_SELECTION_MIN) { // Use setTimeout to ensure proxy nodes are rendered first setTimeout(() => { if (transformerRef.current) { // Set up proxy nodes once - transformer will manage them independently // Use getAllPoints() to get the correct proxy nodes for all points const allPoints = getAllPoints(); - const nodes = Array.from(virtualSelectedPoints) + const nodes = Array.from(selectedPoints) .map((index) => { // Ensure the index is within bounds of all points if (index < allPoints.length) { @@ -592,7 +596,7 @@ export const KonvaVector = forwardRef((props, }, TRANSFORMER_CLEAR_DELAY); } } - }, [virtualSelectedPoints]); // Only depend on virtualSelectedPoints, not initialPoints + }, [selectedPoints]); // Only depend on selectedPoints, not initialPoints // Note: We don't update proxy node positions during transformation // The transformer handles positioning the proxy nodes itself @@ -911,6 +915,11 @@ export const KonvaVector = forwardRef((props, return true; }, selectPointsByIds: (pointIds: string[]) => { + // Check if this instance can have selection + if (!tracker.canInstanceHaveSelection(instanceId)) { + return; // Block the selection + } + // Find the indices of the points with the given IDs const selectedIndices = new Set(); let primarySelectedIndex: number | null = null; @@ -925,12 +934,12 @@ export const KonvaVector = forwardRef((props, } } - // Update selection using the unified system - updateSelection(selectedIndices, selectedIndices.size === 1 ? primarySelectedIndex : null); + // Use tracker for global selection management + tracker.selectPoints(instanceId, selectedIndices); }, clearSelection: () => { - // Clear selection using the unified system - updateSelection(new Set(), null); + // Use tracker for global selection management + tracker.selectPoints(instanceId, new Set()); }, getSelectedPointIds: () => { const selectedIds: string[] = []; @@ -1395,20 +1404,15 @@ export const KonvaVector = forwardRef((props, // Create event handlers const eventHandlers = createEventHandlers({ + instanceId, initialPoints, width, height, pixelSnapping, selectedPoints, selectedPointIndex, - setSelectedPointIndex: (index) => { - const newSelectedPoints = index !== null ? new Set([index]) : new Set(); - updateSelection(newSelectedPoints, index); - }, - setSelectedPoints: (points) => { - const newSelectedPointIndex = points.size === 1 ? Array.from(points)[0] : null; - updateSelection(points, newSelectedPointIndex); - }, + setSelectedPointIndex, + setSelectedPoints, setDraggedPointIndex, setDraggedControlPoint, setIsDisconnectedMode, @@ -1490,42 +1494,7 @@ export const KonvaVector = forwardRef((props, pointSelectionHandled.current = false; return; } - - // Skip if VectorShape already handled the click - if (shapeHandledClick.current) { - shapeHandledClick.current = false; - return; - } - - // Clear any existing click timeout - if (clickTimeoutRef.current) { - clearTimeout(clickTimeoutRef.current); - } - - // Delay click execution to check if it's part of a double-click - clickTimeoutRef.current = setTimeout(() => { - eventHandlers.handleLayerClick(e); - }, CLICK_DELAY); - } - } - onDblClick={ - disabled - ? undefined - : (e) => { - // Skip if VectorShape already handled the double-click - if (shapeHandledClick.current) { - shapeHandledClick.current = false; - return; - } - - // Clear the pending click timeout to prevent single click from executing - if (clickTimeoutRef.current) { - clearTimeout(clickTimeoutRef.current); - clickTimeoutRef.current = null; - } - - // Execute double click handler - onDblClick?.(e); + eventHandlers.handleLayerClick(e); } } > @@ -1542,249 +1511,67 @@ export const KonvaVector = forwardRef((props, )} {/* Unified vector shape - renders all lines based on id-prevPointId relationships */} - {name?.includes('_transformable') ? ( - - { - // Mark that VectorShape handled the click - shapeHandledClick.current = true; - - // Clear any existing click timeout - if (clickTimeoutRef.current) { - clearTimeout(clickTimeoutRef.current); - } - - // Delay click execution to check if it's part of a double-click - clickTimeoutRef.current = setTimeout(() => { - // Check if click is on the last added point by checking cursor position - if (cursorPosition && 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 distance = Math.sqrt( - (cursorPosition.x - lastAddedPoint.x) ** 2 + (cursorPosition.y - lastAddedPoint.y) ** 2, - ); - - if (distance <= hitRadius) { - // Find the index of the last added point - const lastAddedPointIndex = initialPoints.findIndex((p) => p.id === lastAddedPointId); - - // 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 && selectedPoints.has(lastAddedPointIndex) && !disabled) { - const hasModifiers = e.evt.ctrlKey || e.evt.metaKey || e.evt.shiftKey || e.evt.altKey; - if (!hasModifiers) { - onFinish?.(e); - return; - } - // If modifiers are held, skip onFinish entirely and let normal modifier handling take over - return; - } - } - } - } - - // Call the event handler (for drawing/interaction logic) - eventHandlers.handleLayerClick(e); - - // Call the original onClick handler (custom callback) - onClick?.(e); - }, CLICK_DELAY); - }} - onDblClick={(e) => { - // Mark that VectorShape handled the double-click - shapeHandledClick.current = true; - - // Clear the pending click timeout to prevent single click from executing - if (clickTimeoutRef.current) { - clearTimeout(clickTimeoutRef.current); - clickTimeoutRef.current = null; - } - - // Execute double click handler - onDblClick?.(e); - }} - onMouseEnter={onMouseEnter} - onMouseLeave={onMouseLeave} - key={`vector-shape-${initialPoints.length}-${initialPoints.map((p) => p.id).join("-")}`} - /> - - {/* All vector points - inside the same _transformable group */} - { - // Handle point selection even when disabled (similar to shape clicks) - if (disabled) { - // Check if we're about to close the path - prevent point selection in this case - if ( - shouldClosePathOnPointClick( - pointIndex, - { - initialPoints, - allowClose, - isPathClosed: finalIsPathClosed, - skeletonEnabled, - activePointId, - } as any, - e, - ) && - isActivePointEligibleForClosing({ - initialPoints, - skeletonEnabled, - activePointId, - } as any) - ) { - // Use the bidirectional closePath function - const success = (ref as React.MutableRefObject)?.current?.close(); - if (success) { - return; // Path was closed, don't select the point - } - } + { + // Handle cmd-click to select all points + if ((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 is the last added point and already selected (second click) - const isLastAddedPoint = lastAddedPointId && initialPoints[pointIndex]?.id === lastAddedPointId; - const isAlreadySelected = selectedPoints.has(pointIndex); + // Select all points in the path + const allPointIndices = Array.from({ length: initialPoints.length }, (_, i) => i); + tracker.selectPoints(instanceId, new Set(allPointIndices)); + return; + } - // Only fire onFinish if this is the last added point AND it was already selected (second click) + // Check if click is on the last added point by checking cursor position + if (cursorPosition && 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 distance = Math.sqrt( + (cursorPosition.x - lastAddedPoint.x) ** 2 + (cursorPosition.y - lastAddedPoint.y) ** 2, + ); + + if (distance <= hitRadius) { + // Find the index of the last added point + const lastAddedPointIndex = initialPoints.findIndex((p) => p.id === lastAddedPointId); + + // 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 (isLastAddedPoint && isAlreadySelected && !disabled) { + if (lastAddedPointIndex !== -1 && selectedPoints.has(lastAddedPointIndex) && !disabled) { const hasModifiers = e.evt.ctrlKey || e.evt.metaKey || e.evt.shiftKey || e.evt.altKey; if (!hasModifiers) { + e.evt.preventDefault(); 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; } - - // Handle regular point selection - if (e.evt.ctrlKey || e.evt.metaKey) { - // Add to multi-selection - const newSelection = new Set(selectedPoints); - newSelection.add(pointIndex); - updateSelection(newSelection, null); - } else { - // Select only this point - updateSelection(new Set([pointIndex]), pointIndex); - } - - // Call the original onClick handler if provided - onClick?.(e); - - // Mark that we handled selection and prevent all other handlers from running - pointSelectionHandled.current = true; - e.evt.stopImmediatePropagation(); - return; } - - // When not disabled, let the normal event handlers handle it - // The point click will be detected by the layer-level handlers - // - }} - /> - - ) : ( - { - // Mark that VectorShape handled the click - shapeHandledClick.current = true; - - // Clear any existing click timeout - if (clickTimeoutRef.current) { - clearTimeout(clickTimeoutRef.current); - } - - // Delay click execution to check if it's part of a double-click - clickTimeoutRef.current = setTimeout(() => { - // Check if click is on the last added point by checking cursor position - if (cursorPosition && 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 distance = Math.sqrt( - (cursorPosition.x - lastAddedPoint.x) ** 2 + (cursorPosition.y - lastAddedPoint.y) ** 2, - ); - - if (distance <= hitRadius) { - // Find the index of the last added point - const lastAddedPointIndex = initialPoints.findIndex((p) => p.id === lastAddedPointId); - - // 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 && selectedPoints.has(lastAddedPointIndex) && !disabled) { - const hasModifiers = e.evt.ctrlKey || e.evt.metaKey || e.evt.shiftKey || e.evt.altKey; - if (!hasModifiers) { - onFinish?.(e); - return; - } - // If modifiers are held, skip onFinish entirely and let normal modifier handling take over - return; - } - } - } - } - - // Call the event handler (for drawing/interaction logic) - eventHandlers.handleLayerClick(e); - - // Call the original onClick handler (custom callback) - onClick?.(e); - }, CLICK_DELAY); - }} - onDblClick={(e) => { - // Mark that VectorShape handled the double-click - shapeHandledClick.current = true; - - // Clear the pending click timeout to prevent single click from executing - if (clickTimeoutRef.current) { - clearTimeout(clickTimeoutRef.current); - clickTimeoutRef.current = null; } + } - // Execute double click handler - onDblClick?.(e); - }} - onMouseEnter={onMouseEnter} - onMouseLeave={onMouseLeave} - key={`vector-shape-${initialPoints.length}-${initialPoints.map((p) => p.id).join("-")}`} - /> - )} + // Call the original onClick handler + onClick?.(e); + }} + onMouseEnter={onMouseEnter} + onMouseLeave={onMouseLeave} + key={`vector-shape-${initialPoints.length}-${initialPoints.map((p) => p.id).join("-")}`} + /> {/* Ghost line - preview from last point to cursor */} ((props, /> )} - {/* All vector points - only render when not in multi-region mode */} - {!name?.includes('_transformable') && ( - { - // Handle point selection even when disabled (similar to shape clicks) - if (disabled) { - // Check if we're about to close the path - prevent point selection in this case - if ( - shouldClosePathOnPointClick( - pointIndex, - { - initialPoints, - allowClose, - isPathClosed: finalIsPathClosed, - skeletonEnabled, - activePointId, - } as any, - e, - ) && - isActivePointEligibleForClosing({ + {/* All vector points */} + { + // Handle point selection even when disabled (similar to shape clicks) + if (disabled) { + // Check if this instance can have selection + if (!tracker.canInstanceHaveSelection(instanceId)) { + return; // Block the selection + } + + // Check if we're about to close the path - prevent point selection in this case + if ( + shouldClosePathOnPointClick( + pointIndex, + { initialPoints, + allowClose, + isPathClosed: finalIsPathClosed, skeletonEnabled, activePointId, - } as any) - ) { - // Use the bidirectional closePath function - const success = (ref as React.MutableRefObject)?.current?.close(); - if (success) { - return; // Path was closed, don't select the point - } + } as any, + e, + ) && + isActivePointEligibleForClosing({ + initialPoints, + skeletonEnabled, + activePointId, + } as any) + ) { + // Use the bidirectional closePath function + const success = (ref as React.MutableRefObject)?.current?.close(); + if (success) { + return; // Path was closed, don't select the point } + } - // Check if this is the last added point and already selected (second click) - const isLastAddedPoint = lastAddedPointId && initialPoints[pointIndex]?.id === lastAddedPointId; - const isAlreadySelected = selectedPoints.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 - return; - } - // If modifiers are held, skip onFinish entirely and let normal modifier handling take over + // Handle cmd-click to select all points + if ((e.evt.ctrlKey || e.evt.metaKey) && !e.evt.altKey && !e.evt.shiftKey) { + // Select all points in the path + const allPointIndices = Array.from({ length: initialPoints.length }, (_, i) => i); + tracker.selectPoints(instanceId, new Set(allPointIndices)); + pointSelectionHandled.current = true; // Mark that we handled selection + e.evt.stopImmediatePropagation(); // Prevent all other handlers from running + return; + } + + // Check if this is the last added point and already selected (second click) + const isLastAddedPoint = lastAddedPointId && initialPoints[pointIndex]?.id === lastAddedPointId; + const isAlreadySelected = selectedPoints.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 return; } + // If modifiers are held, skip onFinish entirely and let normal modifier handling take over + return; + } - // Handle regular point selection - if (e.evt.ctrlKey || e.evt.metaKey) { - // Add to multi-selection - const newSelection = new Set(selectedPoints); - newSelection.add(pointIndex); - updateSelection(newSelection, null); - } else { - // Select only this point - updateSelection(new Set([pointIndex]), pointIndex); - } + // Handle regular point selection + if (e.evt.ctrlKey || e.evt.metaKey) { + // Add to multi-selection + const newSelection = new Set(selectedPoints); + newSelection.add(pointIndex); + tracker.selectPoints(instanceId, newSelection); + } else { + // Select only this point + tracker.selectPoints(instanceId, new Set([pointIndex])); + } - // Call the original onClick handler if provided - onClick?.(e); + // Call the original onClick handler if provided + onClick?.(e); - // Mark that we handled selection and prevent all other handlers from running - pointSelectionHandled.current = true; - e.evt.stopImmediatePropagation(); - return; - } + // Mark that we handled selection and prevent all other handlers from running + pointSelectionHandled.current = true; + e.evt.stopImmediatePropagation(); + return; + } - // When not disabled, let the normal event handlers handle it - // The point click will be detected by the layer-level handlers - // - }} - /> - )} + // When not disabled, let the normal event handlers handle it + // The point click will be detected by the layer-level handlers + // + }} + /> {/* Proxy nodes for Transformer (positioned at exact point centers) - only show when not in drawing mode */} {drawingDisabled && ( - + )} {/* Transformer for multiselection - only show when not in drawing mode */} {drawingDisabled && ( >; // instanceId -> selected point indices + activeInstanceId: string | null; + isTransforming: boolean; + transformerState: { + rotation: number; + scaleX: number; + scaleY: number; + centerX: number; + centerY: number; + } | null; +} + +export interface VectorInstance { + id: string; + getPoints: () => BezierPoint[]; + updatePoints: (points: BezierPoint[]) => void; + setSelectedPoints: (selectedPoints: Set) => void; + setSelectedPointIndex: (index: number | null) => void; + onPointSelected?: (index: number | null) => void; + onTransformationComplete?: (data: any) => void; + getTransform: () => { zoom: number; offsetX: number; offsetY: number }; + getFitScale: () => number; + getBounds: () => { width: number; height: number } | undefined; + constrainToBounds?: boolean; +} + +export class VectorSelectionTracker { + private static instance: VectorSelectionTracker | null = null; + private state: GlobalSelectionState = { + selectedInstances: new Map(), + activeInstanceId: null, + isTransforming: false, + transformerState: null, + }; + private instances: Map = new Map(); + private listeners: Set<(state: GlobalSelectionState) => void> = new Set(); + + private constructor() {} + + static getInstance(): VectorSelectionTracker { + if (!VectorSelectionTracker.instance) { + VectorSelectionTracker.instance = new VectorSelectionTracker(); + } + return VectorSelectionTracker.instance; + } + + // Instance Management + registerInstance(instance: VectorInstance): void { + this.instances.set(instance.id, instance); + } + + unregisterInstance(instanceId: string): void { + this.instances.delete(instanceId); + this.state.selectedInstances.delete(instanceId); + + // If this was the active instance, clear it + if (this.state.activeInstanceId === instanceId) { + this.state.activeInstanceId = null; + } + + this.notifyListeners(); + } + + // Selection Management + selectPoints(instanceId: string, pointIndices: Set): void { + // If trying to select points and there's already an active instance that's different + if (pointIndices.size > 0 && this.state.activeInstanceId && this.state.activeInstanceId !== instanceId) { + return; // Block the selection + } + + if (pointIndices.size === 0) { + this.state.selectedInstances.delete(instanceId); + // If this was the active instance and we're clearing selection, clear active instance + if (this.state.activeInstanceId === instanceId) { + this.state.activeInstanceId = null; + } + } else { + this.state.selectedInstances.set(instanceId, new Set(pointIndices)); + // Set this as the active instance (first to select wins) + this.state.activeInstanceId = instanceId; + } + + this.notifyListeners(); + + // Update the instance's local selection state + const instance = this.instances.get(instanceId); + if (instance) { + instance.setSelectedPoints(pointIndices); + instance.setSelectedPointIndex(pointIndices.size === 1 ? Array.from(pointIndices)[0] : null); + instance.onPointSelected?.(pointIndices.size === 1 ? Array.from(pointIndices)[0] : null); + } + } + + // Check if an instance can have selection + canInstanceHaveSelection(instanceId: string): boolean { + return this.state.activeInstanceId === null || this.state.activeInstanceId === instanceId; + } + + // Get the currently active instance ID + getActiveInstanceId(): string | null { + return this.state.activeInstanceId; + } + + clearSelection(): void { + // Clear all instance selections + for (const [instanceId, instance] of this.instances) { + instance.setSelectedPoints(new Set()); + instance.setSelectedPointIndex(null); + instance.onPointSelected?.(null); + } + + this.state.selectedInstances.clear(); + this.state.activeInstanceId = null; + this.notifyListeners(); + } + + getGlobalSelection(): GlobalSelectionState { + return { ...this.state }; + } + + getSelectedPoints(): Array<{ instanceId: string; pointIndex: number; point: BezierPoint }> { + const selectedPoints: Array<{ instanceId: string; pointIndex: number; point: BezierPoint }> = []; + + for (const [instanceId, pointIndices] of this.state.selectedInstances) { + const instance = this.instances.get(instanceId); + if (instance) { + const points = instance.getPoints(); + for (const pointIndex of pointIndices) { + if (pointIndex < points.length) { + selectedPoints.push({ + instanceId, + pointIndex, + point: points[pointIndex], + }); + } + } + } + } + + return selectedPoints; + } + + // Event Listeners + subscribe(listener: (state: GlobalSelectionState) => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + private notifyListeners(): void { + const globalState = this.getGlobalSelection(); + for (const listener of this.listeners) { + listener(globalState); + } + } + + // Utility Methods + hasSelection(): boolean { + return this.state.selectedInstances.size > 0; + } + + getSelectionCount(): number { + let count = 0; + for (const pointIndices of this.state.selectedInstances.values()) { + count += pointIndices.size; + } + return count; + } + + isInstanceSelected(instanceId: string): boolean { + return this.state.selectedInstances.has(instanceId); + } + + getInstanceSelection(instanceId: string): Set | undefined { + return this.state.selectedInstances.get(instanceId); + } +} diff --git a/web/libs/editor/src/components/KonvaVector/components/VectorShape.tsx b/web/libs/editor/src/components/KonvaVector/components/VectorShape.tsx index eb3d8434fc6b..deace4ab7ceb 100644 --- a/web/libs/editor/src/components/KonvaVector/components/VectorShape.tsx +++ b/web/libs/editor/src/components/KonvaVector/components/VectorShape.tsx @@ -4,7 +4,7 @@ import type { BezierPoint } from "../types"; import chroma from "chroma-js"; import type { KonvaEventObject } from "konva/lib/Node"; -export interface VectorShapeProps { +interface VectorShapeProps { segments: Array<{ from: BezierPoint; to: BezierPoint }>; allowClose?: boolean; isPathClosed?: boolean; @@ -14,12 +14,9 @@ export interface VectorShapeProps { opacity?: number; transform?: { zoom: number; offsetX: number; offsetY: number }; fitScale?: number; - name?: string; onClick?: (e: KonvaEventObject) => void; - onDblClick?: (e: KonvaEventObject) => void; onMouseEnter?: (e: any) => void; onMouseLeave?: (e: any) => void; - onTransformEnd?: (e: KonvaEventObject) => void; } // Convert Bezier segments to SVG path data for a single continuous path @@ -214,12 +211,9 @@ export const VectorShape: React.FC = ({ opacity = 1, transform = { zoom: 1, offsetX: 0, offsetY: 0 }, fitScale = 1, - name, onClick, - onDblClick, onMouseEnter, onMouseLeave, - onTransformEnd, }) => { if (segments.length === 0) return null; @@ -276,7 +270,6 @@ export const VectorShape: React.FC = ({ fill={undefined} // No fill for individual segments hitStrokeWidth={20} onClick={onClick} - onDblClick={onDblClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} /> @@ -299,7 +292,6 @@ export const VectorShape: React.FC = ({ return ( = ({ fill={fillWithOpacity} hitStrokeWidth={20} onClick={onClick} - onDblClick={onDblClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} - onTransformEnd={index === 0 ? onTransformEnd : undefined} /> ); })} diff --git a/web/libs/editor/src/components/KonvaVector/constants.ts b/web/libs/editor/src/components/KonvaVector/constants.ts index 005541ddf798..d20bdc70b2d0 100644 --- a/web/libs/editor/src/components/KonvaVector/constants.ts +++ b/web/libs/editor/src/components/KonvaVector/constants.ts @@ -59,7 +59,6 @@ export const HIT_RADIUS = { // Timing constants export const TRANSFORMER_SETUP_DELAY = 0; export const TRANSFORMER_CLEAR_DELAY = 10; -export const CLICK_DELAY = 200; // Delay for distinguishing click from double-click // Point count constraints export const MIN_POINTS_FOR_CLOSING = 2; diff --git a/web/libs/editor/src/components/KonvaVector/eventHandlers/mouseHandlers.ts b/web/libs/editor/src/components/KonvaVector/eventHandlers/mouseHandlers.ts index 071828292d47..990ca7783301 100644 --- a/web/libs/editor/src/components/KonvaVector/eventHandlers/mouseHandlers.ts +++ b/web/libs/editor/src/components/KonvaVector/eventHandlers/mouseHandlers.ts @@ -24,6 +24,7 @@ import { stageToImageCoordinates, } from "./utils"; import { constrainPointToBounds } from "../utils/boundsChecking"; +import { VectorSelectionTracker } from "../VectorSelectionTracker"; import { PointType } from "../types"; export function createMouseDownHandler(props: EventHandlerProps, handledSelectionInMouseDown: { current: boolean }) { @@ -269,10 +270,9 @@ export function createMouseDownHandler(props: EventHandlerProps, handledSelectio // If we get here, we're not clicking on anything specific // Handle deselection based on transformer state if (!e.evt.ctrlKey && !e.evt.metaKey) { - // Clear local selection state - props.setSelectedPoints?.(new Set()); - props.setSelectedPointIndex?.(null); - props.onPointSelected?.(null); + // Use tracker for global selection management + const tracker = VectorSelectionTracker.getInstance(); + tracker.selectPoints(props.instanceId || "unknown", new Set()); // Reset active point to the last physically added point when deselecting if (props.skeletonEnabled && props.initialPoints.length > 0) { @@ -991,10 +991,9 @@ function handlePointSelectionFromIndex( // For now, just do single selection since we don't have access to modifier keys in mouse up // Multi-selection will be handled by the existing point selection logic in mouse down - // Update local selection state - props.setSelectedPoints?.(new Set([pointIndex])); - props.setSelectedPointIndex?.(pointIndex); - props.onPointSelected?.(pointIndex); + // Use tracker for global selection management + const tracker = VectorSelectionTracker.getInstance(); + tracker.selectPoints(props.instanceId || "unknown", new Set([pointIndex])); // Update activePointId for skeleton mode - set the selected point as the active point if (pointIndex >= 0 && pointIndex < props.initialPoints.length) { diff --git a/web/libs/editor/src/components/KonvaVector/eventHandlers/pointSelection.ts b/web/libs/editor/src/components/KonvaVector/eventHandlers/pointSelection.ts index ba16476107db..b76089995a08 100644 --- a/web/libs/editor/src/components/KonvaVector/eventHandlers/pointSelection.ts +++ b/web/libs/editor/src/components/KonvaVector/eventHandlers/pointSelection.ts @@ -2,6 +2,7 @@ import type { KonvaEventObject } from "konva/lib/Node"; import type { EventHandlerProps } from "./types"; import { isPointInHitRadius, stageToImageCoordinates } from "./utils"; import { closePathBetweenFirstAndLast } from "./drawing"; +import { VectorSelectionTracker } from "../VectorSelectionTracker"; // Helper function to check if a point click should trigger path closing export function shouldClosePathOnPointClick( @@ -98,6 +99,14 @@ export function handlePointSelection(e: KonvaEventObject, props: Eve const scale = props.transform.zoom * props.fitScale; const hitRadius = 10 / scale; + // Get the tracker instance + const tracker = VectorSelectionTracker.getInstance(); + + // Check if this instance can have selection + if (!tracker.canInstanceHaveSelection(props.instanceId || "unknown")) { + return false; // Block selection in this instance + } + // Check if we clicked on any point for (let i = 0; i < props.initialPoints.length; i++) { const point = props.initialPoints[i]; @@ -145,19 +154,15 @@ export function handlePointSelection(e: KonvaEventObject, props: Eve const newSelection = new Set(currentSelection); newSelection.add(i); - // Update local selection state - props.setSelectedPoints?.(newSelection); - props.setSelectedPointIndex?.(null); // Multiple points selected - props.onPointSelected?.(null); + // Use tracker for global selection management + tracker.selectPoints(props.instanceId || "unknown", newSelection); return true; } // Handle skeleton mode point selection (when not multi-selecting) if (props.skeletonEnabled) { - // Update local selection state - props.setSelectedPoints?.(new Set([i])); - props.setSelectedPointIndex?.(i); - props.onPointSelected?.(i); + // Use tracker for global selection management + tracker.selectPoints(props.instanceId || "unknown", new Set([i])); // In skeleton mode, update the active point when selecting a different point // This ensures onFinish only fires for the currently selected point props.setActivePointId?.(point.id); @@ -165,10 +170,8 @@ export function handlePointSelection(e: KonvaEventObject, props: Eve } // If no Cmd/Ctrl and not skeleton mode, clear multi-selection and select only this point - // Update local selection state - props.setSelectedPoints?.(new Set([i])); - props.setSelectedPointIndex?.(i); - props.onPointSelected?.(i); + // Use tracker for global selection management + tracker.selectPoints(props.instanceId || "unknown", new Set([i])); // Return true to indicate we handled the selection return true; } @@ -186,6 +189,14 @@ export function handlePointDeselection(e: KonvaEventObject, props: E const scale = props.transform.zoom * props.fitScale; const hitRadius = 10 / scale; + // Get the tracker instance + const tracker = VectorSelectionTracker.getInstance(); + + // Check if this instance can have selection (deselection is allowed for the active instance) + if (!tracker.canInstanceHaveSelection(props.instanceId || "unknown")) { + return false; // Block deselection in this instance + } + // Check if we clicked on a selected point to unselect it for (let i = 0; i < props.initialPoints.length; i++) { if (props.selectedPoints.has(i)) { @@ -195,10 +206,8 @@ export function handlePointDeselection(e: KonvaEventObject, props: E const newSet = new Set(props.selectedPoints); newSet.delete(i); - // Update local selection state - props.setSelectedPoints?.(newSet); - props.setSelectedPointIndex?.(newSet.size === 1 ? Array.from(newSet)[0] : null); - props.onPointSelected?.(newSet.size === 1 ? Array.from(newSet)[0] : null); + // Use tracker for global selection management + tracker.selectPoints(props.instanceId || "unknown", newSet); // Handle skeleton mode reset if (newSet.size <= 1 && props.skeletonEnabled && props.initialPoints.length > 0) { diff --git a/web/libs/editor/src/components/KonvaVector/eventHandlers/types.ts b/web/libs/editor/src/components/KonvaVector/eventHandlers/types.ts index 0427b74e83ee..cbb4e7ee6a40 100644 --- a/web/libs/editor/src/components/KonvaVector/eventHandlers/types.ts +++ b/web/libs/editor/src/components/KonvaVector/eventHandlers/types.ts @@ -3,6 +3,7 @@ import type { BezierPoint, Point, GhostPoint } from "../types"; import type { PointType } from "../types"; export interface EventHandlerProps { + instanceId?: string; // Add instanceId for tracker integration initialPoints: BezierPoint[]; width: number; height: number; diff --git a/web/libs/editor/src/components/KonvaVector/types.ts b/web/libs/editor/src/components/KonvaVector/types.ts index bd732aec4ea1..a336c8df3d77 100644 --- a/web/libs/editor/src/components/KonvaVector/types.ts +++ b/web/libs/editor/src/components/KonvaVector/types.ts @@ -132,10 +132,6 @@ export interface KonvaVectorProps { }) => void; /** Called when a point is selected */ onPointSelected?: (pointIndex: number | null) => void; - /** Array of selected point IDs (controlled selection) */ - selectedPoints?: string[]; - /** Called when selection changes */ - onSelectionChange?: (selectedPointIds: string[], selectedPointIndex: number | null) => void; /** Called when drawing is finished (click on last point or double click on empty space) */ onFinish?: (e: KonvaEventObject) => void; /** Canvas width */ @@ -205,22 +201,14 @@ export interface KonvaVectorProps { onMouseUp?: (e?: KonvaEventObject) => void; /** Click event handler */ onClick?: (e: KonvaEventObject) => void; - /** Double click event handler */ - onDblClick?: (e: KonvaEventObject) => void; /** Mouse enter event handler */ onMouseEnter?: (e: KonvaEventObject) => void; /** Mouse leave event handler */ onMouseLeave?: (e: KonvaEventObject) => void; - /** Transform end event handler (for multi-region transformation) */ - onTransformEnd?: (e: KonvaEventObject) => void; /** Disable all interactions when true */ disabled?: boolean; /** Constrain points to stay within image bounds */ constrainToBounds?: boolean; - /** Enable transform mode - automatically selects all points and shows transformer */ - transformMode?: boolean; - /** Name to assign to the vector shape (for transformer identification) */ - name?: string; /** Ref to access component methods */ ref?: React.RefObject; } diff --git a/web/libs/editor/src/regions/VectorRegion.jsx b/web/libs/editor/src/regions/VectorRegion.jsx index 46dc451e52b1..663894e345fa 100644 --- a/web/libs/editor/src/regions/VectorRegion.jsx +++ b/web/libs/editor/src/regions/VectorRegion.jsx @@ -54,12 +54,6 @@ const Model = types // Internal flag to detect if we converted data back from relative points converted: false, - - // Transform mode -- virtual mode to allow transforming the shape as whole (rotate, resize, translate) - // - when transforming -- user can resize, translate or rotate entire shape (all points at once) - // - when NOT transforming -- user works on individual points, moving them, adding, removing, etc. - // Every shape is in transform mode by default except for newly drawn one - transformMode: true, }) .volatile(() => ({ mouseOverStartPoint: false, @@ -73,7 +67,6 @@ const Model = types isDrawing: false, vectorRef: null, groupRef: null, - selectedVertices: [], })) .views((self) => ({ get store() { @@ -89,21 +82,13 @@ const Model = types get bbox() { if (!self.vertices?.length || !isAlive(self)) return {}; - // Calculate bounding box directly from vertices (similar to PolygonRegion) - const bbox = self.vertices.reduce( - (bboxCoords, point) => ({ - left: Math.min(bboxCoords.left, point.x), - top: Math.min(bboxCoords.top, point.y), - right: Math.max(bboxCoords.right, point.x), - bottom: Math.max(bboxCoords.bottom, point.y), - }), - { - left: self.vertices[0].x, - top: self.vertices[0].y, - right: self.vertices[0].x, - bottom: self.vertices[0].y, - }, - ); + // Calculate bounding box from vector points + const bbox = self.vectorRef?.getShapeBoundingBox() ?? {}; + + // Ensure we have valid coordinates + if (bbox.left === undefined || bbox.top === undefined) { + return {}; + } return bbox; }, @@ -267,13 +252,10 @@ const Model = types .map((p) => p.id); const vector = self.vectorRef; - const selectionSize = self.annotation.regionStore.selection.size; - - self.selectedVertices = selectionSize > 1 ? [] : selectedPoints; + vector?.selectPointsByIds(selectedPoints); }, _selectArea(additiveMode = false) { - self.transformMode = true; const annotation = self.annotation; if (!annotation) return; @@ -390,51 +372,6 @@ const Model = types return selection.length > 1; }, - // Apply transformation from ImageTransformer - applyTransform(transformation) { - console.log("applying transfomations", transformation); - if (!self.vectorRef) return; - - const { dx, dy, scaleX, scaleY, rotation } = transformation; - - // Check if transformation values are reasonable (not already applied) - // If dx/dy are very large, it means the transformation was already applied by Konva - const isTranslationReasonable = Math.abs(dx) < 1000 && Math.abs(dy) < 1000; - const isScaleReasonable = scaleX > 0.1 && scaleX < 10 && scaleY > 0.1 && scaleY < 10; - - console.log("transformation check:", { isTranslationReasonable, isScaleReasonable }); - - // Only apply transformation if values are reasonable - if (!isTranslationReasonable || !isScaleReasonable) { - console.log("Skipping transformation - values seem already applied"); - return; - } - - // Get the bounding box center for rotation/scale origin - const bbox = self.bboxCoords || self.bbox; - if (!bbox) return; - - // Use the same coordinate system as translation - no conversion needed - // The bbox is already in the correct coordinate system for KonvaVector - const centerX = (bbox.left + bbox.right) / 2; - const centerY = (bbox.top + bbox.bottom) / 2; - - console.log("bbox center:", { centerX, centerY }); - console.log("transformation values:", { dx, dy, scaleX, scaleY, rotation }); - - // Apply transformations using the same approach as translation - // Use raw values directly - Konva transformer already handled coordinate conversion - self.vectorRef.transformPoints({ - dx: dx, - dy: dy, - rotation: rotation, - scaleX: scaleX, - scaleY: scaleY, - centerX: centerX, - centerY: centerY, - }); - }, - segGroupRef(ref) { self.groupRef = ref; }, @@ -554,14 +491,6 @@ const Model = types } tool?.complete(); }, - - toggleTransformMode() { - self.setTransformMode(!self.transformMode); - }, - - setTransformMode(transformMode) { - self.transformMode = transformMode; - }, }; }); @@ -586,21 +515,17 @@ const HtxVectorView = observer(({ item, suggestion }) => { const stageWidth = image?.naturalWidth ?? 0; const stageHeight = image?.naturalHeight ?? 0; const { x: offsetX, y: offsetY } = item.parent?.layerZoomScalePosition ?? { x: 0, y: 0 }; - const disabled = item.disabled || suggestion || store.annotationStore.selected.isLinkingMode; // Wait for stage to be properly initialized if (!item.parent?.stageWidth || !item.parent?.stageHeight) { return null; } - const isMultiRegionSelected = item.inSelection && item.parent?.selectedRegions?.length > 1; - return ( item.segGroupRef(ref)}> item.setKonvaVectorRef(kv)} - name={isMultiRegionSelected ? `${item.id} _transformable` : undefined} initialPoints={Array.from(item.vertices)} onFinish={(e) => { e.evt.stopPropagation(); @@ -630,10 +555,8 @@ const HtxVectorView = observer(({ item, suggestion }) => { stage.container().style.cursor = Constants.DEFAULT_CURSOR; } - if (!item.selected) { - item.setHighlight(false); - item.onClickRegion(e); - } + item.setHighlight(false); + item.onClickRegion(e); }} onMouseEnter={() => { if (store.annotationStore.selected.isLinkingMode) { @@ -647,10 +570,6 @@ const HtxVectorView = observer(({ item, suggestion }) => { } item.updateCursor(); }} - onDblClick={(e) => { - item.toggleTransformMode(); - }} - selectedPoints={item.annotation.regionStore.selection.size > 1 ? [] : item.selectedVertices} closed={item.closed} width={stageWidth} height={stageHeight} @@ -671,8 +590,7 @@ const HtxVectorView = observer(({ item, suggestion }) => { opacity={Number.parseFloat(item.control?.opacity || "1")} pixelSnapping={item.control?.snap === "pixel"} constrainToBounds={item.control?.constrainToBounds ?? true} - disabled={disabled} - transformMode={!disabled && item.transformMode && !isMultiRegionSelected} + disabled={item.disabled || suggestion || store.annotationStore.selected.isLinkingMode} // Point styling - customize point appearance based on control settings pointRadius={item.pointRadiusFromSize} pointFill={item.selected ? "#ffffff" : "#f8fafc"} diff --git a/web/libs/editor/src/tools/Vector.js b/web/libs/editor/src/tools/Vector.js index 3e45cdb9157e..1464414197c6 100644 --- a/web/libs/editor/src/tools/Vector.js +++ b/web/libs/editor/src/tools/Vector.js @@ -54,7 +54,6 @@ const _Tool = types vertices: [], converted: true, closed: false, - transformMode: false, }); }, From 9d3fd6425d6208faa0c3eb33cacd40499c3a0171 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Tue, 14 Oct 2025 15:09:09 +0100 Subject: [PATCH 11/32] Remove cmd-click, add double click functionality --- .../src/components/KonvaVector/KonvaVector.tsx | 14 ++------------ .../editor/src/components/KonvaVector/types.ts | 2 ++ web/libs/editor/src/regions/VectorRegion.jsx | 10 ++++++++-- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx index 4061d59d92c4..ce0ef2e2a4d3 100644 --- a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx +++ b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx @@ -229,6 +229,7 @@ export const KonvaVector = forwardRef((props, onMouseMove, onMouseUp, onClick, + onDblClick, onMouseEnter, onMouseLeave, allowClose = false, @@ -1497,6 +1498,7 @@ export const KonvaVector = forwardRef((props, eventHandlers.handleLayerClick(e); } } + onDblClick={disabled ? undefined : onDblClick} > {/* Invisible rectangle - always render to capture mouse events for cursor position updates */} {!disabled && ( @@ -1522,18 +1524,6 @@ export const KonvaVector = forwardRef((props, transform={transform} fitScale={fitScale} onClick={(e) => { - // Handle cmd-click to select all points - if ((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 - } - - // Select all points in the path - const allPointIndices = Array.from({ length: initialPoints.length }, (_, i) => i); - tracker.selectPoints(instanceId, new Set(allPointIndices)); - return; - } // Check if click is on the last added point by checking cursor position if (cursorPosition && lastAddedPointId) { diff --git a/web/libs/editor/src/components/KonvaVector/types.ts b/web/libs/editor/src/components/KonvaVector/types.ts index a336c8df3d77..075df1b082c2 100644 --- a/web/libs/editor/src/components/KonvaVector/types.ts +++ b/web/libs/editor/src/components/KonvaVector/types.ts @@ -201,6 +201,8 @@ export interface KonvaVectorProps { onMouseUp?: (e?: KonvaEventObject) => void; /** Click event handler */ onClick?: (e: KonvaEventObject) => void; + /** Double click event handler */ + onDblClick?: (e: KonvaEventObject) => void; /** Mouse enter event handler */ onMouseEnter?: (e: KonvaEventObject) => void; /** Mouse leave event handler */ diff --git a/web/libs/editor/src/regions/VectorRegion.jsx b/web/libs/editor/src/regions/VectorRegion.jsx index 663894e345fa..aa0cef574ce7 100644 --- a/web/libs/editor/src/regions/VectorRegion.jsx +++ b/web/libs/editor/src/regions/VectorRegion.jsx @@ -555,8 +555,10 @@ const HtxVectorView = observer(({ item, suggestion }) => { stage.container().style.cursor = Constants.DEFAULT_CURSOR; } - item.setHighlight(false); - item.onClickRegion(e); + if (!item.selected) { + item.setHighlight(false); + item.onClickRegion(e); + } }} onMouseEnter={() => { if (store.annotationStore.selected.isLinkingMode) { @@ -570,6 +572,10 @@ const HtxVectorView = observer(({ item, suggestion }) => { } item.updateCursor(); }} + onDblClick={(e) => { + e.evt.stopImmediatePropagation(); + console.log("double click"); + }} closed={item.closed} width={stageWidth} height={stageHeight} From 4988900c383ec85c14fb77e52ad1184977f76260 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Tue, 14 Oct 2025 15:17:20 +0100 Subject: [PATCH 12/32] Transform mode --- .../components/KonvaVector/KonvaVector.tsx | 55 +++++++++++-------- .../src/components/KonvaVector/types.ts | 2 + web/libs/editor/src/regions/VectorRegion.jsx | 17 +++++- 3 files changed, 51 insertions(+), 23 deletions(-) diff --git a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx index ce0ef2e2a4d3..5f0d0fe2f598 100644 --- a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx +++ b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx @@ -243,6 +243,7 @@ export const KonvaVector = forwardRef((props, fill = DEFAULT_FILL_COLOR, pixelSnapping = false, disabled = false, + transformMode = false, constrainToBounds = false, pointRadius, pointFill = DEFAULT_POINT_FILL, @@ -274,6 +275,14 @@ export const KonvaVector = forwardRef((props, // Use initialPoints directly - this will update when the parent re-renders const [selectedPointIndex, setSelectedPointIndex] = useState(null); const [selectedPoints, setSelectedPoints] = useState>(new Set()); + + // Compute effective selected points - when transformMode is true, all points are selected + const effectiveSelectedPoints = useMemo(() => { + if (transformMode && initialPoints.length > 0) { + return new Set(Array.from({ length: initialPoints.length }, (_, i) => i)); + } + return selectedPoints; + }, [transformMode, initialPoints.length, selectedPoints]); const [lastAddedPointId, setLastAddedPointId] = useState(null); const transformerRef = useRef(null); @@ -390,8 +399,8 @@ export const KonvaVector = forwardRef((props, const isDrawingDisabled = () => { // Disable all interactions when disabled prop is true // Disable drawing when Shift is held (for Shift+click functionality) - // Disable drawing when multiple points are selected - if (disabled || isShiftKeyHeld || selectedPoints.size > SELECTION_SIZE.MULTI_SELECTION_MIN) { + // Disable drawing when multiple points are selected or when in transform mode + if (disabled || isShiftKeyHeld || effectiveSelectedPoints.size > SELECTION_SIZE.MULTI_SELECTION_MIN || transformMode) { return true; } @@ -563,14 +572,14 @@ export const KonvaVector = forwardRef((props, // Set up Transformer nodes once when selection changes useEffect(() => { if (transformerRef.current) { - if (selectedPoints.size > SELECTION_SIZE.MULTI_SELECTION_MIN) { + if (effectiveSelectedPoints.size > SELECTION_SIZE.MULTI_SELECTION_MIN) { // Use setTimeout to ensure proxy nodes are rendered first setTimeout(() => { if (transformerRef.current) { // Set up proxy nodes once - transformer will manage them independently // Use getAllPoints() to get the correct proxy nodes for all points const allPoints = getAllPoints(); - const nodes = Array.from(selectedPoints) + const nodes = Array.from(effectiveSelectedPoints) .map((index) => { // Ensure the index is within bounds of all points if (index < allPoints.length) { @@ -597,7 +606,7 @@ export const KonvaVector = forwardRef((props, }, TRANSFORMER_CLEAR_DELAY); } } - }, [selectedPoints]); // Only depend on selectedPoints, not initialPoints + }, [effectiveSelectedPoints]); // Depend on effectiveSelectedPoints to include transform mode // Note: We don't update proxy node positions during transformation // The transformer handles positioning the proxy nodes itself @@ -1410,7 +1419,7 @@ export const KonvaVector = forwardRef((props, width, height, pixelSnapping, - selectedPoints, + selectedPoints: effectiveSelectedPoints, selectedPointIndex, setSelectedPointIndex, setSelectedPoints, @@ -1541,7 +1550,7 @@ 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 && selectedPoints.has(lastAddedPointIndex) && !disabled) { + if (lastAddedPointIndex !== -1 && effectiveSelectedPoints.has(lastAddedPointIndex) && !disabled) { const hasModifiers = e.evt.ctrlKey || e.evt.metaKey || e.evt.shiftKey || e.evt.altKey; if (!hasModifiers) { e.evt.preventDefault(); @@ -1603,7 +1612,7 @@ export const KonvaVector = forwardRef((props, ((props, } } - // Handle cmd-click to select all points - if ((e.evt.ctrlKey || e.evt.metaKey) && !e.evt.altKey && !e.evt.shiftKey) { + // Handle cmd-click to select all points (only when not in transform mode) + if (!transformMode && (e.evt.ctrlKey || e.evt.metaKey) && !e.evt.altKey && !e.evt.shiftKey) { // Select all points in the path const allPointIndices = Array.from({ length: initialPoints.length }, (_, i) => i); tracker.selectPoints(instanceId, new Set(allPointIndices)); @@ -1659,7 +1668,7 @@ 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 = selectedPoints.has(pointIndex); + 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 @@ -1675,15 +1684,17 @@ export const KonvaVector = forwardRef((props, return; } - // Handle regular point selection - if (e.evt.ctrlKey || e.evt.metaKey) { - // Add to multi-selection - const newSelection = new Set(selectedPoints); - newSelection.add(pointIndex); - tracker.selectPoints(instanceId, newSelection); - } else { - // Select only this point - tracker.selectPoints(instanceId, new Set([pointIndex])); + // Handle regular point selection (only when not in transform mode) + if (!transformMode) { + if (e.evt.ctrlKey || e.evt.metaKey) { + // Add to multi-selection + const newSelection = new Set(selectedPoints); + newSelection.add(pointIndex); + tracker.selectPoints(instanceId, newSelection); + } else { + // Select only this point + tracker.selectPoints(instanceId, new Set([pointIndex])); + } } // Call the original onClick handler if provided @@ -1703,13 +1714,13 @@ export const KonvaVector = forwardRef((props, {/* Proxy nodes for Transformer (positioned at exact point centers) - only show when not in drawing mode */} {drawingDisabled && ( - + )} {/* Transformer for multiselection - only show when not in drawing mode */} {drawingDisabled && ( ) => void; /** Disable all interactions when true */ disabled?: boolean; + /** Enable transform mode where all points are treated as selected */ + transformMode?: boolean; /** Constrain points to stay within image bounds */ constrainToBounds?: boolean; /** Ref to access component methods */ diff --git a/web/libs/editor/src/regions/VectorRegion.jsx b/web/libs/editor/src/regions/VectorRegion.jsx index aa0cef574ce7..475f46704fae 100644 --- a/web/libs/editor/src/regions/VectorRegion.jsx +++ b/web/libs/editor/src/regions/VectorRegion.jsx @@ -54,6 +54,11 @@ const Model = types // Internal flag to detect if we converted data back from relative points converted: false, + + // 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, }) .volatile(() => ({ mouseOverStartPoint: false, @@ -257,6 +262,7 @@ const Model = types _selectArea(additiveMode = false) { const annotation = self.annotation; + self.setTransformMode(true); if (!annotation) return; if (additiveMode) { @@ -491,6 +497,12 @@ const Model = types } tool?.complete(); }, + toggleTransformMode() { + self.setTransformMode(!self.transformMode); + }, + setTransformMode(transformMode) { + self.transformMode = transformMode; + }, }; }); @@ -515,6 +527,7 @@ const HtxVectorView = observer(({ item, suggestion }) => { const stageWidth = image?.naturalWidth ?? 0; const stageHeight = image?.naturalHeight ?? 0; const { x: offsetX, y: offsetY } = item.parent?.layerZoomScalePosition ?? { x: 0, y: 0 }; + const disabled = item.disabled || suggestion || store.annotationStore.selected.isLinkingMode; // Wait for stage to be properly initialized if (!item.parent?.stageWidth || !item.parent?.stageHeight) { @@ -575,7 +588,9 @@ const HtxVectorView = observer(({ item, suggestion }) => { onDblClick={(e) => { e.evt.stopImmediatePropagation(); console.log("double click"); + item.toggleTransformMode(); }} + transformMode={!disabled && item.transformMode} closed={item.closed} width={stageWidth} height={stageHeight} @@ -596,7 +611,7 @@ const HtxVectorView = observer(({ item, suggestion }) => { opacity={Number.parseFloat(item.control?.opacity || "1")} pixelSnapping={item.control?.snap === "pixel"} constrainToBounds={item.control?.constrainToBounds ?? true} - disabled={item.disabled || suggestion || store.annotationStore.selected.isLinkingMode} + disabled={disabled} // Point styling - customize point appearance based on control settings pointRadius={item.pointRadiusFromSize} pointFill={item.selected ? "#ffffff" : "#f8fafc"} From 0b27edc581a57620921441b8c763697d2506c546 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Tue, 14 Oct 2025 15:50:53 +0100 Subject: [PATCH 13/32] Remove selection tracker --- .../components/KonvaVector/VectorSelectionTracker.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/web/libs/editor/src/components/KonvaVector/VectorSelectionTracker.ts b/web/libs/editor/src/components/KonvaVector/VectorSelectionTracker.ts index a3a81ed472cd..f496a6a95859 100644 --- a/web/libs/editor/src/components/KonvaVector/VectorSelectionTracker.ts +++ b/web/libs/editor/src/components/KonvaVector/VectorSelectionTracker.ts @@ -66,10 +66,8 @@ export class VectorSelectionTracker { // Selection Management selectPoints(instanceId: string, pointIndices: Set): void { - // If trying to select points and there's already an active instance that's different - if (pointIndices.size > 0 && this.state.activeInstanceId && this.state.activeInstanceId !== instanceId) { - return; // Block the selection - } + // Allow multiple instances to have selections simultaneously + // Removed the blocking logic that prevented multiple vector regions from being selected if (pointIndices.size === 0) { this.state.selectedInstances.delete(instanceId); @@ -79,7 +77,8 @@ export class VectorSelectionTracker { } } else { this.state.selectedInstances.set(instanceId, new Set(pointIndices)); - // Set this as the active instance (first to select wins) + // Set this as the active instance for transformation purposes + // Multiple instances can now have selections, but only one can be actively transforming this.state.activeInstanceId = instanceId; } @@ -96,7 +95,8 @@ export class VectorSelectionTracker { // Check if an instance can have selection canInstanceHaveSelection(instanceId: string): boolean { - return this.state.activeInstanceId === null || this.state.activeInstanceId === instanceId; + // Allow all instances to have selections simultaneously + return true; } // Get the currently active instance ID From 64c6e631263a4e602e749d695157e273ffb16cbd Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Tue, 14 Oct 2025 16:48:01 +0100 Subject: [PATCH 14/32] Multi-selection WIP --- .../components/KonvaVector/KonvaVector.tsx | 4 ++ .../components/VectorTransformer.tsx | 5 +++ .../src/components/KonvaVector/types.ts | 4 ++ web/libs/editor/src/regions/VectorRegion.jsx | 43 +++++++++++++++++-- 4 files changed, 53 insertions(+), 3 deletions(-) diff --git a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx index 5f0d0fe2f598..8c5e9ef0bf85 100644 --- a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx +++ b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx @@ -230,6 +230,7 @@ export const KonvaVector = forwardRef((props, onMouseUp, onClick, onDblClick, + onTransformEnd, onMouseEnter, onMouseLeave, allowClose = false, @@ -245,6 +246,7 @@ export const KonvaVector = forwardRef((props, disabled = false, transformMode = false, constrainToBounds = false, + name, pointRadius, pointFill = DEFAULT_POINT_FILL, pointStroke = DEFAULT_POINT_STROKE, @@ -1487,6 +1489,7 @@ export const KonvaVector = forwardRef((props, return ( ((props, onTransformationEnd={() => { setIsTransforming(false); }} + onTransformEnd={onTransformEnd} /> )} diff --git a/web/libs/editor/src/components/KonvaVector/components/VectorTransformer.tsx b/web/libs/editor/src/components/KonvaVector/components/VectorTransformer.tsx index 629e4e6f271f..87355d2a07f6 100644 --- a/web/libs/editor/src/components/KonvaVector/components/VectorTransformer.tsx +++ b/web/libs/editor/src/components/KonvaVector/components/VectorTransformer.tsx @@ -19,6 +19,7 @@ interface VectorTransformerProps { }) => void; onTransformationStart?: () => void; onTransformationEnd?: () => void; + onTransformEnd?: (e: any) => void; constrainToBounds?: boolean; bounds?: { x: number; y: number; width: number; height: number }; } @@ -32,6 +33,7 @@ export const VectorTransformer: React.FC = ({ onTransformStateChange, onTransformationStart, onTransformationEnd, + onTransformEnd, constrainToBounds, bounds, }) => { @@ -304,6 +306,9 @@ export const VectorTransformer: React.FC = ({ // Notify that transformation has ended onTransformationEnd?.(); + + // Call external onTransformEnd handler + onTransformEnd?.(_e); }} /> ); diff --git a/web/libs/editor/src/components/KonvaVector/types.ts b/web/libs/editor/src/components/KonvaVector/types.ts index ed643c04e3bd..63376a595344 100644 --- a/web/libs/editor/src/components/KonvaVector/types.ts +++ b/web/libs/editor/src/components/KonvaVector/types.ts @@ -203,6 +203,8 @@ export interface KonvaVectorProps { onClick?: (e: KonvaEventObject) => void; /** Double click event handler */ onDblClick?: (e: KonvaEventObject) => void; + /** Transform end event handler */ + onTransformEnd?: (e: KonvaEventObject) => void; /** Mouse enter event handler */ onMouseEnter?: (e: KonvaEventObject) => void; /** Mouse leave event handler */ @@ -213,6 +215,8 @@ export interface KonvaVectorProps { transformMode?: boolean; /** Constrain points to stay within image bounds */ constrainToBounds?: boolean; + /** Name attribute for the component */ + name?: string; /** Ref to access component methods */ ref?: React.RefObject; } diff --git a/web/libs/editor/src/regions/VectorRegion.jsx b/web/libs/editor/src/regions/VectorRegion.jsx index 475f46704fae..640c13a913cf 100644 --- a/web/libs/editor/src/regions/VectorRegion.jsx +++ b/web/libs/editor/src/regions/VectorRegion.jsx @@ -65,7 +65,7 @@ const Model = types selectedPoint: null, hideable: true, _supportsTransform: true, - useTransformer: true, + useTransformer: false, preferTransformer: false, supportsRotate: true, supportsScale: true, @@ -536,15 +536,52 @@ const HtxVectorView = observer(({ item, suggestion }) => { return ( - item.segGroupRef(ref)}> + item.segGroupRef(ref)} name={item.id}> item.setKonvaVectorRef(kv)} initialPoints={Array.from(item.vertices)} + name="_transformable" onFinish={(e) => { e.evt.stopPropagation(); e.evt.preventDefault(); item.handleFinish(); }} + onTransformEnd={(e) => { + if (e.target !== e.currentTarget) return; + + const t = e.target; + const dx = t.getAttr("x", 0); + const dy = t.getAttr("y", 0); + const scaleX = t.getAttr("scaleX", 1); + const scaleY = t.getAttr("scaleY", 1); + const rotation = t.getAttr("rotation", 0); + + // Reset transform attributes + t.setAttr("x", 0); + t.setAttr("y", 0); + t.setAttr("scaleX", 1); + t.setAttr("scaleY", 1); + t.setAttr("rotation", 0); + + // Apply transformation to all points using KonvaVector methods + if (item.vectorRef) { + // Calculate center point for rotation and scaling + const bbox = item.bboxCoords; + const centerX = (bbox.left + bbox.right) / 2; + const centerY = (bbox.top + bbox.bottom) / 2; + + // Apply transformation + item.vectorRef.transformPoints({ + dx, + dy, + rotation, + scaleX, + scaleY, + centerX, + centerY, + }); + } + }} onPointsChange={(points) => { item.updatePointsFromKonvaVector(points); }} @@ -590,7 +627,7 @@ const HtxVectorView = observer(({ item, suggestion }) => { console.log("double click"); item.toggleTransformMode(); }} - transformMode={!disabled && item.transformMode} + transformMode={!disabled && item.transformMode && !item.inSelection} closed={item.closed} width={stageWidth} height={stageHeight} From 740f58dd4ff8de99693a2b9dbd1d0d8bd7b8aa33 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Tue, 21 Oct 2025 15:36:08 +0100 Subject: [PATCH 15/32] Biome --- .../editor/src/components/KonvaVector/KonvaVector.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx index 8c5e9ef0bf85..bc14c04c11ef 100644 --- a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx +++ b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx @@ -402,7 +402,12 @@ export const KonvaVector = forwardRef((props, // Disable all interactions when disabled prop is true // Disable drawing when Shift is held (for Shift+click functionality) // Disable drawing when multiple points are selected or when in transform mode - if (disabled || isShiftKeyHeld || effectiveSelectedPoints.size > SELECTION_SIZE.MULTI_SELECTION_MIN || transformMode) { + if ( + disabled || + isShiftKeyHeld || + effectiveSelectedPoints.size > SELECTION_SIZE.MULTI_SELECTION_MIN || + transformMode + ) { return true; } @@ -1536,7 +1541,6 @@ export const KonvaVector = forwardRef((props, transform={transform} fitScale={fitScale} onClick={(e) => { - // Check if click is on the last added point by checking cursor position if (cursorPosition && lastAddedPointId) { const lastAddedPoint = initialPoints.find((p) => p.id === lastAddedPointId); From df204ab8fd5d0b1c8cd6b769d1a41d7200bb0f13 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Wed, 22 Oct 2025 16:14:31 +0100 Subject: [PATCH 16/32] Multi-selection --- .../components/KonvaVector/KonvaVector.tsx | 362 +++++++++--------- 1 file changed, 182 insertions(+), 180 deletions(-) diff --git a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx index 207f884f488a..e48fc74a5cd5 100644 --- a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx +++ b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx @@ -249,7 +249,7 @@ export const KonvaVector = forwardRef((props, disabled = false, transformMode = false, constrainToBounds = false, - name, + isMultiRegionSelected = false, pointRadius, pointFill = DEFAULT_POINT_FILL, pointStroke = DEFAULT_POINT_STROKE, @@ -1515,7 +1515,6 @@ export const KonvaVector = forwardRef((props, return ( ((props, /> )} - {/* Unified vector shape - renders all lines based on id-prevPointId relationships */} - { - // Check if click is on the last added point by checking cursor position - if (cursorPosition && 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 distance = Math.sqrt( - (cursorPosition.x - lastAddedPoint.x) ** 2 + (cursorPosition.y - lastAddedPoint.y) ** 2, - ); - - if (distance <= hitRadius) { - // Find the index of the last added point - const lastAddedPointIndex = initialPoints.findIndex((p) => p.id === lastAddedPointId); - - // 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) { - const hasModifiers = e.evt.ctrlKey || e.evt.metaKey || e.evt.shiftKey || e.evt.altKey; - if (!hasModifiers) { - e.evt.preventDefault(); - onFinish?.(e); + {/* Transformable group for ImageTransformer */} + + {/* Unified vector shape - renders all lines based on id-prevPointId relationships */} + { + // Check if click is on the last added point by checking cursor position + if (cursorPosition && 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 distance = Math.sqrt( + (cursorPosition.x - lastAddedPoint.x) ** 2 + (cursorPosition.y - lastAddedPoint.y) ** 2, + ); + + if (distance <= hitRadius) { + // Find the index of the last added point + const lastAddedPointIndex = initialPoints.findIndex((p) => p.id === lastAddedPointId); + + // 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) { + const hasModifiers = e.evt.ctrlKey || e.evt.metaKey || e.evt.shiftKey || e.evt.altKey; + if (!hasModifiers) { + e.evt.preventDefault(); + onFinish?.(e); + 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; } } } - } - // Call the original onClick handler - onClick?.(e); - }} - onMouseEnter={onMouseEnter} - onMouseLeave={onMouseLeave} - key={`vector-shape-${initialPoints.length}-${initialPoints.map((p) => p.id).join("-")}`} - /> - - {/* Ghost line - preview from last point to cursor */} - - - {/* Control points - render first so lines appear under main points */} - {!disabled && ( - p.id).join("-")}`} + /> + + {/* Ghost line - preview from last point to cursor */} + `${i}-${p.x.toFixed(1)}-${p.y.toFixed(1)}-${p.controlPoint1?.x?.toFixed(1) || "null"}-${p.controlPoint1?.y?.toFixed(1) || "null"}-${p.controlPoint2?.x?.toFixed(1) || "null"}-${p.controlPoint2?.y?.toFixed(1) || "null"}`).join("-")}`} + maxPoints={maxPoints} + minPoints={minPoints} + skeletonEnabled={skeletonEnabled} + selectedPointIndex={selectedPointIndex} + lastAddedPointId={lastAddedPointId} + activePointId={activePointId} + stroke={stroke} + pixelSnapping={pixelSnapping} + drawingDisabled={drawingDisabled} /> - )} - {/* All vector points */} - { - // Handle point selection even when disabled (similar to shape clicks) - if (disabled) { - // Check if this instance can have selection - if (!tracker.canInstanceHaveSelection(instanceId)) { - return; // Block the selection - } + {/* Control points - render first so lines appear under main points */} + {!disabled && ( + `${i}-${p.x.toFixed(1)}-${p.y.toFixed(1)}-${p.controlPoint1?.x?.toFixed(1) || "null"}-${p.controlPoint1?.y?.toFixed(1) || "null"}-${p.controlPoint2?.x?.toFixed(1) || "null"}-${p.controlPoint2?.y?.toFixed(1) || "null"}`).join("-")}`} + /> + )} + + {/* All vector points */} + { + // Handle point selection even when disabled (similar to shape clicks) + if (disabled) { + // Check if this instance can have selection + if (!tracker.canInstanceHaveSelection(instanceId)) { + return; // Block the selection + } - // Check if we're about to close the path - prevent point selection in this case - if ( - shouldClosePathOnPointClick( - pointIndex, - { + // Check if we're about to close the path - prevent point selection in this case + if ( + shouldClosePathOnPointClick( + pointIndex, + { + initialPoints, + allowClose, + isPathClosed: finalIsPathClosed, + skeletonEnabled, + activePointId, + } as any, + e, + ) && + isActivePointEligibleForClosing({ initialPoints, - allowClose, - isPathClosed: finalIsPathClosed, skeletonEnabled, activePointId, - } as any, - e, - ) && - isActivePointEligibleForClosing({ - initialPoints, - skeletonEnabled, - activePointId, - } as any) - ) { - // Use the bidirectional closePath function - const success = (ref as React.MutableRefObject)?.current?.close(); - if (success) { - return; // Path was closed, don't select the point + } as any) + ) { + // Use the bidirectional closePath function + const success = (ref as React.MutableRefObject)?.current?.close(); + if (success) { + return; // Path was closed, don't select the point + } } - } - - // Handle cmd-click to select all points (only when not in transform mode) - if (!transformMode && (e.evt.ctrlKey || e.evt.metaKey) && !e.evt.altKey && !e.evt.shiftKey) { - // Select all points in the path - const allPointIndices = Array.from({ length: initialPoints.length }, (_, i) => i); - tracker.selectPoints(instanceId, new Set(allPointIndices)); - pointSelectionHandled.current = true; // Mark that we handled selection - e.evt.stopImmediatePropagation(); // Prevent all other handlers from running - return; - } - // 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); + // Handle cmd-click to select all points (only when not in transform mode) + if (!transformMode && (e.evt.ctrlKey || e.evt.metaKey) && !e.evt.altKey && !e.evt.shiftKey) { + // Select all points in the path + const allPointIndices = Array.from({ length: initialPoints.length }, (_, i) => i); + tracker.selectPoints(instanceId, new Set(allPointIndices)); 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; - } - // Handle regular point selection (only when not in transform mode) - if (!transformMode) { - if (e.evt.ctrlKey || e.evt.metaKey) { - // Add to multi-selection - const newSelection = new Set(selectedPoints); - newSelection.add(pointIndex); - tracker.selectPoints(instanceId, newSelection); - } else { - // Select only this point - tracker.selectPoints(instanceId, new Set([pointIndex])); + // 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 + return; + } + // If modifiers are held, skip onFinish entirely and let normal modifier handling take over + return; } - } - // Call the original onClick handler if provided - onClick?.(e); + // Handle regular point selection (only when not in transform mode) + if (!transformMode) { + if (e.evt.ctrlKey || e.evt.metaKey) { + // Add to multi-selection + const newSelection = new Set(selectedPoints); + newSelection.add(pointIndex); + tracker.selectPoints(instanceId, newSelection); + } else { + // Select only this point + tracker.selectPoints(instanceId, new Set([pointIndex])); + } + } - // Mark that we handled selection and prevent all other handlers from running - pointSelectionHandled.current = true; - e.evt.stopImmediatePropagation(); - return; - } + // Call the original onClick handler if provided + onClick?.(e); + + // Mark that we handled selection and prevent all other handlers from running + pointSelectionHandled.current = true; + e.evt.stopImmediatePropagation(); + return; + } + + // When not disabled, let the normal event handlers handle it + // The point click will be detected by the layer-level handlers + // + }} + /> - // When not disabled, let the normal event handlers handle it - // The point click will be detected by the layer-level handlers - // - }} - /> + {/* Ghost point */} + + - {/* Proxy nodes for Transformer (positioned at exact point centers) - only show when not in drawing mode */} - {drawingDisabled && ( + {/* Proxy nodes for Transformer (positioned at exact point centers) - only show when not in drawing mode and not multi-region selected */} + {drawingDisabled && !isMultiRegionSelected && ( )} - {/* Transformer for multiselection - only show when not in drawing mode */} - {drawingDisabled && ( + {/* Transformer for multiselection - only show when not in drawing mode and not multi-region selected */} + {drawingDisabled && !isMultiRegionSelected && ( ((props, onTransformEnd={onTransformEnd} /> )} - - {/* Ghost point */} - ); }); From c61d17efafc9588fc8f52ad6c670ec0fed717565 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Wed, 22 Oct 2025 17:14:10 +0100 Subject: [PATCH 17/32] Apply transformations to points --- .../ImageTransformer/ImageTransformer.jsx | 52 +++++++++++++++++++ .../components/KonvaVector/KonvaVector.tsx | 6 ++- .../src/components/KonvaVector/types.ts | 2 + web/libs/editor/src/regions/VectorRegion.jsx | 30 +++++++++++ 4 files changed, 89 insertions(+), 1 deletion(-) diff --git a/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx b/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx index 987b03211ab7..bcbb807cbf77 100644 --- a/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx +++ b/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx @@ -174,6 +174,50 @@ export default class TransformerComponent extends Component { }); }; + applyTransformToRegions = () => { + if (!this.transformer) return; + + const { item } = this.props; + const { selectedRegions } = item; + + // Get the transformer's current transform values + const nodes = this.transformer.nodes(); + if (!nodes || nodes.length === 0) return; + + // Get transform from the first node (they should all have the same transform) + const firstNode = nodes[0]; + const transform = { + dx: firstNode.x(), + dy: firstNode.y(), + scaleX: firstNode.scaleX(), + scaleY: firstNode.scaleY(), + rotation: firstNode.rotation(), + }; + + console.log('🔄 ImageTransformer applying transform to regions:', { + ...transform, + regionCount: selectedRegions.length + }); + + // Apply transform to each selected region + selectedRegions.forEach((region) => { + if (region.applyTransform && typeof region.applyTransform === 'function') { + region.applyTransform(transform); + } + }); + + // Reset the transformer nodes to identity transform + nodes.forEach((node) => { + node.x(0); + node.y(0); + node.scaleX(1); + node.scaleY(1); + node.rotation(0); + }); + + this.transformer.getLayer()?.batchDraw(); + }; + renderLSTransformer() { return ( <> @@ -214,10 +258,14 @@ export default class TransformerComponent extends Component { }} dragBoundFunc={this.dragBoundFunc} onDragEnd={() => { + // Apply transformations to individual regions + this.applyTransformToRegions(); this.unfreeze(); setTimeout(this.checkNode); }} onTransformEnd={() => { + // Apply transformations to individual regions + this.applyTransformToRegions(); setTimeout(this.checkNode); }} backSelector={this.props.draggableBackgroundSelector} @@ -262,10 +310,14 @@ export default class TransformerComponent extends Component { }} dragBoundFunc={this.dragBoundFunc} onDragEnd={() => { + // Apply transformations to individual regions + this.applyTransformToRegions(); this.unfreeze(); setTimeout(this.checkNode); }} onTransformEnd={() => { + // Apply transformations to individual regions + this.applyTransformToRegions(); setTimeout(this.checkNode); }} backSelector={this.props.draggableBackgroundSelector} diff --git a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx index e48fc74a5cd5..cc070f22a893 100644 --- a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx +++ b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx @@ -356,6 +356,10 @@ export const KonvaVector = forwardRef((props, // Flag to track if point selection was handled in VectorPoints onClick const pointSelectionHandled = useRef(false); + // Ref to track the _transformable group for applying transformations + const transformableGroupRef = useRef(null); + + // Initialize PointCreationManager instance const pointCreationManager = useMemo(() => new PointCreationManager(), []); @@ -1550,7 +1554,7 @@ export const KonvaVector = forwardRef((props, )} {/* Transformable group for ImageTransformer */} - + {/* Unified vector shape - renders all lines based on id-prevPointId relationships */} { ref={(kv) => item.setKonvaVectorRef(kv)} initialPoints={Array.from(item.vertices)} name="_transformable" + isMultiRegionSelected={item.object?.selectedRegions?.length > 1} onFinish={(e) => { e.evt.stopPropagation(); e.evt.preventDefault(); From f7a3fd05f6b6d1d00161ef52e770742c9ff6f605 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Wed, 22 Oct 2025 17:16:39 +0100 Subject: [PATCH 18/32] Transformation wip --- web/libs/editor/src/regions/VectorRegion.jsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/web/libs/editor/src/regions/VectorRegion.jsx b/web/libs/editor/src/regions/VectorRegion.jsx index e85286b04d7a..edd5fc18d39b 100644 --- a/web/libs/editor/src/regions/VectorRegion.jsx +++ b/web/libs/editor/src/regions/VectorRegion.jsx @@ -509,13 +509,15 @@ const Model = types console.log('🔄 VectorRegion.applyTransform called:', transform); - // Calculate center point for rotation and scaling - const bbox = self.bboxCoords; - if (!bbox) return; + // Calculate center point for rotation and scaling using image coordinates + const bbox = self.bbox; // Use bbox (image coordinates) instead of bboxCoords (internal coordinates) + if (!bbox || bbox.left === undefined) return; const centerX = (bbox.left + bbox.right) / 2; const centerY = (bbox.top + bbox.bottom) / 2; + console.log('📊 Using center point for transformation:', { centerX, centerY, bbox }); + // Apply transformation using KonvaVector's transformPoints method self.vectorRef.transformPoints({ dx: transform.dx || 0, From 06ba82870c794fb4d77528744a607d8a53beca6b Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Wed, 22 Oct 2025 18:54:11 +0100 Subject: [PATCH 19/32] Transformation WIP --- .../ImageTransformer/ImageTransformer.jsx | 14 +- .../components/KonvaVector/KonvaVector.tsx | 247 +++++++++++++++--- web/libs/editor/src/regions/VectorRegion.jsx | 63 +++-- web/libs/editor/src/tools/Vector.js | 1 + 4 files changed, 272 insertions(+), 53 deletions(-) diff --git a/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx b/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx index bcbb807cbf77..11fd1b497e7b 100644 --- a/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx +++ b/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx @@ -194,15 +194,27 @@ export default class TransformerComponent extends Component { rotation: firstNode.rotation(), }; + // Calculate the center point that the transformer is using + // This is the center of the combined bounding box of all selected regions + const transformerCenter = { + x: this.transformer.x() + this.transformer.width() / 2, + y: this.transformer.y() + this.transformer.height() / 2, + }; + console.log('🔄 ImageTransformer applying transform to regions:', { ...transform, + transformerCenter, regionCount: selectedRegions.length }); // Apply transform to each selected region selectedRegions.forEach((region) => { + console.log('🔄 Processing region:', region.id, 'has applyTransform:', typeof region.applyTransform); if (region.applyTransform && typeof region.applyTransform === 'function') { - region.applyTransform(transform); + console.log('🔄 Calling applyTransform on region:', region.id); + region.applyTransform(transform, transformerCenter); + } else { + console.log('🔄 Region does not have applyTransform method:', region.id); } }); diff --git a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx index cc070f22a893..e548286bd447 100644 --- a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx +++ b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx @@ -1536,6 +1536,43 @@ export const KonvaVector = forwardRef((props, pointSelectionHandled.current = false; 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) { + // For the first point, call the drawing mode click handler directly + const pos = e.target.getStage()?.getPointerPosition(); + if (pos) { + const imagePos = { + x: (pos.x - x) / (scaleX * transform.zoom * fitScale), + y: (pos.y - y) / (scaleY * transform.zoom * fitScale), + }; + + // Check if we're within canvas bounds + if (!constrainToBounds || (imagePos.x >= 0 && imagePos.x <= width && imagePos.y >= 0 && imagePos.y <= height)) { + // Create the first point directly + const newPoint = { + id: `point-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + x: imagePos.x, + y: imagePos.y, + isBezier: false, + }; + + const newPoints = [...initialPoints, newPoint]; + onPointsChange?.(newPoints); + onPointAdded?.(newPoint, newPoints.length - 1); + + // Set as the last added point + setLastAddedPointId(newPoint.id); + setActivePointId(newPoint.id); + + return; + } + } + } + + // For subsequent points, use the normal event handler eventHandlers.handleLayerClick(e); } } @@ -1553,8 +1590,9 @@ export const KonvaVector = forwardRef((props, /> )} - {/* Transformable group for ImageTransformer */} - + {/* Conditionally wrap content with _transformable group for ImageTransformer */} + {isMultiRegionSelected ? ( + {/* Unified vector shape - renders all lines based on id-prevPointId relationships */} ((props, initialPointsLength={initialPoints.length} isDragging={isDragging.current} /> - + + ) : ( + <> + {/* Unified vector shape - renders all lines based on id-prevPointId relationships */} + { + // Check if click is on the last added point by checking cursor position + if (cursorPosition && 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 distance = Math.sqrt( + (cursorPosition.x - lastAddedPoint.x) ** 2 + (cursorPosition.y - lastAddedPoint.y) ** 2, + ); + + if (distance <= hitRadius) { + // Find the index of the last added point + const lastAddedPointIndex = initialPoints.findIndex((p) => p.id === lastAddedPointId); + + // 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) { + const hasModifiers = e.evt.ctrlKey || e.evt.metaKey || e.evt.shiftKey || e.evt.altKey; + if (!hasModifiers) { + e.evt.preventDefault(); + onFinish?.(e); + return; + } + // If modifiers are held, skip onFinish entirely and let normal modifier handling take over + return; + } + } + } + } - {/* Proxy nodes for Transformer (positioned at exact point centers) - only show when not in drawing mode and not multi-region selected */} - {drawingDisabled && !isMultiRegionSelected && ( - - )} + // Call the original onClick handler + onClick?.(e); + }} + onMouseEnter={onMouseEnter} + onMouseLeave={onMouseLeave} + key={`vector-shape-${initialPoints.length}-${initialPoints.map((p) => p.id).join("-")}`} + /> - {/* Transformer for multiselection - only show when not in drawing mode and not multi-region selected */} - {drawingDisabled && !isMultiRegionSelected && ( - { - // Update main path points - onPointsChange?.(newPoints); - }} - onTransformStateChange={(state) => { - transformerStateRef.current = state; - }} - onTransformationStart={() => { - setIsTransforming(true); - }} - onTransformationEnd={() => { - setIsTransforming(false); - }} - onTransformEnd={onTransformEnd} - /> + {/* Ghost line - preview from last point to cursor */} + + + {/* Control points - render first so lines appear under main points */} + {!disabled && ( + `${i}-${p.x.toFixed(1)}-${p.y.toFixed(1)}-${p.controlPoint1?.x?.toFixed(1) || "null"}-${p.controlPoint1?.y?.toFixed(1) || "null"}-${p.controlPoint2?.x?.toFixed(1) || "null"}-${p.controlPoint2?.y?.toFixed(1) || "null"}`).join("-")}`} + /> + )} + + {/* All vector points */} + { + // Handle point selection even when disabled (similar to shape clicks) + if (disabled) { + // Check if this instance can have selection + if (!tracker.canInstanceHaveSelection(instanceId)) { + return; // Block the selection + } + + // Check if we're about to close the path - prevent point selection in this case + if ( + shouldClosePathOnPointClick( + pointIndex, + { + initialPoints, + allowClose, + isPathClosed: finalIsPathClosed, + skeletonEnabled, + activePointId, + } as any, + e, + ) + ) { + return; // Block the selection + } + + // Allow selection for this instance + tracker.allowInstanceSelection(instanceId); + } + + // Mark that point selection was handled + pointSelectionHandled.current = true; + + eventHandlers.handlePointClick(e, pointIndex); + }} + onPointDragStart={eventHandlers.handlePointDragStart} + onPointDragMove={eventHandlers.handlePointDragMove} + onPointDragEnd={eventHandlers.handlePointDragEnd} + onPointConvert={eventHandlers.handlePointConvert} + onControlPointDragStart={eventHandlers.handleControlPointDragStart} + onControlPointDragMove={eventHandlers.handleControlPointDragMove} + onControlPointDragEnd={eventHandlers.handleControlPointDragEnd} + onControlPointConvert={eventHandlers.handleControlPointConvert} + onSegmentClick={eventHandlers.handleSegmentClick} + visibleControlPoints={visibleControlPoints} + allowBezier={allowBezier} + isTransforming={isTransforming} + key={`vector-points-${initialPoints.length}-${initialPoints.map((p, i) => `${i}-${p.x.toFixed(1)}-${p.y.toFixed(1)}-${p.controlPoint1?.x?.toFixed(1) || "null"}-${p.controlPoint1?.y?.toFixed(1) || "null"}-${p.controlPoint2?.x?.toFixed(1) || "null"}-${p.controlPoint2?.y?.toFixed(1) || "null"}`).join("-")}`} + /> + + {/* Proxy nodes for Transformer (positioned at exact point centers) - only show when not in drawing mode and not multi-region selected */} + {drawingDisabled && !isMultiRegionSelected && ( + + )} + + {/* Transformer for multiselection - only show when not in drawing mode and not multi-region selected */} + {drawingDisabled && !isMultiRegionSelected && ( + + )} + )} ); diff --git a/web/libs/editor/src/regions/VectorRegion.jsx b/web/libs/editor/src/regions/VectorRegion.jsx index edd5fc18d39b..7b00c9a45966 100644 --- a/web/libs/editor/src/regions/VectorRegion.jsx +++ b/web/libs/editor/src/regions/VectorRegion.jsx @@ -164,7 +164,7 @@ const Model = types } }, get disabled() { - const tool = self.parent.getToolsManager().findSelectedTool(); + const tool = self.parent?.getToolsManager().findSelectedTool(); return (tool?.disabled ?? false) || self.isReadOnly() || (!self.selected && !self.isDrawing); }, })) @@ -503,31 +503,62 @@ const Model = types * Apply transformations from ImageTransformer to the vector points * Called by ImageTransformer when multi-region transformations complete * @param {Object} transform - Transform object with dx, dy, scaleX, scaleY, rotation + * @param {Object} transformerCenter - Center point used by the ImageTransformer for scaling/rotation */ - applyTransform(transform) { + applyTransform(transform, transformerCenter) { if (!self.vectorRef) return; - console.log('🔄 VectorRegion.applyTransform called:', transform); + console.log("🔄 VectorRegion.applyTransform called:", { transform, transformerCenter }); - // Calculate center point for rotation and scaling using image coordinates - const bbox = self.bbox; // Use bbox (image coordinates) instead of bboxCoords (internal coordinates) - if (!bbox || bbox.left === undefined) return; + const dx = transform.dx || 0; + const dy = transform.dy || 0; + const scaleX = transform.scaleX || 1; + const scaleY = transform.scaleY || 1; + const rotation = transform.rotation || 0; - const centerX = (bbox.left + bbox.right) / 2; - const centerY = (bbox.top + bbox.bottom) / 2; + console.log("📊 Applying transformation:", { dx, dy, scaleX, scaleY, rotation }); - console.log('📊 Using center point for transformation:', { centerX, centerY, bbox }); + // The ImageTransformer applies transforms to the _transformable group which is positioned at (0,0) + // in the KonvaVector coordinate system. The transform values (dx, dy, scaleX, scaleY, rotation) + // are already in the correct coordinate system for the vector points. + // This matches how the single-region onTransformEnd handler works. - // Apply transformation using KonvaVector's transformPoints method - self.vectorRef.transformPoints({ - dx: transform.dx || 0, - dy: transform.dy || 0, - rotation: transform.rotation || 0, - scaleX: transform.scaleX || 1, - scaleY: transform.scaleY || 1, + // Convert transformer center from stage coordinates to image coordinates + let centerX, centerY; + if (transformerCenter) { + const internalX = self.parent.canvasToInternalX(transformerCenter.x); + const internalY = self.parent.canvasToInternalY(transformerCenter.y); + centerX = self.parent.internalToImageX(internalX); + centerY = self.parent.internalToImageY(internalY); + } + + console.log("📊 Using transform values directly:", { + dx, + dy, + scaleX, + scaleY, + rotation, centerX, centerY, + transformerCenter }); + + // Use the KonvaVector's transformPoints method with the transform values directly + // No coordinate conversion needed since ImageTransformer already works in the correct coordinate system + if (self.vectorRef && typeof self.vectorRef.transformPoints === 'function') { + self.vectorRef.transformPoints({ + dx, + dy, + scaleX, + scaleY, + rotation, + centerX, + centerY, + }); + console.log("📊 transformPoints called successfully"); + } else { + console.error("📊 transformPoints method not available"); + } }, }; }); diff --git a/web/libs/editor/src/tools/Vector.js b/web/libs/editor/src/tools/Vector.js index 1464414197c6..3e45cdb9157e 100644 --- a/web/libs/editor/src/tools/Vector.js +++ b/web/libs/editor/src/tools/Vector.js @@ -54,6 +54,7 @@ const _Tool = types vertices: [], converted: true, closed: false, + transformMode: false, }); }, From 7def038e91641db01fe55cec3adb5fac5eb2034c Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Thu, 23 Oct 2025 11:37:40 +0100 Subject: [PATCH 20/32] Transformation WIP --- .../components/KonvaVector/KonvaVector.tsx | 142 +++++++++++++++++ web/libs/editor/src/regions/VectorRegion.jsx | 143 +++++++++++------- 2 files changed, 228 insertions(+), 57 deletions(-) diff --git a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx index e548286bd447..509fa43b5048 100644 --- a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx +++ b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx @@ -359,6 +359,33 @@ export const KonvaVector = forwardRef((props, // Ref to track the _transformable group for applying transformations const transformableGroupRef = useRef(null); + // Track initial transform state for delta calculation + const initialTransformRef = useRef<{ + x: number; + y: number; + scaleX: number; + scaleY: number; + rotation: number; + } | null>(null); + + // Capture initial transform state when group is created + useEffect(() => { + if (isMultiRegionSelected && transformableGroupRef.current && !initialTransformRef.current) { + const group = transformableGroupRef.current; + initialTransformRef.current = { + x: group.x(), + y: group.y(), + scaleX: group.scaleX(), + 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; + } + }, [isMultiRegionSelected]); + // Initialize PointCreationManager instance const pointCreationManager = useMemo(() => new PointCreationManager(), []); @@ -1419,6 +1446,116 @@ export const KonvaVector = forwardRef((props, return false; }, + // Multi-region transformation method - applies group transform to points + commitMultiRegionTransform: () => { + if (!isMultiRegionSelected || !transformableGroupRef.current || !initialTransformRef.current) { + 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 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 (dx, dy) + const radians = rotation * (Math.PI / 180); + const cos = Math.cos(radians); + const sin = Math.sin(radians); + + const transformedVertices = initialPoints.map((point) => { + // Step 1: Scale + let x = point.x * scaleX; + let y = point.y * scaleY; + + // Step 2: Rotate + const rx = x * cos - y * sin; + const ry = x * sin + y * cos; + + // Step 3: Translate + const result = { + ...point, + x: rx + dx, + y: ry + dy, + }; + + // Transform control points if bezier + if (point.isBezier) { + if (point.controlPoint1) { + let cp1x = point.controlPoint1.x * scaleX; + let cp1y = point.controlPoint1.y * scaleY; + const cp1rx = cp1x * cos - cp1y * sin; + const cp1ry = cp1x * sin + cp1y * cos; + result.controlPoint1 = { + x: cp1rx + dx, + y: cp1ry + dy, + }; + } + if (point.controlPoint2) { + let cp2x = point.controlPoint2.x * scaleX; + let cp2y = point.controlPoint2.y * scaleY; + const cp2rx = cp2x * cos - cp2y * sin; + const cp2ry = cp2x * sin + cp2y * cos; + result.controlPoint2 = { + x: cp2rx + dx, + y: cp2ry + dy, + }; + } + } + + return result; + }); + + // Update the points + onPointsChange?.(transformedVertices); + + console.log("📊 Updated points:", transformedVertices.map(p => ({ id: p.id, x: p.x, y: p.y }))); + + // Reset the _transformable group transform to identity + 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, + }; + + console.log("📊 Reset _transformable group transform to identity"); + }, })); // Handle Shift key for disconnected mode @@ -1783,6 +1920,11 @@ export const KonvaVector = forwardRef((props, }} /> + {/* Proxy nodes for ImageTransformer - INSIDE the _transformable group for multi-region mode */} + {drawingDisabled && ( + + )} + {/* Ghost point */} ({ id: v.id, x: v.x, y: v.y })), }); - // Use the KonvaVector's transformPoints method with the transform values directly - // No coordinate conversion needed since ImageTransformer already works in the correct coordinate system - if (self.vectorRef && typeof self.vectorRef.transformPoints === 'function') { - self.vectorRef.transformPoints({ - dx, - dy, - scaleX, - scaleY, - rotation, - centerX, - centerY, - }); - console.log("📊 transformPoints called successfully"); + // Delegate to KonvaVector's commitMultiRegionTransform method + // This method reads the proxy node coordinates and applies them directly + if (typeof self.vectorRef.commitMultiRegionTransform === 'function') { + self.vectorRef.commitMultiRegionTransform(); + console.log("📊 commitMultiRegionTransform called successfully"); } else { - console.error("📊 transformPoints method not available"); + console.error("📊 commitMultiRegionTransform method not available"); } }, }; @@ -614,6 +581,19 @@ const HtxVectorView = observer(({ item, suggestion }) => { const scaleY = t.getAttr("scaleY", 1); const rotation = t.getAttr("rotation", 0); + console.log("🎯 Single-region onTransformEnd:", { + regionId: item.id, + dx, + dy, + scaleX, + scaleY, + rotation, + stageZoom: item.parent.stageZoom, + bbox: item.bbox, + bboxCoords: item.bboxCoords, + vertices: item.vertices.map(v => ({ id: v.id, x: v.x, y: v.y })), + }); + // Reset transform attributes t.setAttr("x", 0); t.setAttr("y", 0); @@ -623,21 +603,70 @@ const HtxVectorView = observer(({ item, suggestion }) => { // Apply transformation to all points using KonvaVector methods if (item.vectorRef) { - // Calculate center point for rotation and scaling - const bbox = item.bboxCoords; - const centerX = (bbox.left + bbox.right) / 2; - const centerY = (bbox.top + bbox.bottom) / 2; - - // Apply transformation - item.vectorRef.transformPoints({ + console.log("🎯 Calling transformPoints with:", { dx, dy, - rotation, scaleX, scaleY, - centerX, - centerY, + rotation, }); + + // Apply the transformation exactly as Konva did: + // 1. Scale around origin (0,0) + // 2. Rotate around origin (0,0) + // 3. Translate by (dx, dy) + // Don't pass centerX/centerY - transform around origin + const radians = rotation * (Math.PI / 180); + const cos = Math.cos(radians); + const sin = Math.sin(radians); + + const transformedVertices = item.vertices.map((point) => { + // Step 1: Scale + let x = point.x * scaleX; + let y = point.y * scaleY; + + // Step 2: Rotate + const rx = x * cos - y * sin; + const ry = x * sin + y * cos; + + // Step 3: Translate + const result = { + ...point, + x: rx + dx, + y: ry + dy, + }; + + // Transform control points if bezier + if (point.isBezier) { + if (point.controlPoint1) { + let cp1x = point.controlPoint1.x * scaleX; + let cp1y = point.controlPoint1.y * scaleY; + const cp1rx = cp1x * cos - cp1y * sin; + const cp1ry = cp1x * sin + cp1y * cos; + result.controlPoint1 = { + x: cp1rx + dx, + y: cp1ry + dy, + }; + } + if (point.controlPoint2) { + let cp2x = point.controlPoint2.x * scaleX; + let cp2y = point.controlPoint2.y * scaleY; + const cp2rx = cp2x * cos - cp2y * sin; + const cp2ry = cp2x * sin + cp2y * cos; + result.controlPoint2 = { + x: cp2rx + dx, + y: cp2ry + dy, + }; + } + } + + return result; + }); + + // Update the points + item.updatePointsFromKonvaVector(transformedVertices); + + console.log("🎯 After transform, vertices:", transformedVertices.map(v => ({ id: v.id, x: v.x, y: v.y }))); } }} onPointsChange={(points) => { From 49c7d843cd7b918cd6040acb5e83c31e930605aa Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Thu, 23 Oct 2025 16:22:18 +0100 Subject: [PATCH 21/32] Transform multiple shapes --- .../ImageTransformer/ImageTransformer.jsx | 51 +++++++++---------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx b/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx index 11fd1b497e7b..c2227a981323 100644 --- a/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx +++ b/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx @@ -184,35 +184,31 @@ export default class TransformerComponent extends Component { const nodes = this.transformer.nodes(); if (!nodes || nodes.length === 0) return; - // Get transform from the first node (they should all have the same transform) - const firstNode = nodes[0]; - const transform = { - dx: firstNode.x(), - dy: firstNode.y(), - scaleX: firstNode.scaleX(), - scaleY: firstNode.scaleY(), - rotation: firstNode.rotation(), - }; - - // Calculate the center point that the transformer is using - // This is the center of the combined bounding box of all selected regions - const transformerCenter = { - x: this.transformer.x() + this.transformer.width() / 2, - y: this.transformer.y() + this.transformer.height() / 2, - }; + // Apply transform to each selected region individually + // Each node may have different transform values when transforming multiple nodes together + selectedRegions.forEach((region, index) => { + const node = nodes[index]; + if (!node) { + console.warn('🔄 No node found for region:', region.id); + return; + } - console.log('🔄 ImageTransformer applying transform to regions:', { - ...transform, - transformerCenter, - regionCount: selectedRegions.length - }); + // Get the transform for THIS specific node + const transform = { + dx: node.x(), + dy: node.y(), + scaleX: node.scaleX(), + scaleY: node.scaleY(), + rotation: node.rotation(), + }; + + console.log('🔄 Applying transform to region:', { + regionId: region.id, + ...transform, + }); - // Apply transform to each selected region - selectedRegions.forEach((region) => { - console.log('🔄 Processing region:', region.id, 'has applyTransform:', typeof region.applyTransform); if (region.applyTransform && typeof region.applyTransform === 'function') { - console.log('🔄 Calling applyTransform on region:', region.id); - region.applyTransform(transform, transformerCenter); + region.applyTransform(transform, null); } else { console.log('🔄 Region does not have applyTransform method:', region.id); } @@ -227,6 +223,9 @@ export default class TransformerComponent extends Component { node.rotation(0); }); + // Detach transformer temporarily to prevent recalculation during re-render + // The transformer will be reattached by checkNode after components update + this.transformer.nodes([]); this.transformer.getLayer()?.batchDraw(); }; From a1b67060d8fa38b4e57555da6de87018bda0ac42 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Fri, 24 Oct 2025 14:38:52 +0100 Subject: [PATCH 22/32] Various tiny fixes --- .../components/KonvaVector/KonvaVector.tsx | 45 ++++++++++++------- .../KonvaVector/eventHandlers/index.ts | 2 + .../eventHandlers/mouseHandlers.ts | 12 +++++ .../KonvaVector/eventHandlers/types.ts | 2 + web/libs/editor/src/regions/VectorRegion.jsx | 29 ++++++------ web/libs/editor/src/tags/control/Vector.js | 2 - .../editor/src/tags/control/VectorLabels.jsx | 21 --------- 7 files changed, 60 insertions(+), 53 deletions(-) diff --git a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx index b91dbe6f4f70..2059cee72313 100644 --- a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx +++ b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx @@ -18,6 +18,7 @@ import { PointCreationManager } from "./pointCreationManager"; import { VectorSelectionTracker, type VectorInstance } from "./VectorSelectionTracker"; import { calculateShapeBoundingBox } from "./utils/bezierBoundingBox"; import { shouldClosePathOnPointClick, isActivePointEligibleForClosing } from "./eventHandlers/pointSelection"; +import { handleShiftClickPointConversion } from "./eventHandlers/drawing"; import type { BezierPoint, GhostPoint as GhostPointType, KonvaVectorProps, KonvaVectorRef } from "./types"; import { ShapeType, ExportFormat, PathType } from "./types"; import { @@ -1632,6 +1633,7 @@ export const KonvaVector = forwardRef((props, onMouseMove, onMouseUp, onClick, + onDblClick, notifyTransformationComplete, canAddMorePoints, maxPoints, @@ -1677,16 +1679,14 @@ export const KonvaVector = forwardRef((props, // For the first point, call the drawing mode click handler directly const pos = e.target.getStage()?.getPointerPosition(); if (pos) { + // Use the same coordinate transformation as the event handlers const imagePos = { - x: (pos.x - x) / (scaleX * transform.zoom * fitScale), - y: (pos.y - y) / (scaleY * transform.zoom * fitScale), + x: (pos.x - x - transform.offsetX) / (scaleX * transform.zoom * fitScale), + y: (pos.y - y - transform.offsetY) / (scaleY * transform.zoom * fitScale), }; // Check if we're within canvas bounds - if ( - !constrainToBounds || - (imagePos.x >= 0 && imagePos.x <= width && imagePos.y >= 0 && imagePos.y <= height) - ) { + if (imagePos.x >= 0 && imagePos.x <= width && imagePos.y >= 0 && imagePos.y <= height) { // Create the first point directly const newPoint = { id: `point-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, @@ -1712,7 +1712,7 @@ export const KonvaVector = forwardRef((props, eventHandlers.handleLayerClick(e); } } - onDblClick={disabled ? undefined : onDblClick} + onDblClick={disabled ? undefined : eventHandlers.handleLayerDoubleClick} > {/* Invisible rectangle - always render to capture mouse events for cursor position updates */} {!disabled && ( @@ -1924,8 +1924,8 @@ export const KonvaVector = forwardRef((props, )} - {/* Transformer for multiselection - only show when not in drawing mode */} - {drawingDisabled && ( + {/* Transformer for multiselection - only show when not in drawing mode and not multi-region selected */} + {drawingDisabled && !isMultiRegionSelected && ( ((props, pointStrokeSelected={pointStrokeSelected} pointStrokeWidth={pointStrokeWidth} onPointClick={(e, pointIndex) => { + // Handle Shift+click point conversion FIRST (before other checks) + if (e.evt.shiftKey && !e.evt.altKey && !disabled) { + if (handleShiftClickPointConversion(e, { + initialPoints, + transform, + fitScale, + x, + y, + allowBezier, + pixelSnapping, + onPointsChange, + onPointEdited, + setVisibleControlPoints, + })) { + pointSelectionHandled.current = true; + return; // Successfully converted point + } + } + // Handle point selection even when disabled (similar to shape clicks) if (disabled) { // Check if this instance can have selection @@ -2094,15 +2113,10 @@ export const KonvaVector = forwardRef((props, ) { return; // Block the selection } - - // Allow selection for this instance - tracker.allowInstanceSelection(instanceId); } // Mark that point selection was handled pointSelectionHandled.current = true; - - eventHandlers.handlePointClick(e, pointIndex); }} onPointDragStart={eventHandlers.handlePointDragStart} onPointDragMove={eventHandlers.handlePointDragMove} @@ -2133,8 +2147,7 @@ export const KonvaVector = forwardRef((props, proxyRefs={proxyRefs} onPointsChange={onPointsChange} onTransformationComplete={notifyTransformationComplete} - constrainToBounds={constrainToBounds} - bounds={{ width, height }} + bounds={{ x: 0, y: 0, width, height }} transform={transform} fitScale={fitScale} /> diff --git a/web/libs/editor/src/components/KonvaVector/eventHandlers/index.ts b/web/libs/editor/src/components/KonvaVector/eventHandlers/index.ts index ae8f8afd1831..3e431cbed70b 100644 --- a/web/libs/editor/src/components/KonvaVector/eventHandlers/index.ts +++ b/web/libs/editor/src/components/KonvaVector/eventHandlers/index.ts @@ -1,5 +1,6 @@ import { createClickHandler, + createDoubleClickHandler, createMouseDownHandler, createMouseMoveHandler, createMouseUpHandler, @@ -13,6 +14,7 @@ export function createEventHandlers(props: EventHandlerProps): EventHandlers { return { handleLayerMouseDown: createMouseDownHandler(props, handledSelectionInMouseDown), handleLayerClick: createClickHandler(props, handledSelectionInMouseDown), + handleLayerDoubleClick: createDoubleClickHandler(props), handleLayerMouseMove: createMouseMoveHandler(props, handledSelectionInMouseDown), handleLayerMouseUp: createMouseUpHandler(props), }; diff --git a/web/libs/editor/src/components/KonvaVector/eventHandlers/mouseHandlers.ts b/web/libs/editor/src/components/KonvaVector/eventHandlers/mouseHandlers.ts index 871feda2f2f4..3c1b508730ca 100644 --- a/web/libs/editor/src/components/KonvaVector/eventHandlers/mouseHandlers.ts +++ b/web/libs/editor/src/components/KonvaVector/eventHandlers/mouseHandlers.ts @@ -975,6 +975,18 @@ export function createClickHandler(props: EventHandlerProps, handledSelectionInM }; } +export function createDoubleClickHandler(props: EventHandlerProps) { + return (e: KonvaEventObject) => { + // Prevent the click handler from interfering with double-click + e.evt.stopImmediatePropagation(); + + // Call the parent's onDblClick handler if provided + if (props.onDblClick) { + props.onDblClick(e); + } + }; +} + // Helper function to select a point by index function handlePointSelectionFromIndex( pointIndex: number, diff --git a/web/libs/editor/src/components/KonvaVector/eventHandlers/types.ts b/web/libs/editor/src/components/KonvaVector/eventHandlers/types.ts index 220a71f3ab1d..e5b52ebf2a00 100644 --- a/web/libs/editor/src/components/KonvaVector/eventHandlers/types.ts +++ b/web/libs/editor/src/components/KonvaVector/eventHandlers/types.ts @@ -85,6 +85,7 @@ export interface EventHandlerProps { onMouseMove?: (e: KonvaEventObject) => void; onMouseUp?: (e?: KonvaEventObject) => void; onClick?: (e: KonvaEventObject) => void; + onDblClick?: (e: KonvaEventObject) => void; notifyTransformationComplete?: () => void; canAddMorePoints?: () => boolean; maxPoints?: number; @@ -134,6 +135,7 @@ export interface EventHandlerProps { export interface EventHandlers { handleLayerMouseDown: (e: KonvaEventObject) => void; handleLayerClick: (e: KonvaEventObject) => void; + handleLayerDoubleClick: (e: KonvaEventObject) => void; handleLayerMouseMove: (e: KonvaEventObject) => void; handleLayerMouseUp: () => void; } diff --git a/web/libs/editor/src/regions/VectorRegion.jsx b/web/libs/editor/src/regions/VectorRegion.jsx index 0d0eb3fc5e31..c397e2b6d61a 100644 --- a/web/libs/editor/src/regions/VectorRegion.jsx +++ b/web/libs/editor/src/regions/VectorRegion.jsx @@ -494,12 +494,12 @@ const Model = types stageZoom: self.parent.stageZoom, bbox: self.bbox, bboxCoords: self.bboxCoords, - vertices: self.vertices.map(v => ({ id: v.id, x: v.x, y: v.y })), + vertices: self.vertices.map((v) => ({ id: v.id, x: v.x, y: v.y })), }); // Delegate to KonvaVector's commitMultiRegionTransform method // This method reads the proxy node coordinates and applies them directly - if (typeof self.vectorRef.commitMultiRegionTransform === 'function') { + if (typeof self.vectorRef.commitMultiRegionTransform === "function") { self.vectorRef.commitMultiRegionTransform(); console.log("📊 commitMultiRegionTransform called successfully"); } else { @@ -543,7 +543,6 @@ const HtxVectorView = observer(({ item, suggestion }) => { item.setKonvaVectorRef(kv)} initialPoints={Array.from(item.vertices)} - name="_transformable" isMultiRegionSelected={item.object?.selectedRegions?.length > 1} onFinish={(e) => { e.evt.stopPropagation(); @@ -570,7 +569,7 @@ const HtxVectorView = observer(({ item, suggestion }) => { stageZoom: item.parent.stageZoom, bbox: item.bbox, bboxCoords: item.bboxCoords, - vertices: item.vertices.map(v => ({ id: v.id, x: v.x, y: v.y })), + vertices: item.vertices.map((v) => ({ id: v.id, x: v.x, y: v.y })), }); // Reset transform attributes @@ -592,7 +591,7 @@ const HtxVectorView = observer(({ item, suggestion }) => { // Apply the transformation exactly as Konva did: // 1. Scale around origin (0,0) - // 2. Rotate around origin (0,0) + // 2. Rotate around origin (0,0) // 3. Translate by (dx, dy) // Don't pass centerX/centerY - transform around origin const radians = rotation * (Math.PI / 180); @@ -601,8 +600,8 @@ const HtxVectorView = observer(({ item, suggestion }) => { const transformedVertices = item.vertices.map((point) => { // Step 1: Scale - let x = point.x * scaleX; - let y = point.y * scaleY; + const x = point.x * scaleX; + const y = point.y * scaleY; // Step 2: Rotate const rx = x * cos - y * sin; @@ -618,8 +617,8 @@ const HtxVectorView = observer(({ item, suggestion }) => { // Transform control points if bezier if (point.isBezier) { if (point.controlPoint1) { - let cp1x = point.controlPoint1.x * scaleX; - let cp1y = point.controlPoint1.y * scaleY; + const cp1x = point.controlPoint1.x * scaleX; + const cp1y = point.controlPoint1.y * scaleY; const cp1rx = cp1x * cos - cp1y * sin; const cp1ry = cp1x * sin + cp1y * cos; result.controlPoint1 = { @@ -628,8 +627,8 @@ const HtxVectorView = observer(({ item, suggestion }) => { }; } if (point.controlPoint2) { - let cp2x = point.controlPoint2.x * scaleX; - let cp2y = point.controlPoint2.y * scaleY; + const cp2x = point.controlPoint2.x * scaleX; + const cp2y = point.controlPoint2.y * scaleY; const cp2rx = cp2x * cos - cp2y * sin; const cp2ry = cp2x * sin + cp2y * cos; result.controlPoint2 = { @@ -645,7 +644,10 @@ const HtxVectorView = observer(({ item, suggestion }) => { // Update the points item.updatePointsFromKonvaVector(transformedVertices); - console.log("🎯 After transform, vertices:", transformedVertices.map(v => ({ id: v.id, x: v.x, y: v.y }))); + console.log( + "🎯 After transform, vertices:", + transformedVertices.map((v) => ({ id: v.id, x: v.x, y: v.y })), + ); } }} onPointsChange={(points) => { @@ -694,7 +696,7 @@ const HtxVectorView = observer(({ item, suggestion }) => { console.log("double click"); item.toggleTransformMode(); }} - transformMode={!disabled && item.transformMode && !item.inSelection} + transformMode={!disabled && item.transformMode} closed={item.closed} width={stageWidth} height={stageHeight} @@ -714,7 +716,6 @@ const HtxVectorView = observer(({ item, suggestion }) => { strokeWidth={regionStyles.strokeWidth} opacity={Number.parseFloat(item.control?.opacity || "1")} pixelSnapping={item.control?.snap === "pixel"} - constrainToBounds={item.control?.constrainToBounds ?? true} disabled={disabled} // Point styling - customize point appearance based on control settings pointRadius={item.pointRadiusFromSize} diff --git a/web/libs/editor/src/tags/control/Vector.js b/web/libs/editor/src/tags/control/Vector.js index cc7c82614c58..f3d7b73b4010 100644 --- a/web/libs/editor/src/tags/control/Vector.js +++ b/web/libs/editor/src/tags/control/Vector.js @@ -40,7 +40,6 @@ const hotkeys = Hotkey("Vectors"); * @param {boolean} [skeleton=false] - Enables skeleton mode to allow branch paths * @param {number|none} [minPoints=none] - Minimum allowed number of points * @param {number|none} [maxPoints=none] - Maximum allowed number of points - * @param {boolean} [constrainToBounds=false] - Whether to keep shapes inside image bounds * @param {number} [pointSizeEnabled=5] - Size of a point in pixels when shape is selected * @param {number} [pointSizeDisabled=3] - Size of a point in pixels when shape is not selected */ @@ -62,7 +61,6 @@ const TagAttrs = types.model({ curves: types.optional(types.maybeNull(types.boolean), false), minpoints: types.optional(types.maybeNull(types.string), null), maxpoints: types.optional(types.maybeNull(types.string), null), - constraintobounds: types.optional(types.maybeNull(types.boolean), false), skeleton: types.optional(types.maybeNull(types.boolean), false), pointsizeenabled: types.optional(types.maybeNull(types.string), "5"), pointsizedisabled: types.optional(types.maybeNull(types.string), "3"), diff --git a/web/libs/editor/src/tags/control/VectorLabels.jsx b/web/libs/editor/src/tags/control/VectorLabels.jsx index 5ec5500be006..7a27f674a683 100644 --- a/web/libs/editor/src/tags/control/VectorLabels.jsx +++ b/web/libs/editor/src/tags/control/VectorLabels.jsx @@ -124,25 +124,6 @@ import ControlBase from "./Base"; * * ``` * - * ### Constrained Drawing - * ```jsx - * - * - * - * - * - * ``` - * * ## Advanced Features * * ### Path Breaking @@ -191,7 +172,6 @@ import ControlBase from "./Base"; * @param {boolean} [skeleton=false] - Enables skeleton mode to allow branch paths * @param {number|none} [minPoints=none] - Minimum allowed number of points * @param {number|none} [maxPoints=none] - Maximum allowed number of points - * @param {boolean} [constrainToBounds=false] - Whether to keep shapes inside image bounds * @param {number} [pointsizeenabled=5] - Size of a point in pixels when shape is selected * @param {number} [pointsizedisabled=5] - Size of a point in pixels when shape is not selected */ @@ -206,7 +186,6 @@ const ModelAttrs = types.model("VectorLabelsModel", { curves: types.optional(types.maybeNull(types.boolean), false), minpoints: types.optional(types.maybeNull(types.string), null), maxpoints: types.optional(types.maybeNull(types.string), null), - constraintobounds: types.optional(types.maybeNull(types.boolean), false), skeleton: types.optional(types.maybeNull(types.boolean), false), pointsizeenabled: types.optional(types.maybeNull(types.string), "5"), pointsizedisabled: types.optional(types.maybeNull(types.string), "3"), From e319cbc187dc5f117f2941a0f6b1b31d7b07c270 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Fri, 24 Oct 2025 17:25:18 +0100 Subject: [PATCH 23/32] Restore double click --- .../src/components/KonvaVector/KonvaVector.tsx | 3 +-- .../components/KonvaVector/eventHandlers/index.ts | 2 -- .../KonvaVector/eventHandlers/mouseHandlers.ts | 12 ------------ .../components/KonvaVector/eventHandlers/types.ts | 2 -- 4 files changed, 1 insertion(+), 18 deletions(-) diff --git a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx index 2059cee72313..221c1809f109 100644 --- a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx +++ b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx @@ -1633,7 +1633,6 @@ export const KonvaVector = forwardRef((props, onMouseMove, onMouseUp, onClick, - onDblClick, notifyTransformationComplete, canAddMorePoints, maxPoints, @@ -1712,7 +1711,7 @@ export const KonvaVector = forwardRef((props, eventHandlers.handleLayerClick(e); } } - onDblClick={disabled ? undefined : eventHandlers.handleLayerDoubleClick} + onDblClick={disabled ? undefined : onDblClick} > {/* Invisible rectangle - always render to capture mouse events for cursor position updates */} {!disabled && ( diff --git a/web/libs/editor/src/components/KonvaVector/eventHandlers/index.ts b/web/libs/editor/src/components/KonvaVector/eventHandlers/index.ts index 3e431cbed70b..ae8f8afd1831 100644 --- a/web/libs/editor/src/components/KonvaVector/eventHandlers/index.ts +++ b/web/libs/editor/src/components/KonvaVector/eventHandlers/index.ts @@ -1,6 +1,5 @@ import { createClickHandler, - createDoubleClickHandler, createMouseDownHandler, createMouseMoveHandler, createMouseUpHandler, @@ -14,7 +13,6 @@ export function createEventHandlers(props: EventHandlerProps): EventHandlers { return { handleLayerMouseDown: createMouseDownHandler(props, handledSelectionInMouseDown), handleLayerClick: createClickHandler(props, handledSelectionInMouseDown), - handleLayerDoubleClick: createDoubleClickHandler(props), handleLayerMouseMove: createMouseMoveHandler(props, handledSelectionInMouseDown), handleLayerMouseUp: createMouseUpHandler(props), }; diff --git a/web/libs/editor/src/components/KonvaVector/eventHandlers/mouseHandlers.ts b/web/libs/editor/src/components/KonvaVector/eventHandlers/mouseHandlers.ts index 3c1b508730ca..871feda2f2f4 100644 --- a/web/libs/editor/src/components/KonvaVector/eventHandlers/mouseHandlers.ts +++ b/web/libs/editor/src/components/KonvaVector/eventHandlers/mouseHandlers.ts @@ -975,18 +975,6 @@ export function createClickHandler(props: EventHandlerProps, handledSelectionInM }; } -export function createDoubleClickHandler(props: EventHandlerProps) { - return (e: KonvaEventObject) => { - // Prevent the click handler from interfering with double-click - e.evt.stopImmediatePropagation(); - - // Call the parent's onDblClick handler if provided - if (props.onDblClick) { - props.onDblClick(e); - } - }; -} - // Helper function to select a point by index function handlePointSelectionFromIndex( pointIndex: number, diff --git a/web/libs/editor/src/components/KonvaVector/eventHandlers/types.ts b/web/libs/editor/src/components/KonvaVector/eventHandlers/types.ts index e5b52ebf2a00..220a71f3ab1d 100644 --- a/web/libs/editor/src/components/KonvaVector/eventHandlers/types.ts +++ b/web/libs/editor/src/components/KonvaVector/eventHandlers/types.ts @@ -85,7 +85,6 @@ export interface EventHandlerProps { onMouseMove?: (e: KonvaEventObject) => void; onMouseUp?: (e?: KonvaEventObject) => void; onClick?: (e: KonvaEventObject) => void; - onDblClick?: (e: KonvaEventObject) => void; notifyTransformationComplete?: () => void; canAddMorePoints?: () => boolean; maxPoints?: number; @@ -135,7 +134,6 @@ export interface EventHandlerProps { export interface EventHandlers { handleLayerMouseDown: (e: KonvaEventObject) => void; handleLayerClick: (e: KonvaEventObject) => void; - handleLayerDoubleClick: (e: KonvaEventObject) => void; handleLayerMouseMove: (e: KonvaEventObject) => void; handleLayerMouseUp: () => void; } From 9388c02a347755fefa6f9f6ed25a426231f24197 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Fri, 24 Oct 2025 17:26:40 +0100 Subject: [PATCH 24/32] Fix alt-click to delete points --- .../components/KonvaVector/KonvaVector.tsx | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx index 221c1809f109..ae7f7b1811c6 100644 --- a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx +++ b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx @@ -19,6 +19,7 @@ import { VectorSelectionTracker, type VectorInstance } from "./VectorSelectionTr import { calculateShapeBoundingBox } from "./utils/bezierBoundingBox"; import { shouldClosePathOnPointClick, isActivePointEligibleForClosing } from "./eventHandlers/pointSelection"; import { handleShiftClickPointConversion } from "./eventHandlers/drawing"; +import { deletePoint } from "./pointManagement"; import type { BezierPoint, GhostPoint as GhostPointType, KonvaVectorProps, KonvaVectorRef } from "./types"; import { ShapeType, ExportFormat, PathType } from "./types"; import { @@ -2070,7 +2071,25 @@ export const KonvaVector = forwardRef((props, pointStrokeSelected={pointStrokeSelected} pointStrokeWidth={pointStrokeWidth} onPointClick={(e, pointIndex) => { - // Handle Shift+click point conversion FIRST (before other checks) + // Handle Alt+click point deletion FIRST (before other checks) + if (e.evt.altKey && !e.evt.shiftKey && !disabled) { + deletePoint( + pointIndex, + initialPoints, + selectedPointIndex, + setSelectedPointIndex, + setVisibleControlPoints, + onPointSelected, + onPointRemoved, + onPointsChange, + setLastAddedPointId, + lastAddedPointId, + ); + pointSelectionHandled.current = true; + return; // Successfully deleted point + } + + // Handle Shift+click point conversion (before other checks) if (e.evt.shiftKey && !e.evt.altKey && !disabled) { if (handleShiftClickPointConversion(e, { initialPoints, From df5814ec85b9bb5ba103a6b2262de6de2d20d7ee Mon Sep 17 00:00:00 2001 From: robot-ci-heartex Date: Mon, 27 Oct 2025 10:24:28 +0000 Subject: [PATCH 25/32] ci: Build tag docs Workflow run: https://github.com/HumanSignal/label-studio/actions/runs/18837605005 --- docs/source/includes/tags/vector.md | 1 - docs/source/includes/tags/vectorlabels.md | 1 - web/libs/core/src/lib/utils/schema/tags.json | 16 +--------------- 3 files changed, 1 insertion(+), 17 deletions(-) diff --git a/docs/source/includes/tags/vector.md b/docs/source/includes/tags/vector.md index 20b3f8a48276..b8ffd703bd5d 100644 --- a/docs/source/includes/tags/vector.md +++ b/docs/source/includes/tags/vector.md @@ -18,7 +18,6 @@ | [skeleton] | boolean | false | Enables skeleton mode to allow branch paths | | [minPoints] | number \| none | none | Minimum allowed number of points | | [maxPoints] | number \| none | none | Maximum allowed number of points | -| [constrainToBounds] | boolean | false | Whether to keep shapes inside image bounds | | [pointSizeEnabled] | number | 5 | Size of a point in pixels when shape is selected | | [pointSizeDisabled] | number | 3 | Size of a point in pixels when shape is not selected | diff --git a/docs/source/includes/tags/vectorlabels.md b/docs/source/includes/tags/vectorlabels.md index 03eda9ac1975..57ce5a6c4d58 100644 --- a/docs/source/includes/tags/vectorlabels.md +++ b/docs/source/includes/tags/vectorlabels.md @@ -19,7 +19,6 @@ | [skeleton] | boolean | false | Enables skeleton mode to allow branch paths | | [minPoints] | number \| none | none | Minimum allowed number of points | | [maxPoints] | number \| none | none | Maximum allowed number of points | -| [constrainToBounds] | boolean | false | Whether to keep shapes inside image bounds | | [pointsizeenabled] | number | 5 | Size of a point in pixels when shape is selected | | [pointsizedisabled] | number | 5 | Size of a point in pixels when shape is not selected | diff --git a/web/libs/core/src/lib/utils/schema/tags.json b/web/libs/core/src/lib/utils/schema/tags.json index 7945b5e88e89..1bf313a16328 100644 --- a/web/libs/core/src/lib/utils/schema/tags.json +++ b/web/libs/core/src/lib/utils/schema/tags.json @@ -2738,13 +2738,6 @@ "required": false, "default": "none" }, - "constrainToBounds": { - "name": "constrainToBounds", - "description": "Whether to keep shapes inside image bounds", - "type": ["true", "false"], - "required": false, - "default": false - }, "pointSizeEnabled": { "name": "pointSizeEnabled", "description": "Size of a point in pixels when shape is selected", @@ -2763,7 +2756,7 @@ }, "VectorLabels": { "name": "VectorLabels", - "description": "The `VectorLabels` tag is used to create labeled vectors. Use to apply labels to vectors in semantic segmentation tasks.\n\nUse with the following data types: image.\n\n## Key Features\n\n### Point Management\n- **Add Points**: Click on empty space, Shift+click on path segments\n- **Edit Points**: Drag to reposition, Shift+click to convert regular ↔ bezier\n- **Delete Points**: Alt+click on existing points\n- **Multi-Selection**: Select multiple points for batch transformations\n- **Break Closed Path**: Alt+click on any segment of a closed path to break it at that specific segment\n\n### Bezier Curves\n- **Create**: Drag while adding points or convert existing points\n- **Edit**: Drag control points, disconnect/reconnect control handles\n- **Control**: `curves` prop to enable/disable bezier functionality\n\n## Keyboard Shortcuts & Hotkeys\n\n### Point Creation & Editing\n- **Click**: Add new point in drawing mode\n- **Shift + Click** on a segment: Add point on path segment (insert between existing points)\n- **Shift + Drag**: Create bezier point with control handles\n- **Shift + Click** on a point: Convert point between regular ↔ bezier\n- **Alt + Click** on a segment: Break closed path at segment (when path is closed)\n\n### Point Selection\n- **Click**: Select single point\n- **Cmd/Ctrl + Click**: Add point to multi-selection\n- **Cmd/Ctrl + Click on shape**: Select all points in the path\n- **Cmd/Ctrl + Click on point**: Toggle point selection in multi-selection\n\n### Path Management\n- **Click on first/last point**: Close path bidirectionally (first→last or last→first)\n- **Shift + Click**: Add point on path segment without closing\n\n### Bezier Curve Control\n- **Drag control points**: Adjust curve shape\n- **Alt + Drag control point**: Disconnect control handles (make asymmetric)\n- **Shift + Drag**: Create new bezier point with control handles\n\n### Multi-Selection & Transformation\n- **Select multiple points**: Use Cmd/Ctrl + Click to build selection\n- **Transform selection**: Use transformer handles for rotation, scaling, and translation\n- **Clear selection**: Click on any point\n\n## Usage Examples\n\n### Basic Vector Path\n```jsx\n\n \n \n \n\n```\n\n### Polygon with Bezier Support\n```jsx\n\n \n \n \n```\n\n### Skeleton Mode for Branching Paths\n```jsx\n\n \n \n \n```\n\n### Keypoint Annotation Tool\n```jsx\n\n \n \n \n```\n\n### Constrained Drawing\n```jsx\n\n \n \n \n```\n\n## Advanced Features\n\n### Path Breaking\nWhen a path is closed, you can break it at any segment:\n- **Alt + Click** on any segment of a closed path\n- The path breaks at that segment\n- The breaking point becomes the first element\n- The point before breaking becomes active\n\n### Skeleton Mode\n- **Purpose**: Create branching paths instead of linear sequences\n- **Behavior**: New points connect to the active point, not the last added point\n- **Use Case**: Tree structures, network diagrams, anatomical features\n\n### Bezier Curve Management\n- **Symmetric Control**: By default, control points move symmetrically\n- **Asymmetric Control**: Hold Alt while dragging to disconnect handles\n- **Control Point Visibility**: Control points are shown when editing bezier points\n\n### Multi-Selection Workflow\n1. **Build Selection**: Use Cmd/Ctrl + Click to add points\n2. **Transform**: Use transformer handles for rotation, scaling, translation\n3. **Batch Operations**: Apply transformations to all selected points\n4. **Clear**: Click outside or use programmatic methods\n\n## Props Reference", + "description": "The `VectorLabels` tag is used to create labeled vectors. Use to apply labels to vectors in semantic segmentation tasks.\n\nUse with the following data types: image.\n\n## Key Features\n\n### Point Management\n- **Add Points**: Click on empty space, Shift+click on path segments\n- **Edit Points**: Drag to reposition, Shift+click to convert regular ↔ bezier\n- **Delete Points**: Alt+click on existing points\n- **Multi-Selection**: Select multiple points for batch transformations\n- **Break Closed Path**: Alt+click on any segment of a closed path to break it at that specific segment\n\n### Bezier Curves\n- **Create**: Drag while adding points or convert existing points\n- **Edit**: Drag control points, disconnect/reconnect control handles\n- **Control**: `curves` prop to enable/disable bezier functionality\n\n## Keyboard Shortcuts & Hotkeys\n\n### Point Creation & Editing\n- **Click**: Add new point in drawing mode\n- **Shift + Click** on a segment: Add point on path segment (insert between existing points)\n- **Shift + Drag**: Create bezier point with control handles\n- **Shift + Click** on a point: Convert point between regular ↔ bezier\n- **Alt + Click** on a segment: Break closed path at segment (when path is closed)\n\n### Point Selection\n- **Click**: Select single point\n- **Cmd/Ctrl + Click**: Add point to multi-selection\n- **Cmd/Ctrl + Click on shape**: Select all points in the path\n- **Cmd/Ctrl + Click on point**: Toggle point selection in multi-selection\n\n### Path Management\n- **Click on first/last point**: Close path bidirectionally (first→last or last→first)\n- **Shift + Click**: Add point on path segment without closing\n\n### Bezier Curve Control\n- **Drag control points**: Adjust curve shape\n- **Alt + Drag control point**: Disconnect control handles (make asymmetric)\n- **Shift + Drag**: Create new bezier point with control handles\n\n### Multi-Selection & Transformation\n- **Select multiple points**: Use Cmd/Ctrl + Click to build selection\n- **Transform selection**: Use transformer handles for rotation, scaling, and translation\n- **Clear selection**: Click on any point\n\n## Usage Examples\n\n### Basic Vector Path\n```jsx\n\n \n \n \n\n```\n\n### Polygon with Bezier Support\n```jsx\n\n \n \n \n```\n\n### Skeleton Mode for Branching Paths\n```jsx\n\n \n \n \n```\n\n### Keypoint Annotation Tool\n```jsx\n\n \n \n \n```\n\n## Advanced Features\n\n### Path Breaking\nWhen a path is closed, you can break it at any segment:\n- **Alt + Click** on any segment of a closed path\n- The path breaks at that segment\n- The breaking point becomes the first element\n- The point before breaking becomes active\n\n### Skeleton Mode\n- **Purpose**: Create branching paths instead of linear sequences\n- **Behavior**: New points connect to the active point, not the last added point\n- **Use Case**: Tree structures, network diagrams, anatomical features\n\n### Bezier Curve Management\n- **Symmetric Control**: By default, control points move symmetrically\n- **Asymmetric Control**: Hold Alt while dragging to disconnect handles\n- **Control Point Visibility**: Control points are shown when editing bezier points\n\n### Multi-Selection Workflow\n1. **Build Selection**: Use Cmd/Ctrl + Click to add points\n2. **Transform**: Use transformer handles for rotation, scaling, translation\n3. **Batch Operations**: Apply transformations to all selected points\n4. **Clear**: Click outside or use programmatic methods\n\n## Props Reference", "attrs": { "name": { "name": "name", @@ -2879,13 +2872,6 @@ "required": false, "default": "none" }, - "constrainToBounds": { - "name": "constrainToBounds", - "description": "Whether to keep shapes inside image bounds", - "type": ["true", "false"], - "required": false, - "default": false - }, "pointsizeenabled": { "name": "pointsizeenabled", "description": "Size of a point in pixels when shape is selected", From 9e2a99133e1ab92d0b1540edd0a68bae5c0dd3dc Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Mon, 27 Oct 2025 11:34:32 +0000 Subject: [PATCH 26/32] Transform bounds --- .../src/components/KonvaVector/KonvaVector.tsx | 14 +++++++------- web/libs/editor/src/regions/VectorRegion.jsx | 17 ++++++++++------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx index ae7f7b1811c6..cc65976ecbd9 100644 --- a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx +++ b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx @@ -1495,11 +1495,11 @@ export const KonvaVector = forwardRef((props, const rx = x * cos - y * sin; const ry = x * sin + y * cos; - // Step 3: Translate + // Step 3: Translate and clamp to image bounds const result = { ...point, - x: rx + dx, - y: ry + dy, + x: Math.max(0, Math.min(width, rx + dx)), + y: Math.max(0, Math.min(height, ry + dy)), }; // Transform control points if bezier @@ -1510,8 +1510,8 @@ export const KonvaVector = forwardRef((props, const cp1rx = cp1x * cos - cp1y * sin; const cp1ry = cp1x * sin + cp1y * cos; result.controlPoint1 = { - x: cp1rx + dx, - y: cp1ry + dy, + x: Math.max(0, Math.min(width, cp1rx + dx)), + y: Math.max(0, Math.min(height, cp1ry + dy)), }; } if (point.controlPoint2) { @@ -1520,8 +1520,8 @@ export const KonvaVector = forwardRef((props, const cp2rx = cp2x * cos - cp2y * sin; const cp2ry = cp2x * sin + cp2y * cos; result.controlPoint2 = { - x: cp2rx + dx, - y: cp2ry + dy, + x: Math.max(0, Math.min(width, cp2rx + dx)), + y: Math.max(0, Math.min(height, cp2ry + dy)), }; } } diff --git a/web/libs/editor/src/regions/VectorRegion.jsx b/web/libs/editor/src/regions/VectorRegion.jsx index c397e2b6d61a..d85373872a44 100644 --- a/web/libs/editor/src/regions/VectorRegion.jsx +++ b/web/libs/editor/src/regions/VectorRegion.jsx @@ -598,6 +598,9 @@ const HtxVectorView = observer(({ item, suggestion }) => { const cos = Math.cos(radians); const sin = Math.sin(radians); + const imageWidth = image?.naturalWidth ?? 0; + const imageHeight = image?.naturalHeight ?? 0; + const transformedVertices = item.vertices.map((point) => { // Step 1: Scale const x = point.x * scaleX; @@ -607,11 +610,11 @@ const HtxVectorView = observer(({ item, suggestion }) => { const rx = x * cos - y * sin; const ry = x * sin + y * cos; - // Step 3: Translate + // Step 3: Translate and clamp to image bounds const result = { ...point, - x: rx + dx, - y: ry + dy, + x: Math.max(0, Math.min(imageWidth, rx + dx)), + y: Math.max(0, Math.min(imageHeight, ry + dy)), }; // Transform control points if bezier @@ -622,8 +625,8 @@ const HtxVectorView = observer(({ item, suggestion }) => { const cp1rx = cp1x * cos - cp1y * sin; const cp1ry = cp1x * sin + cp1y * cos; result.controlPoint1 = { - x: cp1rx + dx, - y: cp1ry + dy, + x: Math.max(0, Math.min(imageWidth, cp1rx + dx)), + y: Math.max(0, Math.min(imageHeight, cp1ry + dy)), }; } if (point.controlPoint2) { @@ -632,8 +635,8 @@ const HtxVectorView = observer(({ item, suggestion }) => { const cp2rx = cp2x * cos - cp2y * sin; const cp2ry = cp2x * sin + cp2y * cos; result.controlPoint2 = { - x: cp2rx + dx, - y: cp2ry + dy, + x: Math.max(0, Math.min(imageWidth, cp2rx + dx)), + y: Math.max(0, Math.min(imageHeight, cp2ry + dy)), }; } } From 65b5279078a5218d010a53cc92545c650f07a368 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Mon, 27 Oct 2025 13:19:32 +0000 Subject: [PATCH 27/32] Proper bounds --- .../components/KonvaVector/KonvaVector.tsx | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx index cc65976ecbd9..01abc6a08dcb 100644 --- a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx +++ b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx @@ -1728,7 +1728,50 @@ export const KonvaVector = forwardRef((props, {/* Conditionally wrap content with _transformable group for ImageTransformer */} {isMultiRegionSelected ? ( - + { + // 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) { + node.position({ x: constrainedX, y: constrainedY }); + } + + console.log(`🔍 VectorDragConstraint: bounds=${imageWidth}x${imageHeight}, pos=(${constrainedX.toFixed(1)}, ${constrainedY.toFixed(1)})`); + } + }} + > {/* Unified vector shape - renders all lines based on id-prevPointId relationships */} Date: Mon, 27 Oct 2025 14:10:43 +0000 Subject: [PATCH 28/32] Proper bounds constraints --- .../components/KonvaVector/KonvaVector.tsx | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx index 01abc6a08dcb..2f51ae0779e1 100644 --- a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx +++ b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx @@ -1765,6 +1765,34 @@ export const KonvaVector = forwardRef((props, // 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 }); } From 76f229dae9365e411826cecd2ee3f2d05f0139f3 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Mon, 27 Oct 2025 14:38:24 +0000 Subject: [PATCH 29/32] Fix regions coordinates reset (regression) --- .../ImageTransformer/ImageTransformer.jsx | 66 +++----------- .../components/KonvaVector/KonvaVector.tsx | 87 ++++++++++++++++--- web/libs/editor/src/regions/VectorRegion.jsx | 5 +- 3 files changed, 91 insertions(+), 67 deletions(-) diff --git a/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx b/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx index c2227a981323..b7d534bf6359 100644 --- a/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx +++ b/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx @@ -174,59 +174,19 @@ export default class TransformerComponent extends Component { }); }; - applyTransformToRegions = () => { + applyTransformToVectorRegions = () => { if (!this.transformer) return; const { item } = this.props; const { selectedRegions } = item; - // Get the transformer's current transform values - const nodes = this.transformer.nodes(); - if (!nodes || nodes.length === 0) return; - - // Apply transform to each selected region individually - // Each node may have different transform values when transforming multiple nodes together - selectedRegions.forEach((region, index) => { - const node = nodes[index]; - if (!node) { - console.warn('🔄 No node found for region:', region.id); - return; - } - - // Get the transform for THIS specific node - const transform = { - dx: node.x(), - dy: node.y(), - scaleX: node.scaleX(), - scaleY: node.scaleY(), - rotation: node.rotation(), - }; - - console.log('🔄 Applying transform to region:', { - regionId: region.id, - ...transform, - }); - + // Only apply custom transform logic for VectorRegion instances + selectedRegions.forEach((region) => { if (region.applyTransform && typeof region.applyTransform === 'function') { - region.applyTransform(transform, null); - } else { - console.log('🔄 Region does not have applyTransform method:', region.id); + console.log('🔄 ImageTransformer calling applyTransform on VectorRegion:', region.id); + region.applyTransform({}, null); } }); - - // Reset the transformer nodes to identity transform - nodes.forEach((node) => { - node.x(0); - node.y(0); - node.scaleX(1); - node.scaleY(1); - node.rotation(0); - }); - - // Detach transformer temporarily to prevent recalculation during re-render - // The transformer will be reattached by checkNode after components update - this.transformer.nodes([]); - this.transformer.getLayer()?.batchDraw(); }; renderLSTransformer() { @@ -269,14 +229,14 @@ export default class TransformerComponent extends Component { }} dragBoundFunc={this.dragBoundFunc} onDragEnd={() => { - // Apply transformations to individual regions - this.applyTransformToRegions(); + // Call applyTransform for VectorRegion instances + this.applyTransformToVectorRegions(); this.unfreeze(); setTimeout(this.checkNode); }} onTransformEnd={() => { - // Apply transformations to individual regions - this.applyTransformToRegions(); + // Call applyTransform for VectorRegion instances + this.applyTransformToVectorRegions(); setTimeout(this.checkNode); }} backSelector={this.props.draggableBackgroundSelector} @@ -321,14 +281,14 @@ export default class TransformerComponent extends Component { }} dragBoundFunc={this.dragBoundFunc} onDragEnd={() => { - // Apply transformations to individual regions - this.applyTransformToRegions(); + // Call applyTransform for VectorRegion instances + this.applyTransformToVectorRegions(); this.unfreeze(); setTimeout(this.checkNode); }} onTransformEnd={() => { - // Apply transformations to individual regions - this.applyTransformToRegions(); + // Call applyTransform for VectorRegion instances + this.applyTransformToVectorRegions(); setTimeout(this.checkNode); }} backSelector={this.props.draggableBackgroundSelector} diff --git a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx index 2f51ae0779e1..aea171bba58b 100644 --- a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx +++ b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx @@ -1445,6 +1445,7 @@ export const KonvaVector = forwardRef((props, // 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; } @@ -1478,18 +1479,57 @@ export const KonvaVector = forwardRef((props, 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; + let constrainedScaleX = scaleX; + let 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 (dx, dy) + // 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 * scaleX; - const y = point.y * scaleY; + const x = point.x * constrainedScaleX; + const y = point.y * constrainedScaleY; // Step 2: Rotate const rx = x * cos - y * sin; @@ -1498,30 +1538,30 @@ export const KonvaVector = forwardRef((props, // Step 3: Translate and clamp to image bounds const result = { ...point, - x: Math.max(0, Math.min(width, rx + dx)), - y: Math.max(0, Math.min(height, ry + dy)), + 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 * scaleX; - const cp1y = point.controlPoint1.y * scaleY; + 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(width, cp1rx + dx)), - y: Math.max(0, Math.min(height, cp1ry + dy)), + 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 * scaleX; - const cp2y = point.controlPoint2.y * scaleY; + 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(width, cp2rx + dx)), - y: Math.max(0, Math.min(height, cp2ry + dy)), + x: Math.max(0, Math.min(imageWidth, cp2rx + constrainedDx)), + y: Math.max(0, Math.min(imageHeight, cp2ry + constrainedDy)), }; } } @@ -1538,6 +1578,7 @@ export const KonvaVector = forwardRef((props, ); // 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); @@ -1554,6 +1595,26 @@ export const KonvaVector = forwardRef((props, }; console.log("📊 Reset _transformable group transform to identity"); + + // 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([]); + + // Force a redraw + stage.batchDraw(); + + // Reattach the transformer after a brief delay + setTimeout(() => { + transformer.nodes(nodes); + stage.batchDraw(); + }, 0); + } + } }, })); diff --git a/web/libs/editor/src/regions/VectorRegion.jsx b/web/libs/editor/src/regions/VectorRegion.jsx index d85373872a44..9ec21c3599fb 100644 --- a/web/libs/editor/src/regions/VectorRegion.jsx +++ b/web/libs/editor/src/regions/VectorRegion.jsx @@ -485,7 +485,10 @@ const Model = types * @param {Object} transformerCenter - Center point used by the ImageTransformer for scaling/rotation */ applyTransform(transform, transformerCenter) { - if (!self.vectorRef) return; + if (!self.vectorRef) { + console.log("🔄 VectorRegion.applyTransform: No vectorRef, returning"); + return; + } console.log("🔄 VectorRegion.applyTransform called:", { regionId: self.id, From 3a799cd765be66bb1a30603008cc145050bb6eb9 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Mon, 27 Oct 2025 15:52:55 +0000 Subject: [PATCH 30/32] Double click handling --- .../components/KonvaVector/KonvaVector.tsx | 68 +++++++++++++++++-- web/libs/editor/src/regions/VectorRegion.jsx | 9 ++- 2 files changed, 67 insertions(+), 10 deletions(-) diff --git a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx index aea171bba58b..8f4aac465643 100644 --- a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx +++ b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx @@ -360,6 +360,12 @@ export const KonvaVector = forwardRef((props, // Ref to track the _transformable group for applying transformations const transformableGroupRef = useRef(null); + // Ref to track click timeout for click/double-click debouncing + const clickTimeoutRef = useRef(null); + + // Flag to track if we've handled a double-click through debouncing + const doubleClickHandledRef = useRef(false); + // Track initial transform state for delta calculation const initialTransformRef = useRef<{ x: number; @@ -1618,6 +1624,16 @@ export const KonvaVector = forwardRef((props, }, })); + // Clean up click timeout on unmount + useEffect(() => { + return () => { + if (clickTimeoutRef.current) { + clearTimeout(clickTimeoutRef.current); + clickTimeoutRef.current = null; + } + }; + }, []); + // Handle Shift key for disconnected mode useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -1641,6 +1657,38 @@ export const KonvaVector = forwardRef((props, }; }, []); + // 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); + + // 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); + } + // Reset the flag after a short delay + setTimeout(() => { + doubleClickHandledRef.current = false; + }, 100); + return; + } + + // Set a timeout for single-click handling + console.log("🖱️ Single-click detected, setting timeout"); + clickTimeoutRef.current = setTimeout(() => { + clickTimeoutRef.current = null; + console.log("🖱️ Single-click timeout fired, calling onClickHandler"); + if (onClickHandler) { + onClickHandler(e); + } + }, 300); + }, []); + // Create event handlers const eventHandlers = createEventHandlers({ instanceId, @@ -1773,7 +1821,17 @@ export const KonvaVector = forwardRef((props, eventHandlers.handleLayerClick(e); } } - onDblClick={disabled ? undefined : onDblClick} + 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 && ( @@ -1903,8 +1961,8 @@ export const KonvaVector = forwardRef((props, } } - // Call the original onClick handler - onClick?.(e); + // Use debouncing for click/double-click detection + handleClickWithDebouncing(e, onClick, onDblClick); }} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} @@ -2144,8 +2202,8 @@ export const KonvaVector = forwardRef((props, } } - // Call the original onClick handler - onClick?.(e); + // Use debouncing for click/double-click detection + handleClickWithDebouncing(e, onClick, onDblClick); }} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} diff --git a/web/libs/editor/src/regions/VectorRegion.jsx b/web/libs/editor/src/regions/VectorRegion.jsx index 9ec21c3599fb..71f600869a04 100644 --- a/web/libs/editor/src/regions/VectorRegion.jsx +++ b/web/libs/editor/src/regions/VectorRegion.jsx @@ -680,10 +680,8 @@ const HtxVectorView = observer(({ item, suggestion }) => { stage.container().style.cursor = Constants.DEFAULT_CURSOR; } - if (!item.selected) { - item.setHighlight(false); - item.onClickRegion(e); - } + item.setHighlight(false); + item.onClickRegion(e); }} onMouseEnter={() => { if (store.annotationStore.selected.isLinkingMode) { @@ -699,7 +697,8 @@ const HtxVectorView = observer(({ item, suggestion }) => { }} onDblClick={(e) => { e.evt.stopImmediatePropagation(); - console.log("double click"); + e.evt.stopPropagation(); + e.evt.preventDefault(); item.toggleTransformMode(); }} transformMode={!disabled && item.transformMode} From 3033a21f41d074be2775b55bc61cb3355b5c060c Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Wed, 29 Oct 2025 13:29:30 +0000 Subject: [PATCH 31/32] Remove logs --- .../ImageTransformer/ImageTransformer.jsx | 17 ++++----- web/libs/editor/src/regions/VectorRegion.jsx | 38 ------------------- 2 files changed, 7 insertions(+), 48 deletions(-) diff --git a/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx b/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx index b7d534bf6359..72a4bb2651fc 100644 --- a/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx +++ b/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx @@ -174,18 +174,15 @@ export default class TransformerComponent extends Component { }); }; - applyTransformToVectorRegions = () => { + applyRegionsTransform = () => { if (!this.transformer) return; const { item } = this.props; const { selectedRegions } = item; - // Only apply custom transform logic for VectorRegion instances + selectedRegions.forEach((region) => { - if (region.applyTransform && typeof region.applyTransform === 'function') { - console.log('🔄 ImageTransformer calling applyTransform on VectorRegion:', region.id); - region.applyTransform({}, null); - } + region.applyTransform?.({}, null); }); }; @@ -230,13 +227,13 @@ export default class TransformerComponent extends Component { dragBoundFunc={this.dragBoundFunc} onDragEnd={() => { // Call applyTransform for VectorRegion instances - this.applyTransformToVectorRegions(); + this.applyRegionsTransform(); this.unfreeze(); setTimeout(this.checkNode); }} onTransformEnd={() => { // Call applyTransform for VectorRegion instances - this.applyTransformToVectorRegions(); + this.applyRegionsTransform(); setTimeout(this.checkNode); }} backSelector={this.props.draggableBackgroundSelector} @@ -282,13 +279,13 @@ export default class TransformerComponent extends Component { dragBoundFunc={this.dragBoundFunc} onDragEnd={() => { // Call applyTransform for VectorRegion instances - this.applyTransformToVectorRegions(); + this.applyRegionsTransform(); this.unfreeze(); setTimeout(this.checkNode); }} onTransformEnd={() => { // Call applyTransform for VectorRegion instances - this.applyTransformToVectorRegions(); + this.applyRegionsTransform(); setTimeout(this.checkNode); }} backSelector={this.props.draggableBackgroundSelector} diff --git a/web/libs/editor/src/regions/VectorRegion.jsx b/web/libs/editor/src/regions/VectorRegion.jsx index 71f600869a04..967455fd80fe 100644 --- a/web/libs/editor/src/regions/VectorRegion.jsx +++ b/web/libs/editor/src/regions/VectorRegion.jsx @@ -486,25 +486,13 @@ const Model = types */ applyTransform(transform, transformerCenter) { if (!self.vectorRef) { - console.log("🔄 VectorRegion.applyTransform: No vectorRef, returning"); return; } - console.log("🔄 VectorRegion.applyTransform called:", { - regionId: self.id, - transform, - transformerCenter, - stageZoom: self.parent.stageZoom, - bbox: self.bbox, - bboxCoords: self.bboxCoords, - vertices: self.vertices.map((v) => ({ id: v.id, x: v.x, y: v.y })), - }); - // Delegate to KonvaVector's commitMultiRegionTransform method // This method reads the proxy node coordinates and applies them directly if (typeof self.vectorRef.commitMultiRegionTransform === "function") { self.vectorRef.commitMultiRegionTransform(); - console.log("📊 commitMultiRegionTransform called successfully"); } else { console.error("📊 commitMultiRegionTransform method not available"); } @@ -562,19 +550,6 @@ const HtxVectorView = observer(({ item, suggestion }) => { const scaleY = t.getAttr("scaleY", 1); const rotation = t.getAttr("rotation", 0); - console.log("🎯 Single-region onTransformEnd:", { - regionId: item.id, - dx, - dy, - scaleX, - scaleY, - rotation, - stageZoom: item.parent.stageZoom, - bbox: item.bbox, - bboxCoords: item.bboxCoords, - vertices: item.vertices.map((v) => ({ id: v.id, x: v.x, y: v.y })), - }); - // Reset transform attributes t.setAttr("x", 0); t.setAttr("y", 0); @@ -584,14 +559,6 @@ const HtxVectorView = observer(({ item, suggestion }) => { // Apply transformation to all points using KonvaVector methods if (item.vectorRef) { - console.log("🎯 Calling transformPoints with:", { - dx, - dy, - scaleX, - scaleY, - rotation, - }); - // Apply the transformation exactly as Konva did: // 1. Scale around origin (0,0) // 2. Rotate around origin (0,0) @@ -649,11 +616,6 @@ const HtxVectorView = observer(({ item, suggestion }) => { // Update the points item.updatePointsFromKonvaVector(transformedVertices); - - console.log( - "🎯 After transform, vertices:", - transformedVertices.map((v) => ({ id: v.id, x: v.x, y: v.y })), - ); } }} onPointsChange={(points) => { From 78c024991c2df7aeb3ff4165750b16900e65f304 Mon Sep 17 00:00:00 2001 From: Nick Skriabin Date: Mon, 3 Nov 2025 10:51:36 +0000 Subject: [PATCH 32/32] Formatting --- .../ImageTransformer/ImageTransformer.jsx | 1 - .../components/KonvaVector/KonvaVector.tsx | 146 ++++++++++-------- 2 files changed, 80 insertions(+), 67 deletions(-) diff --git a/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx b/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx index 72a4bb2651fc..466f8bb58f3c 100644 --- a/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx +++ b/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx @@ -180,7 +180,6 @@ export default class TransformerComponent extends Component { const { item } = this.props; const { selectedRegions } = item; - selectedRegions.forEach((region) => { region.applyTransform?.({}, null); }); diff --git a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx index 8f4aac465643..1aab09658e1e 100644 --- a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx +++ b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx @@ -1491,13 +1491,13 @@ export const KonvaVector = forwardRef((props, let constrainedDx = dx; let constrainedDy = dy; - let constrainedScaleX = scaleX; - let constrainedScaleY = scaleY; + 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 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); @@ -1605,7 +1605,7 @@ export const KonvaVector = forwardRef((props, // Detach and reattach the transformer to prevent resizing issues const stage = transformableGroup.getStage(); if (stage) { - const transformer = stage.findOne('Transformer'); + const transformer = stage.findOne("Transformer"); if (transformer) { // Temporarily detach the transformer const nodes = transformer.nodes(); @@ -1658,36 +1658,39 @@ export const KonvaVector = forwardRef((props, }, []); // 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); - - // 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); - } - // Reset the flag after a short delay - setTimeout(() => { - doubleClickHandledRef.current = false; - }, 100); - return; - } + const handleClickWithDebouncing = useCallback( + (e: any, onClickHandler?: (e: any) => void, onDblClickHandler?: (e: any) => void) => { + console.log("🖱 handleClickWithDebouncing called, timeout exists:", !!clickTimeoutRef.current); - // Set a timeout for single-click handling - console.log("🖱️ Single-click detected, setting timeout"); - clickTimeoutRef.current = setTimeout(() => { - clickTimeoutRef.current = null; - console.log("🖱️ Single-click timeout fired, calling onClickHandler"); - if (onClickHandler) { - onClickHandler(e); + // 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); + } + // Reset the flag after a short delay + setTimeout(() => { + doubleClickHandledRef.current = false; + }, 100); + return; } - }, 300); - }, []); + + // Set a timeout for single-click handling + console.log("🖱 Single-click detected, setting timeout"); + clickTimeoutRef.current = setTimeout(() => { + clickTimeoutRef.current = null; + console.log("🖱 Single-click timeout fired, calling onClickHandler"); + if (onClickHandler) { + onClickHandler(e); + } + }, 300); + }, + [], + ); // Create event handlers const eventHandlers = createEventHandlers({ @@ -1821,17 +1824,21 @@ export const KonvaVector = forwardRef((props, eventHandlers.handleLayerClick(e); } } - 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); - }} + 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 && ( @@ -1860,8 +1867,8 @@ export const KonvaVector = forwardRef((props, 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 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); @@ -1887,25 +1894,28 @@ export const KonvaVector = forwardRef((props, // For multi-region selection, apply the same constraint to all selected shapes if (isMultiRegionSelected) { const stage = node.getStage(); - const allTransformableGroups = stage?.find('._transformable'); + 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)})`); + console.log( + `🔍 Multi-region constraint offset: (${constraintOffsetX.toFixed(1)}, ${constraintOffsetY.toFixed(1)})`, + ); // Apply the same constraint to all other transformable groups - allTransformableGroups.forEach(group => { + 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)})`); + 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 + y: currentPos.y + constraintOffsetY, }); } }); @@ -1915,7 +1925,9 @@ export const KonvaVector = forwardRef((props, node.position({ x: constrainedX, y: constrainedY }); } - console.log(`🔍 VectorDragConstraint: bounds=${imageWidth}x${imageHeight}, pos=(${constrainedX.toFixed(1)}, ${constrainedY.toFixed(1)})`); + console.log( + `🔍 VectorDragConstraint: bounds=${imageWidth}x${imageHeight}, pos=(${constrainedX.toFixed(1)}, ${constrainedY.toFixed(1)})`, + ); } }} > @@ -2281,18 +2293,20 @@ export const KonvaVector = forwardRef((props, // Handle Shift+click point conversion (before other checks) if (e.evt.shiftKey && !e.evt.altKey && !disabled) { - if (handleShiftClickPointConversion(e, { - initialPoints, - transform, - fitScale, - x, - y, - allowBezier, - pixelSnapping, - onPointsChange, - onPointEdited, - setVisibleControlPoints, - })) { + if ( + handleShiftClickPointConversion(e, { + initialPoints, + transform, + fitScale, + x, + y, + allowBezier, + pixelSnapping, + onPointsChange, + onPointEdited, + setVisibleControlPoints, + }) + ) { pointSelectionHandled.current = true; return; // Successfully converted point } @@ -2355,7 +2369,7 @@ export const KonvaVector = forwardRef((props, proxyRefs={proxyRefs} onPointsChange={onPointsChange} onTransformationComplete={notifyTransformationComplete} - bounds={{ x: 0, y: 0, width, height }} + bounds={{ x: 0, y: 0, width, height }} transform={transform} fitScale={fitScale} />