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```\n\n### Polygon with Bezier Support\n```jsx\n\n \n \n \n \n \n\n```\n\n### Skeleton Mode for Branching Paths\n```jsx\n\n \n \n \n \n \n\n```\n\n### Keypoint Annotation Tool\n```jsx\n\n \n \n \n \n \n \n\n```\n\n### Constrained Drawing\n```jsx\n\n \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```\n\n### Polygon with Bezier Support\n```jsx\n\n \n \n \n \n \n\n```\n\n### Skeleton Mode for Branching Paths\n```jsx\n\n \n \n \n \n \n\n```\n\n### Keypoint Annotation Tool\n```jsx\n\n \n \n \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,
});
},