diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index 5d1dec477..c2e69e42d 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -7,6 +7,8 @@ import { type LocalPoint, pointDistance, vectorFromPoint, + line, + linesIntersectAt, } from "@excalidraw/math"; import { getCurvePathOps } from "@excalidraw/utils/shape"; @@ -25,7 +27,7 @@ import { snapLinearElementPoint, } from "@excalidraw/element/snapping"; -import type { Store } from "@excalidraw/element"; +import { ShapeCache, type Store } from "@excalidraw/element"; import type { Radians } from "@excalidraw/math"; @@ -60,8 +62,6 @@ import { isFixedPointBinding, } from "./typeChecks"; -import { ShapeCache } from "./ShapeCache"; - import { isPathALoop, getBezierCurveLength, @@ -327,7 +327,6 @@ export class LinearElementEditor { : 0 : linearElementEditor.pointerDownState.lastClickedPoint; - // point that's being dragged (out of all selected points) const draggingPoint = element.points[lastClickedPoint]; let _snapLines: SnapLine[] = []; @@ -348,13 +347,119 @@ export class LinearElementEditor { element.points[selectedIndex][0] - referencePoint[0], ); - const [width, height] = LinearElementEditor._getShiftLockedDelta( - element, - elementsMap, - referencePoint, - pointFrom(scenePointerX, scenePointerY), - event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), - customLineAngle, + const referencePointCoords = + LinearElementEditor.getPointGlobalCoordinates( + element, + referencePoint, + elementsMap, + ); + + const [gridX, gridY] = getGridPoint( + scenePointerX, + scenePointerY, + event[KEYS.CTRL_OR_CMD] || isElbowArrow(element) + ? null + : app.getEffectiveGridSize(), + ); + + let dxFromReference = gridX - referencePointCoords[0]; + let dyFromReference = gridY - referencePointCoords[1]; + + if (shouldRotateWithDiscreteAngle(event)) { + ({ width: dxFromReference, height: dyFromReference } = + getLockedLinearCursorAlignSize( + referencePointCoords[0], + referencePointCoords[1], + gridX, + gridY, + customLineAngle, + )); + } + + const effectiveGridX = referencePointCoords[0] + dxFromReference; + const effectiveGridY = referencePointCoords[1] + dyFromReference; + + let newDraggingPointPosition = pointFrom( + effectiveGridX, + effectiveGridY, + ); + + if (!isElbowArrow(element)) { + const { snapOffset, snapLines } = snapLinearElementPoint( + scene.getNonDeletedElements(), + element, + lastClickedPoint, + { x: effectiveGridX, y: effectiveGridY }, + app, + event, + elementsMap, + { includeSelfPoints: true }, + ); + + _snapLines = snapLines; + + if (snapLines.length > 0 && shouldRotateWithDiscreteAngle(event)) { + const angleLine = line( + pointFrom(effectiveGridX, effectiveGridY), + pointFrom(referencePointCoords[0], referencePointCoords[1]), + ); + + const firstSnapLine = snapLines[0]; + if ( + firstSnapLine.type === "points" && + firstSnapLine.points.length > 1 + ) { + const snapLine = line( + firstSnapLine.points[0], + firstSnapLine.points[1], + ); + const intersection = linesIntersectAt( + angleLine, + snapLine, + ); + + if (intersection) { + dxFromReference = intersection[0] - referencePointCoords[0]; + dyFromReference = intersection[1] - referencePointCoords[1]; + + const furthestPoint = firstSnapLine.points.reduce( + (furthest, point) => { + const distance = pointDistance(intersection, point); + if (distance > furthest.distance) { + return { point, distance }; + } + return furthest; + }, + { + point: firstSnapLine.points[0], + distance: pointDistance( + intersection, + firstSnapLine.points[0], + ), + }, + ); + + firstSnapLine.points = [furthestPoint.point, intersection]; + _snapLines = [firstSnapLine]; + } + } + } else if (snapLines.length > 0) { + const snappedGridX = effectiveGridX + snapOffset.x; + const snappedGridY = effectiveGridY + snapOffset.y; + dxFromReference = snappedGridX - referencePointCoords[0]; + dyFromReference = snappedGridY - referencePointCoords[1]; + } + } + + const [rotatedX, rotatedY] = pointRotateRads( + pointFrom(dxFromReference, dyFromReference), + pointFrom(0, 0), + -element.angle as Radians, + ); + + newDraggingPointPosition = pointFrom( + referencePoint[0] + rotatedX, + referencePoint[1] + rotatedY, ); LinearElementEditor.movePoints( @@ -364,14 +469,11 @@ export class LinearElementEditor { [ selectedIndex, { - point: pointFrom( - width + referencePoint[0], - height + referencePoint[1], - ), + point: newDraggingPointPosition, isDragging: selectedIndex === lastClickedPoint, }, ], - ]), + ]) as PointsPositionUpdates, ); } else { // Apply object snapping for the point being dragged @@ -388,7 +490,7 @@ export class LinearElementEditor { app, event, elementsMap, - { includeSelfPoints: true }, // Include element's own points for snapping when editing + { includeSelfPoints: true }, ); _snapLines = snapLines; @@ -415,15 +517,7 @@ export class LinearElementEditor { selectedPointsIndices.map((pointIndex) => { const newPointPosition: LocalPoint = pointIndex === lastClickedPoint - ? LinearElementEditor.createPointAt( - element, - elementsMap, - snappedPointerX, - snappedPointerY, - event[KEYS.CTRL_OR_CMD] - ? null - : app.getEffectiveGridSize(), - ) + ? newDraggingPointPosition : pointFrom( element.points[pointIndex][0] + deltaX, element.points[pointIndex][1] + deltaY, @@ -1053,20 +1147,122 @@ export class LinearElementEditor { let newPoint: LocalPoint; let snapLines: SnapLine[] = []; - if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) { - const lastCommittedPoint = points[points.length - 2]; + const [gridX, gridY] = getGridPoint( + scenePointerX, + scenePointerY, + event[KEYS.CTRL_OR_CMD] || isElbowArrow(element) + ? null + : app.getEffectiveGridSize(), + ); - const [width, height] = LinearElementEditor._getShiftLockedDelta( + const [lastCommittedX, lastCommittedY] = points[points.length - 2] ?? [ + 0, 0, + ]; + + const lastCommittedPointCoords = + LinearElementEditor.getPointGlobalCoordinates( element, + pointFrom(lastCommittedX, lastCommittedY), elementsMap, - lastCommittedPoint, - pointFrom(scenePointerX, scenePointerY), - event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), + ); + + let dxFromLastCommitted = gridX - lastCommittedPointCoords[0]; + let dyFromLastCommitted = gridY - lastCommittedPointCoords[1]; + + if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) { + ({ width: dxFromLastCommitted, height: dyFromLastCommitted } = + getLockedLinearCursorAlignSize( + lastCommittedPointCoords[0], + lastCommittedPointCoords[1], + gridX, + gridY, + )); + + const effectiveGridX = lastCommittedPointCoords[0] + dxFromLastCommitted; + const effectiveGridY = lastCommittedPointCoords[1] + dyFromLastCommitted; + + if (!isElbowArrow(element)) { + const { snapOffset, snapLines: _snapLines } = snapLinearElementPoint( + app.scene.getNonDeletedElements(), + element, + points.length - 1, + { x: effectiveGridX, y: effectiveGridY }, + app, + event, + elementsMap, + { includeSelfPoints: true }, + ); + + snapLines = _snapLines; + + if (_snapLines.length > 0 && shouldRotateWithDiscreteAngle(event)) { + const angleLine = line( + pointFrom(effectiveGridX, effectiveGridY), + pointFrom(lastCommittedPointCoords[0], lastCommittedPointCoords[1]), + ); + + const firstSnapLine = _snapLines[0]; + if ( + firstSnapLine.type === "points" && + firstSnapLine.points.length > 1 + ) { + const snapLine = line( + firstSnapLine.points[0], + firstSnapLine.points[1], + ); + const intersection = linesIntersectAt( + angleLine, + snapLine, + ); + + if (intersection) { + dxFromLastCommitted = + intersection[0] - lastCommittedPointCoords[0]; + dyFromLastCommitted = + intersection[1] - lastCommittedPointCoords[1]; + + const furthestPoint = firstSnapLine.points.reduce( + (furthest, point) => { + const distance = pointDistance(intersection, point); + if (distance > furthest.distance) { + return { point, distance }; + } + return furthest; + }, + { + point: firstSnapLine.points[0], + distance: pointDistance( + intersection, + firstSnapLine.points[0], + ), + }, + ); + + firstSnapLine.points = [furthestPoint.point, intersection]; + snapLines = [firstSnapLine]; + } + } else { + snapLines = []; + } + } else if (_snapLines.length > 0) { + const snappedGridX = effectiveGridX + snapOffset.x; + const snappedGridY = effectiveGridY + snapOffset.y; + dxFromLastCommitted = snappedGridX - lastCommittedPointCoords[0]; + dyFromLastCommitted = snappedGridY - lastCommittedPointCoords[1]; + } else { + snapLines = []; + } + } + + const [rotatedX, rotatedY] = pointRotateRads( + pointFrom(dxFromLastCommitted, dyFromLastCommitted), + pointFrom(0, 0), + -element.angle as Radians, ); newPoint = pointFrom( - width + lastCommittedPoint[0], - height + lastCommittedPoint[1], + lastCommittedX + rotatedX, + lastCommittedY + rotatedY, ); } else { const originalPointerX = @@ -1107,7 +1303,7 @@ export class LinearElementEditor { app.scene, new Map([ [ - element.points.length - 1, + points.length - 1, { point: newPoint, }, @@ -1117,6 +1313,7 @@ export class LinearElementEditor { } else { LinearElementEditor.addPoints(element, app.scene, [newPoint]); } + return { ...appState.editingLinearElement, lastUncommittedPoint: element.points[element.points.length - 1], diff --git a/packages/element/src/snapping.ts b/packages/element/src/snapping.ts index 337312d61..424c02564 100644 --- a/packages/element/src/snapping.ts +++ b/packages/element/src/snapping.ts @@ -2,6 +2,7 @@ import { isCloseTo, pointFrom, pointRotateRads, + pointsEqual, rangeInclusive, rangeIntersection, rangesOverlap, @@ -196,7 +197,7 @@ export const areRoughlyEqual = (a: number, b: number, precision = 0.01) => { }; export const getLinearElementPoints = ( - element: ExcalidrawElement, + element: ExcalidrawLinearElement, elementsMap: ElementsMap, options: { dragOffset?: Vector2D; @@ -205,13 +206,11 @@ export const getLinearElementPoints = ( ): GlobalPoint[] => { const { dragOffset, excludePointIndex } = options; - // Only process linear elements - if (element.type !== "line" && element.type !== "arrow") { + if (isElbowArrow(element)) { return []; } - const linearElement = element as ExcalidrawLinearElement; - if (!linearElement.points || linearElement.points.length === 0) { + if (!element.points || element.points.length === 0) { return []; } @@ -225,13 +224,13 @@ export const getLinearElementPoints = ( const globalPoints: GlobalPoint[] = []; - for (let i = 0; i < linearElement.points.length; i++) { + for (let i = 0; i < element.points.length; i++) { // Skip the point being edited if specified if (excludePointIndex !== undefined && i === excludePointIndex) { continue; } - const localPoint = linearElement.points[i]; + const localPoint = element.points[i]; const globalX = elementX + localPoint[0]; const globalY = elementY + localPoint[1]; @@ -747,6 +746,21 @@ export const getReferenceSnapPointsForLinearElementPoint = ( const elementPoints = getLinearElementPoints(editingElement, elementsMap, { excludePointIndex: editingPointIndex >= 0 ? editingPointIndex : undefined, }); + const shouldSkipFirstOrLast = + editingElement.points.length > 2 && + pointsEqual( + editingElement.points[0], + editingElement.points[editingElement.points.length - 1], + ); + + if (shouldSkipFirstOrLast) { + if (editingPointIndex === 0) { + elementPoints.pop(); + } + if (editingPointIndex === editingElement.points.length - 1) { + elementPoints.shift(); + } + } allSnapPoints.push(...elementPoints); } diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index dd826cff0..5ce863232 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -16,6 +16,8 @@ import { vectorSubtract, vectorDot, vectorNormalize, + line, + linesIntersectAt, } from "@excalidraw/math"; import { isPointInShape } from "@excalidraw/utils/collision"; import { getSelectionBoxShape } from "@excalidraw/utils/shape"; @@ -306,9 +308,9 @@ import { snapLinearElementPoint, } from "@excalidraw/element"; -import type { ElementUpdate } from "@excalidraw/element"; +import type { LocalPoint, GlobalPoint, Radians } from "@excalidraw/math"; -import type { LocalPoint, Radians } from "@excalidraw/math"; +import type { ElementUpdate } from "@excalidraw/element"; import type { ExcalidrawBindableElement, @@ -5948,6 +5950,7 @@ class App extends React.Component { flushSync(() => { this.setState({ editingLinearElement, + snapLines: editingLinearElement.snapLines, }); }); } @@ -6043,7 +6046,9 @@ class App extends React.Component { let dxFromLastCommitted = gridX - rx - lastCommittedX; let dyFromLastCommitted = gridY - ry - lastCommittedY; - if (shouldRotateWithDiscreteAngle(event)) { + const rotateWithDiscreteAngle = shouldRotateWithDiscreteAngle(event); + + if (rotateWithDiscreteAngle) { ({ width: dxFromLastCommitted, height: dyFromLastCommitted } = getLockedLinearCursorAlignSize( // actual coordinate of the last committed point @@ -6053,27 +6058,94 @@ class App extends React.Component { gridX, gridY, )); - } else if (!isElbowArrow(multiElement)) { + } + + const effectiveGridX = lastCommittedX + dxFromLastCommitted + rx; + const effectiveGridY = lastCommittedY + dyFromLastCommitted + ry; + + if (!isElbowArrow(multiElement)) { const { snapOffset, snapLines } = snapLinearElementPoint( this.scene.getNonDeletedElements(), multiElement, points.length - 1, - { x: gridX, y: gridY }, + { x: effectiveGridX, y: effectiveGridY }, this, event, this.scene.getNonDeletedElementsMap(), { includeSelfPoints: true }, ); - const snappedGridX = gridX + snapOffset.x; - const snappedGridY = gridY + snapOffset.y; + if (snapLines.length > 0) { + if (rotateWithDiscreteAngle) { + // Create line from effective position to last committed point + const angleLine = line( + pointFrom(effectiveGridX, effectiveGridY), + pointFrom(lastCommittedX + rx, lastCommittedY + ry), + ); - dxFromLastCommitted = snappedGridX - rx - lastCommittedX; - dyFromLastCommitted = snappedGridY - ry - lastCommittedY; + // Find intersection with first snap line + const firstSnapLine = snapLines[0]; + if ( + firstSnapLine.type === "points" && + firstSnapLine.points.length > 1 + ) { + const snapLine = line( + firstSnapLine.points[0], + firstSnapLine.points[1], + ); + const intersection = linesIntersectAt( + angleLine, + snapLine, + ); - this.setState({ - snapLines, - }); + if (intersection) { + dxFromLastCommitted = intersection[0] - rx - lastCommittedX; + dyFromLastCommitted = intersection[1] - ry - lastCommittedY; + + // Find the furthest point from the intersection + const furthestPoint = firstSnapLine.points.reduce( + (furthest, point) => { + const distance = pointDistance(intersection, point); + if (distance > furthest.distance) { + return { point, distance }; + } + return furthest; + }, + { + point: firstSnapLine.points[0], + distance: pointDistance( + intersection, + firstSnapLine.points[0], + ), + }, + ); + + firstSnapLine.points = [furthestPoint.point, intersection]; + + this.setState({ + snapLines: [firstSnapLine], + }); + + this.setState({ + snapLines: [firstSnapLine], + }); + } + } + } else { + const snappedGridX = effectiveGridX + snapOffset.x; + const snappedGridY = effectiveGridY + snapOffset.y; + dxFromLastCommitted = snappedGridX - rx - lastCommittedX; + dyFromLastCommitted = snappedGridY - ry - lastCommittedY; + + this.setState({ + snapLines, + }); + } + } else { + this.setState({ + snapLines: [], + }); + } } if (isPathALoop(points, this.state.zoom.value)) { @@ -8804,27 +8876,10 @@ class App extends React.Component { let dx = gridX - newElement.x; let dy = gridY - newElement.y; - // snap a two-point line/arrow as well - if (!isElbowArrow(newElement)) { - const { snapOffset, snapLines } = snapLinearElementPoint( - this.scene.getNonDeletedElements(), - newElement, - points.length - 1, - { x: gridX, y: gridY }, - this, - event, - this.scene.getNonDeletedElementsMap(), - { includeSelfPoints: true }, - ); - const snappedGridX = gridX + snapOffset.x; - const snappedGridY = gridY + snapOffset.y; - dx = snappedGridX - newElement.x; - dy = snappedGridY - newElement.y; + const rotateWithDiscreteAngle = + shouldRotateWithDiscreteAngle(event) && points.length === 2; - this.setState({ snapLines }); - } - - if (shouldRotateWithDiscreteAngle(event) && points.length === 2) { + if (rotateWithDiscreteAngle) { ({ width: dx, height: dy } = getLockedLinearCursorAlignSize( newElement.x, newElement.y, @@ -8833,6 +8888,90 @@ class App extends React.Component { )); } + const effectiveGridX = newElement.x + dx; + const effectiveGridY = newElement.y + dy; + + // Snap a two-point line/arrow as well + if (!isElbowArrow(newElement)) { + const { snapOffset, snapLines } = snapLinearElementPoint( + this.scene.getNonDeletedElements(), + newElement, + points.length - 1, + { x: effectiveGridX, y: effectiveGridY }, + this, + event, + this.scene.getNonDeletedElementsMap(), + { includeSelfPoints: true }, + ); + + if (snapLines.length > 0) { + if (rotateWithDiscreteAngle) { + const angleLine = line( + pointFrom(effectiveGridX, effectiveGridY), + pointFrom(newElement.x, newElement.y), + ); + + const firstSnapLine = snapLines.find( + (snapLine) => + snapLine.type === "points" && snapLine.points.length > 2, + ); + if (firstSnapLine && firstSnapLine.points.length > 1) { + const snapLine = line( + firstSnapLine.points[0], + firstSnapLine.points[1], + ); + const intersection = linesIntersectAt( + angleLine, + snapLine, + ); + if (intersection) { + dx = intersection[0] - newElement.x; + dy = intersection[1] - newElement.y; + + // Find the furthest point from the intersection + const furthestPoint = firstSnapLine.points.reduce( + (furthest, point) => { + const distance = pointDistance(intersection, point); + if (distance > furthest.distance) { + return { point, distance }; + } + return furthest; + }, + { + point: firstSnapLine.points[0], + distance: pointDistance( + intersection, + firstSnapLine.points[0], + ), + }, + ); + + firstSnapLine.points = [furthestPoint.point, intersection]; + + this.setState({ + snapLines: [firstSnapLine], + }); + } else { + this.setState({ + snapLines: [], + }); + } + } + } else { + dx = gridX + snapOffset.x - newElement.x; + dy = gridY + snapOffset.y - newElement.y; + + this.setState({ + snapLines, + }); + } + } else { + this.setState({ + snapLines: [], + }); + } + } + if (points.length === 1) { this.scene.mutateElement( newElement,