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", diff --git a/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx b/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx index 987b03211ab7..466f8bb58f3c 100644 --- a/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx +++ b/web/libs/editor/src/components/ImageTransformer/ImageTransformer.jsx @@ -174,6 +174,17 @@ export default class TransformerComponent extends Component { }); }; + applyRegionsTransform = () => { + if (!this.transformer) return; + + const { item } = this.props; + const { selectedRegions } = item; + + selectedRegions.forEach((region) => { + region.applyTransform?.({}, null); + }); + }; + renderLSTransformer() { return ( <> @@ -214,10 +225,14 @@ export default class TransformerComponent extends Component { }} dragBoundFunc={this.dragBoundFunc} onDragEnd={() => { + // Call applyTransform for VectorRegion instances + this.applyRegionsTransform(); this.unfreeze(); setTimeout(this.checkNode); }} onTransformEnd={() => { + // Call applyTransform for VectorRegion instances + this.applyRegionsTransform(); setTimeout(this.checkNode); }} backSelector={this.props.draggableBackgroundSelector} @@ -262,10 +277,14 @@ export default class TransformerComponent extends Component { }} dragBoundFunc={this.dragBoundFunc} onDragEnd={() => { + // Call applyTransform for VectorRegion instances + this.applyRegionsTransform(); this.unfreeze(); setTimeout(this.checkNode); }} onTransformEnd={() => { + // Call applyTransform for VectorRegion instances + this.applyRegionsTransform(); 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 e3a6e0f4b42a..1aab09658e1e 100644 --- a/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx +++ b/web/libs/editor/src/components/KonvaVector/KonvaVector.tsx @@ -18,6 +18,8 @@ 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 { deletePoint } from "./pointManagement"; import type { BezierPoint, GhostPoint as GhostPointType, KonvaVectorProps, KonvaVectorRef } from "./types"; import { ShapeType, ExportFormat, PathType } from "./types"; import { @@ -232,6 +234,8 @@ export const KonvaVector = forwardRef((props, onMouseMove, onMouseUp, onClick, + onDblClick, + onTransformEnd, onMouseEnter, onMouseLeave, allowClose = false, @@ -245,6 +249,8 @@ export const KonvaVector = forwardRef((props, fill = DEFAULT_FILL_COLOR, pixelSnapping = false, disabled = false, + transformMode = false, + isMultiRegionSelected = false, pointRadius, pointFill = DEFAULT_POINT_FILL, pointStroke = DEFAULT_POINT_STROKE, @@ -275,6 +281,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); @@ -343,6 +357,42 @@ 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); + + // 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; + 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(), []); @@ -391,8 +441,13 @@ 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; } @@ -572,14 +627,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) { @@ -606,7 +661,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 @@ -1393,8 +1448,192 @@ export const KonvaVector = forwardRef((props, return false; }, + // Multi-region transformation method - applies group transform to points + commitMultiRegionTransform: () => { + if (!isMultiRegionSelected || !transformableGroupRef.current || !initialTransformRef.current) { + console.log("🔄 commitMultiRegionTransform: Early return - not multi-region or missing refs"); + return; + } + + console.log("🔄 KonvaVector.commitMultiRegionTransform called"); + + // Get the _transformable group + const transformableGroup = transformableGroupRef.current; + + // Get the group's current transform values + const currentX = transformableGroup.x(); + const currentY = transformableGroup.y(); + const currentScaleX = transformableGroup.scaleX(); + const currentScaleY = transformableGroup.scaleY(); + const currentRotation = transformableGroup.rotation(); + + // Calculate deltas from initial state + const initial = initialTransformRef.current; + const dx = currentX - initial.x; + const dy = currentY - initial.y; + const scaleX = currentScaleX / initial.scaleX; + const scaleY = currentScaleY / initial.scaleY; + const rotation = currentRotation - initial.rotation; + + console.log("📊 Transform deltas:", { + dx, + dy, + scaleX, + scaleY, + rotation, + initial, + current: { x: currentX, y: currentY, scaleX: currentScaleX, scaleY: currentScaleY, rotation: currentRotation }, + }); + + // Apply constraints to the transform before committing + const imageWidth = width || 0; + const imageHeight = height || 0; + + let constrainedDx = dx; + let constrainedDy = dy; + const constrainedScaleX = scaleX; + const constrainedScaleY = scaleY; + + if (imageWidth > 0 && imageHeight > 0) { + // Calculate bounding box of current points after transform + const xs = initialPoints.map((p) => p.x); + const ys = initialPoints.map((p) => p.y); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + + // Apply scale and position to get new bounds + const scaledMinX = minX * scaleX + dx; + const scaledMaxX = maxX * scaleX + dx; + const scaledMinY = minY * scaleY + dy; + const scaledMaxY = maxY * scaleY + dy; + + // Apply constraints + if (scaledMinX < 0) constrainedDx = dx - scaledMinX; + if (scaledMaxX > imageWidth) constrainedDx = dx - (scaledMaxX - imageWidth); + if (scaledMinY < 0) constrainedDy = dy - scaledMinY; + if (scaledMaxY > imageHeight) constrainedDy = dy - (scaledMaxY - imageHeight); + + console.log("🔍 Transform constraints applied:", { + original: { dx, dy, scaleX, scaleY }, + constrained: { dx: constrainedDx, dy: constrainedDy, scaleX: constrainedScaleX, scaleY: constrainedScaleY }, + bounds: `${imageWidth}x${imageHeight}`, + shapeBounds: `(${minX.toFixed(1)}, ${minY.toFixed(1)}) to (${maxX.toFixed(1)}, ${maxY.toFixed(1)})`, + newBounds: `(${scaledMinX.toFixed(1)}, ${scaledMinY.toFixed(1)}) to (${scaledMaxX.toFixed(1)}, ${scaledMaxY.toFixed(1)})`, + }); + } + + // Apply the transformation exactly as the single-region onTransformEnd handler does: + // 1. Scale around origin (0,0) + // 2. Rotate around origin (0,0) + // 3. Translate by (constrainedDx, constrainedDy) + const radians = rotation * (Math.PI / 180); + const cos = Math.cos(radians); + const sin = Math.sin(radians); + + const transformedVertices = initialPoints.map((point) => { + // Step 1: Scale + const x = point.x * constrainedScaleX; + const y = point.y * constrainedScaleY; + + // Step 2: Rotate + const rx = x * cos - y * sin; + const ry = x * sin + y * cos; + + // Step 3: Translate and clamp to image bounds + const result = { + ...point, + x: Math.max(0, Math.min(imageWidth, rx + constrainedDx)), + y: Math.max(0, Math.min(imageHeight, ry + constrainedDy)), + }; + + // Transform control points if bezier + if (point.isBezier) { + if (point.controlPoint1) { + const cp1x = point.controlPoint1.x * constrainedScaleX; + const cp1y = point.controlPoint1.y * constrainedScaleY; + const cp1rx = cp1x * cos - cp1y * sin; + const cp1ry = cp1x * sin + cp1y * cos; + result.controlPoint1 = { + x: Math.max(0, Math.min(imageWidth, cp1rx + constrainedDx)), + y: Math.max(0, Math.min(imageHeight, cp1ry + constrainedDy)), + }; + } + if (point.controlPoint2) { + const cp2x = point.controlPoint2.x * constrainedScaleX; + const cp2y = point.controlPoint2.y * constrainedScaleY; + const cp2rx = cp2x * cos - cp2y * sin; + const cp2ry = cp2x * sin + cp2y * cos; + result.controlPoint2 = { + x: Math.max(0, Math.min(imageWidth, cp2rx + constrainedDx)), + y: Math.max(0, Math.min(imageHeight, cp2ry + constrainedDy)), + }; + } + } + + return result; + }); + + // Update the points + onPointsChange?.(transformedVertices); + + console.log( + "📊 Updated points:", + transformedVertices.map((p) => ({ id: p.id, x: p.x, y: p.y })), + ); + + // Reset the _transformable group transform to identity + // This ensures the visual representation matches the committed data + transformableGroup.x(0); + transformableGroup.y(0); + transformableGroup.scaleX(1); + transformableGroup.scaleY(1); + transformableGroup.rotation(0); + + // Update the initial transform state to reflect the reset + initialTransformRef.current = { + x: 0, + y: 0, + scaleX: 1, + scaleY: 1, + rotation: 0, + }; + + 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); + } + } + }, })); + // 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) => { @@ -1418,6 +1657,41 @@ 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, @@ -1425,7 +1699,7 @@ export const KonvaVector = forwardRef((props, width, height, pixelSnapping, - selectedPoints, + selectedPoints: effectiveSelectedPoints, selectedPointIndex, setSelectedPointIndex, setSelectedPoints, @@ -1509,9 +1783,62 @@ 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) { + // Use the same coordinate transformation as the event handlers + const imagePos = { + 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 (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); } } + 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 && ( @@ -1525,255 +1852,530 @@ export const KonvaVector = forwardRef((props, /> )} - {/* Unified vector shape - renders all lines based on id-prevPointId relationships */} - { - // 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 - } + {/* 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) { + // 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, + }); + } + }); + } + } - // Select all points in the path - const allPointIndices = Array.from({ length: initialPoints.length }, (_, i) => i); - tracker.selectPoints(instanceId, new Set(allPointIndices)); - return; - } + node.position({ x: constrainedX, y: constrainedY }); + } - // 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, + 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 */} + { + // 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; + } + } + } + } + + // Use debouncing for click/double-click detection + handleClickWithDebouncing(e, onClick, onDblClick); + }} + 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 && ( + `${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, + ) && + 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 (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 (distance <= hitRadius) { - // Find the index of the last added point - const lastAddedPointIndex = initialPoints.findIndex((p) => p.id === lastAddedPointId); + // 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 trigger onFinish if the last added point is already selected (second click) + // 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 (lastAddedPointIndex !== -1 && selectedPoints.has(lastAddedPointIndex) && !disabled) { + if (isLastAddedPoint && isAlreadySelected && !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; } - } - } - } - // 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 && ( - `${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, - ) && - 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 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])); + } + } - // 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; - } + // Call the original onClick handler if provided + onClick?.(e); - // 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 + // Mark that we handled selection and prevent all other handlers from running + pointSelectionHandled.current = true; + e.evt.stopImmediatePropagation(); 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); - tracker.selectPoints(instanceId, newSelection); - } else { - // Select only this point - tracker.selectPoints(instanceId, new Set([pointIndex])); - } + // 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 and not multi-region selected */} + {drawingDisabled && !isMultiRegionSelected && ( + { + // Update main path points + onPointsChange?.(newPoints); + }} + onTransformStateChange={(state) => { + transformerStateRef.current = state; + }} + onTransformationStart={() => { + setIsTransforming(true); + }} + onTransformationEnd={() => { + setIsTransforming(false); + }} + /> + )} + + {/* Ghost point */} + + + ) : ( + <> + {/* 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; + } + } + } + } - // Call the original onClick handler if provided - onClick?.(e); + // Use debouncing for click/double-click detection + handleClickWithDebouncing(e, onClick, onDblClick); + }} + 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 && ( + `${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 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 + } - // Mark that we handled selection and prevent all other handlers from running - pointSelectionHandled.current = true; - e.evt.stopImmediatePropagation(); - return; - } + // 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, + }) + ) { + pointSelectionHandled.current = true; + return; // Successfully converted point + } + } - // When not disabled, let the normal event handlers handle it - // The point click will be detected by the layer-level handlers - // - }} - /> + // 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 + } - {/* Proxy nodes for Transformer (positioned at exact point centers) - only show when not in drawing mode */} - {drawingDisabled && ( - - )} + // 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 + } + } - {/* Transformer for multiselection - only show when not in drawing mode */} - {drawingDisabled && ( - { - // Update main path points - onPointsChange?.(newPoints); - }} - onTransformStateChange={(state) => { - transformerStateRef.current = state; - }} - onTransformationStart={() => { - setIsTransforming(true); - }} - onTransformationEnd={() => { - setIsTransforming(false); - }} - /> + // Mark that point selection was handled + pointSelectionHandled.current = true; + }} + 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 && ( + + )} + )} - - {/* Ghost point */} - ); }); diff --git a/web/libs/editor/src/components/KonvaVector/VectorSelectionTracker.ts b/web/libs/editor/src/components/KonvaVector/VectorSelectionTracker.ts index a0b664868dc2..2525ed0f2170 100644 --- a/web/libs/editor/src/components/KonvaVector/VectorSelectionTracker.ts +++ b/web/libs/editor/src/components/KonvaVector/VectorSelectionTracker.ts @@ -65,10 +65,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); @@ -78,7 +76,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; } @@ -95,7 +94,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 diff --git a/web/libs/editor/src/components/KonvaVector/components/VectorTransformer.tsx b/web/libs/editor/src/components/KonvaVector/components/VectorTransformer.tsx index 5d3215df6807..9fcf3b3516fb 100644 --- a/web/libs/editor/src/components/KonvaVector/components/VectorTransformer.tsx +++ b/web/libs/editor/src/components/KonvaVector/components/VectorTransformer.tsx @@ -27,6 +27,7 @@ interface VectorTransformerProps { }) => void; onTransformationStart?: () => void; onTransformationEnd?: () => void; + onTransformEnd?: (e: any) => void; bounds?: { x: number; y: number; width: number; height: number }; scaleX?: number; scaleY?: number; @@ -43,6 +44,7 @@ export const VectorTransformer: React.FC = ({ onTransformStateChange, onTransformationStart, onTransformationEnd, + onTransformEnd, bounds, scaleX = 1, scaleY = 1, @@ -503,6 +505,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 8b8664d21964..0ee8ebbb0626 100644 --- a/web/libs/editor/src/components/KonvaVector/types.ts +++ b/web/libs/editor/src/components/KonvaVector/types.ts @@ -201,12 +201,22 @@ export interface KonvaVectorProps { onMouseUp?: (e?: KonvaEventObject) => void; /** Click event handler */ 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 */ onMouseLeave?: (e: KonvaEventObject) => void; /** Disable all interactions when true */ disabled?: boolean; + /** Enable transform mode where all points are treated as selected */ + transformMode?: boolean; + /** Whether multiple regions are currently selected (disables internal transformer) */ + isMultiRegionSelected?: 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 5d4c3b616eee..967455fd80fe 100644 --- a/web/libs/editor/src/regions/VectorRegion.jsx +++ b/web/libs/editor/src/regions/VectorRegion.jsx @@ -54,13 +54,18 @@ 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, selectedPoint: null, hideable: true, _supportsTransform: true, - useTransformer: true, + useTransformer: false, preferTransformer: false, supportsRotate: true, supportsScale: true, @@ -159,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); }, })) @@ -228,6 +233,7 @@ const Model = types _selectArea(additiveMode = false) { const annotation = self.annotation; + self.setTransformMode(true); if (!annotation) return; if (additiveMode) { @@ -465,6 +471,32 @@ const Model = types } tool?.complete(); }, + toggleTransformMode() { + self.setTransformMode(!self.transformMode); + }, + setTransformMode(transformMode) { + self.transformMode = transformMode; + }, + + /** + * 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, transformerCenter) { + if (!self.vectorRef) { + return; + } + + // 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(); + } else { + console.error("📊 commitMultiRegionTransform method not available"); + } + }, }; }); @@ -489,6 +521,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) { @@ -497,15 +530,94 @@ const HtxVectorView = observer(({ item, suggestion }) => { return ( - item.segGroupRef(ref)}> + item.segGroupRef(ref)} name={item.id}> item.setKonvaVectorRef(kv)} initialPoints={Array.from(item.vertices)} + isMultiRegionSelected={item.object?.selectedRegions?.length > 1} 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) { + // 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 imageWidth = image?.naturalWidth ?? 0; + const imageHeight = image?.naturalHeight ?? 0; + + const transformedVertices = item.vertices.map((point) => { + // Step 1: Scale + const x = point.x * scaleX; + const y = point.y * scaleY; + + // Step 2: Rotate + const rx = x * cos - y * sin; + const ry = x * sin + y * cos; + + // Step 3: Translate and clamp to image bounds + const result = { + ...point, + x: Math.max(0, Math.min(imageWidth, rx + dx)), + y: Math.max(0, Math.min(imageHeight, ry + dy)), + }; + + // Transform control points if bezier + if (point.isBezier) { + if (point.controlPoint1) { + 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 = { + x: Math.max(0, Math.min(imageWidth, cp1rx + dx)), + y: Math.max(0, Math.min(imageHeight, cp1ry + dy)), + }; + } + if (point.controlPoint2) { + 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 = { + x: Math.max(0, Math.min(imageWidth, cp2rx + dx)), + y: Math.max(0, Math.min(imageHeight, cp2ry + dy)), + }; + } + } + + return result; + }); + + // Update the points + item.updatePointsFromKonvaVector(transformedVertices); + } + }} onPointsChange={(points) => { item.updatePointsFromKonvaVector(points); }} @@ -545,6 +657,13 @@ const HtxVectorView = observer(({ item, suggestion }) => { } item.updateCursor(); }} + onDblClick={(e) => { + e.evt.stopImmediatePropagation(); + e.evt.stopPropagation(); + e.evt.preventDefault(); + item.toggleTransformMode(); + }} + transformMode={!disabled && item.transformMode} closed={item.closed} width={stageWidth} height={stageHeight} @@ -564,8 +683,7 @@ 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={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"} 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"), 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, }); },