diff --git a/src/components/App.tsx b/src/components/App.tsx index 91a6cbe7..0ecbe27e 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -2718,18 +2718,23 @@ class App extends React.Component { event, scenePointerX, scenePointerY, - this.state.editingLinearElement, - this.state.gridSize, + this.state, ); - if (editingLinearElement !== this.state.editingLinearElement) { + + if ( + editingLinearElement && + editingLinearElement !== this.state.editingLinearElement + ) { // Since we are reading from previous state which is not possible with // automatic batching in React 18 hence using flush sync to synchronously // update the state. Check https://github.com/excalidraw/excalidraw/pull/5508 for more details. flushSync(() => { - this.setState({ editingLinearElement }); + this.setState({ + editingLinearElement, + }); }); } - if (editingLinearElement.lastUncommittedPoint != null) { + if (editingLinearElement?.lastUncommittedPoint != null) { this.maybeSuggestBindingAtCursor(scenePointer); } else { this.setState({ suggestedBindings: [] }); @@ -3058,7 +3063,7 @@ class App extends React.Component { } if (this.state.selectedLinearElement) { let hoverPointIndex = -1; - let midPointHovered = false; + let segmentMidPointHoveredCoords = null; if ( isHittingElementNotConsideringBoundingBox(element, this.state, [ scenePointerX, @@ -3071,13 +3076,14 @@ class App extends React.Component { scenePointerX, scenePointerY, ); - midPointHovered = LinearElementEditor.isHittingMidPoint( - linearElementEditor, - { x: scenePointerX, y: scenePointerY }, - this.state, - ); + segmentMidPointHoveredCoords = + LinearElementEditor.getSegmentMidpointHitCoords( + linearElementEditor, + { x: scenePointerX, y: scenePointerY }, + this.state, + ); - if (hoverPointIndex >= 0 || midPointHovered) { + if (hoverPointIndex >= 0 || segmentMidPointHoveredCoords) { setCursor(this.canvas, CURSOR_TYPE.POINTER); } else { setCursor(this.canvas, CURSOR_TYPE.MOVE); @@ -3106,12 +3112,15 @@ class App extends React.Component { } if ( - this.state.selectedLinearElement.midPointHovered !== midPointHovered + !LinearElementEditor.arePointsEqual( + this.state.selectedLinearElement.segmentMidPointHoveredCoords, + segmentMidPointHoveredCoords, + ) ) { this.setState({ selectedLinearElement: { ...this.state.selectedLinearElement, - midPointHovered, + segmentMidPointHoveredCoords, }, }); } diff --git a/src/element/linearElementEditor.ts b/src/element/linearElementEditor.ts index 48972a82..df915748 100644 --- a/src/element/linearElementEditor.ts +++ b/src/element/linearElementEditor.ts @@ -12,6 +12,11 @@ import { getGridPoint, rotatePoint, centerPoint, + getControlPointsForBezierCurve, + getBezierXY, + getBezierCurveLength, + mapIntervalToBezierT, + arePointsEqual, } from "../math"; import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from "."; import { getElementPointsCoords } from "./bounds"; @@ -29,6 +34,12 @@ import { tupleToCoors } from "../utils"; import { isBindingElement } from "./typeChecks"; import { shouldRotateWithDiscreteAngle } from "../keys"; +const editorMidPointsCache: { + version: number | null; + points: (Point | null)[]; + zoom: number | null; +} = { version: null, points: [], zoom: null }; + export class LinearElementEditor { public readonly elementId: ExcalidrawElement["id"] & { _brand: "excalidrawLinearElementId"; @@ -52,7 +63,7 @@ export class LinearElementEditor { | "keep"; public readonly endBindingElement: ExcalidrawBindableElement | null | "keep"; public readonly hoverPointIndex: number; - public readonly midPointHovered: boolean; + public readonly segmentMidPointHoveredCoords: Point | null; constructor(element: NonDeleted, scene: Scene) { this.elementId = element.id as string & { @@ -72,7 +83,7 @@ export class LinearElementEditor { lastClickedPoint: -1, }; this.hoverPointIndex = -1; - this.midPointHovered = false; + this.segmentMidPointHoveredCoords = null; } // --------------------------------------------------------------------------- @@ -80,7 +91,6 @@ export class LinearElementEditor { // --------------------------------------------------------------------------- static POINT_HANDLE_SIZE = 10; - /** * @param id the `elementId` from the instance of this class (so that we can * statically guarantee this method returns an ExcalidrawLinearElement) @@ -359,7 +369,60 @@ export class LinearElementEditor { }; } - static isHittingMidPoint = ( + static getEditorMidPoints = ( + element: NonDeleted, + appState: AppState, + ): typeof editorMidPointsCache["points"] => { + // Since its not needed outside editor unless 2 pointer lines + if (!appState.editingLinearElement && element.points.length > 2) { + return []; + } + if ( + editorMidPointsCache.version === element.version && + editorMidPointsCache.zoom === appState.zoom.value + ) { + return editorMidPointsCache.points; + } + LinearElementEditor.updateEditorMidPointsCache(element, appState); + return editorMidPointsCache.points!; + }; + + static updateEditorMidPointsCache = ( + element: NonDeleted, + appState: AppState, + ) => { + const points = LinearElementEditor.getPointsGlobalCoordinates(element); + + let index = 0; + const midpoints: (Point | null)[] = []; + while (index < points.length - 1) { + if ( + LinearElementEditor.isSegmentTooShort( + element, + element.points[index], + element.points[index + 1], + appState.zoom, + ) + ) { + midpoints.push(null); + index++; + continue; + } + const segmentMidPoint = LinearElementEditor.getSegmentMidPoint( + element, + points[index], + points[index + 1], + index + 1, + ); + midpoints.push(segmentMidPoint); + index++; + } + editorMidPointsCache.points = midpoints; + editorMidPointsCache.version = element.version; + editorMidPointsCache.zoom = appState.zoom.value; + }; + + static getSegmentMidpointHitCoords = ( linearElementEditor: LinearElementEditor, scenePointer: { x: number; y: number }, appState: AppState, @@ -367,7 +430,7 @@ export class LinearElementEditor { const { elementId } = linearElementEditor; const element = LinearElementEditor.getElement(elementId); if (!element) { - return false; + return null; } const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor( element, @@ -376,37 +439,125 @@ export class LinearElementEditor { scenePointer.y, ); if (clickedPointIndex >= 0) { - return false; - } - const points = LinearElementEditor.getPointsGlobalCoordinates(element); - if (points.length >= 3) { - return false; - } - - const midPoint = LinearElementEditor.getMidPoint(linearElementEditor); - if (midPoint) { - const threshold = - LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value; - const distance = distance2d( - midPoint[0], - midPoint[1], - scenePointer.x, - scenePointer.y, - ); - return distance <= threshold; - } - return false; - }; - - static getMidPoint(linearElementEditor: LinearElementEditor) { - const { elementId } = linearElementEditor; - const element = LinearElementEditor.getElement(elementId); - if (!element) { return null; } const points = LinearElementEditor.getPointsGlobalCoordinates(element); + if (points.length >= 3 && !appState.editingLinearElement) { + return null; + } - return centerPoint(points[0], points.at(-1)!); + const threshold = + LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value; + + const existingSegmentMidpointHitCoords = + linearElementEditor.segmentMidPointHoveredCoords; + if (existingSegmentMidpointHitCoords) { + const distance = distance2d( + existingSegmentMidpointHitCoords[0], + existingSegmentMidpointHitCoords[1], + scenePointer.x, + scenePointer.y, + ); + if (distance <= threshold) { + return existingSegmentMidpointHitCoords; + } + } + let index = 0; + const midPoints: typeof editorMidPointsCache["points"] = + LinearElementEditor.getEditorMidPoints(element, appState); + while (index < midPoints.length) { + if (midPoints[index] !== null) { + const distance = distance2d( + midPoints[index]![0], + midPoints[index]![1], + scenePointer.x, + scenePointer.y, + ); + if (distance <= threshold) { + return midPoints[index]; + } + } + + index++; + } + return null; + }; + + static isSegmentTooShort( + element: NonDeleted, + startPoint: Point, + endPoint: Point, + zoom: AppState["zoom"], + ) { + let distance = distance2d( + startPoint[0], + startPoint[1], + endPoint[0], + endPoint[1], + ); + if (element.points.length > 2 && element.strokeSharpness === "round") { + distance = getBezierCurveLength(element, endPoint); + } + + return distance * zoom.value < LinearElementEditor.POINT_HANDLE_SIZE * 4; + } + + static getSegmentMidPoint( + element: NonDeleted, + startPoint: Point, + endPoint: Point, + endPointIndex: number, + ) { + let segmentMidPoint = centerPoint(startPoint, endPoint); + if (element.points.length > 2 && element.strokeSharpness === "round") { + const controlPoints = getControlPointsForBezierCurve( + element, + element.points[endPointIndex], + ); + if (controlPoints) { + const t = mapIntervalToBezierT( + element, + element.points[endPointIndex], + 0.5, + ); + + const [tx, ty] = getBezierXY( + controlPoints[0], + controlPoints[1], + controlPoints[2], + controlPoints[3], + t, + ); + segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates( + element, + [tx, ty], + ); + } + } + + return segmentMidPoint; + } + + static getSegmentMidPointIndex( + linearElementEditor: LinearElementEditor, + appState: AppState, + midPoint: Point, + ) { + const element = LinearElementEditor.getElement( + linearElementEditor.elementId, + ); + if (!element) { + return -1; + } + const midPoints = LinearElementEditor.getEditorMidPoints(element, appState); + let index = 0; + while (index < midPoints.length - 1) { + if (LinearElementEditor.arePointsEqual(midPoint, midPoints[index])) { + return index + 1; + } + index++; + } + return -1; } static handlePointerDown( @@ -438,33 +589,32 @@ export class LinearElementEditor { if (!element) { return ret; } - const hittingMidPoint = LinearElementEditor.isHittingMidPoint( + const segmentMidPoint = LinearElementEditor.getSegmentMidpointHitCoords( linearElementEditor, scenePointer, appState, ); - if ( - LinearElementEditor.isHittingMidPoint( + if (segmentMidPoint) { + const index = LinearElementEditor.getSegmentMidPointIndex( linearElementEditor, - scenePointer, appState, - ) - ) { - const midPoint = LinearElementEditor.getMidPoint(linearElementEditor); - if (midPoint) { - mutateElement(element, { - points: [ - element.points[0], - LinearElementEditor.createPointAt( - element, - midPoint[0], - midPoint[1], - appState.gridSize, - ), - ...element.points.slice(1), - ], - }); - } + segmentMidPoint, + ); + const newMidPoint = LinearElementEditor.createPointAt( + element, + segmentMidPoint[0], + segmentMidPoint[1], + appState.gridSize, + ); + const points = [ + ...element.points.slice(0, index), + newMidPoint, + ...element.points.slice(index), + ]; + mutateElement(element, { + points, + }); + ret.didAddPoint = true; ret.isMidPoint = true; ret.linearElementEditor = { @@ -520,7 +670,7 @@ export class LinearElementEditor { // if we clicked on a point, set the element as hitElement otherwise // it would get deselected if the point is outside the hitbox area - if (clickedPointIndex >= 0 || hittingMidPoint) { + if (clickedPointIndex >= 0 || segmentMidPoint) { ret.hitElement = element; } else { // You might be wandering why we are storing the binding elements on @@ -579,17 +729,29 @@ export class LinearElementEditor { return ret; } + static arePointsEqual(point1: Point | null, point2: Point | null) { + if (!point1 && !point2) { + return true; + } + if (!point1 || !point2) { + return false; + } + return arePointsEqual(point1, point2); + } + static handlePointerMove( event: React.PointerEvent, scenePointerX: number, scenePointerY: number, - linearElementEditor: LinearElementEditor, - gridSize: number | null, - ): LinearElementEditor { - const { elementId, lastUncommittedPoint } = linearElementEditor; + appState: AppState, + ): LinearElementEditor | null { + if (!appState.editingLinearElement) { + return null; + } + const { elementId, lastUncommittedPoint } = appState.editingLinearElement; const element = LinearElementEditor.getElement(elementId); if (!element) { - return linearElementEditor; + return appState.editingLinearElement; } const { points } = element; @@ -599,7 +761,10 @@ export class LinearElementEditor { if (lastPoint === lastUncommittedPoint) { LinearElementEditor.deletePoints(element, [points.length - 1]); } - return { ...linearElementEditor, lastUncommittedPoint: null }; + return { + ...appState.editingLinearElement, + lastUncommittedPoint: null, + }; } let newPoint: Point; @@ -611,7 +776,7 @@ export class LinearElementEditor { element, lastCommittedPoint, [scenePointerX, scenePointerY], - gridSize, + appState.gridSize, ); newPoint = [ @@ -621,9 +786,9 @@ export class LinearElementEditor { } else { newPoint = LinearElementEditor.createPointAt( element, - scenePointerX - linearElementEditor.pointerOffset.x, - scenePointerY - linearElementEditor.pointerOffset.y, - gridSize, + scenePointerX - appState.editingLinearElement.pointerOffset.x, + scenePointerY - appState.editingLinearElement.pointerOffset.y, + appState.gridSize, ); } @@ -635,11 +800,10 @@ export class LinearElementEditor { }, ]); } else { - LinearElementEditor.addPoints(element, [{ point: newPoint }]); + LinearElementEditor.addPoints(element, appState, [{ point: newPoint }]); } - return { - ...linearElementEditor, + ...appState.editingLinearElement, lastUncommittedPoint: element.points[element.points.length - 1], }; } @@ -884,6 +1048,7 @@ export class LinearElementEditor { static addPoints( element: NonDeleted, + appState: AppState, targetPoints: { point: Point }[], ) { const offsetX = 0; diff --git a/src/math.ts b/src/math.ts index 1f2ffc4f..dd1a73b5 100644 --- a/src/math.ts +++ b/src/math.ts @@ -1,6 +1,8 @@ import { NormalizedZoomValue, Point, Zoom } from "./types"; import { LINE_CONFIRM_THRESHOLD } from "./constants"; -import { ExcalidrawLinearElement } from "./element/types"; +import { ExcalidrawLinearElement, NonDeleted } from "./element/types"; +import { getShapeForElement } from "./renderer/renderElement"; +import { getCurvePathOps } from "./element/bounds"; export const rotate = ( x1: number, @@ -263,3 +265,165 @@ export const getGridPoint = ( } return [x, y]; }; + +export const getControlPointsForBezierCurve = ( + element: NonDeleted, + endPoint: Point, +) => { + const shape = getShapeForElement(element as ExcalidrawLinearElement); + if (!shape) { + return null; + } + + const ops = getCurvePathOps(shape[0]); + let currentP: Mutable = [0, 0]; + let index = 0; + let minDistance = Infinity; + let controlPoints: Mutable[] | null = null; + + while (index < ops.length) { + const { op, data } = ops[index]; + if (op === "move") { + currentP = data as unknown as Mutable; + } + if (op === "bcurveTo") { + const p0 = currentP; + const p1 = [data[0], data[1]] as Mutable; + const p2 = [data[2], data[3]] as Mutable; + const p3 = [data[4], data[5]] as Mutable; + const distance = distance2d(p3[0], p3[1], endPoint[0], endPoint[1]); + if (distance < minDistance) { + minDistance = distance; + controlPoints = [p0, p1, p2, p3]; + } + currentP = p3; + } + index++; + } + + return controlPoints; +}; + +export const getBezierXY = ( + p0: Point, + p1: Point, + p2: Point, + p3: Point, + t: number, +) => { + const equation = (t: number, idx: number) => + Math.pow(1 - t, 3) * p3[idx] + + 3 * t * Math.pow(1 - t, 2) * p2[idx] + + 3 * Math.pow(t, 2) * (1 - t) * p1[idx] + + p0[idx] * Math.pow(t, 3); + const tx = equation(t, 0); + const ty = equation(t, 1); + return [tx, ty]; +}; + +export const getPointsInBezierCurve = ( + element: NonDeleted, + endPoint: Point, +) => { + const controlPoints: Mutable[] = getControlPointsForBezierCurve( + element, + endPoint, + )!; + if (!controlPoints) { + return []; + } + const pointsOnCurve: Mutable[] = []; + let t = 1; + // Take 20 points on curve for better accuracy + while (t > 0) { + const point = getBezierXY( + controlPoints[0], + controlPoints[1], + controlPoints[2], + controlPoints[3], + t, + ); + pointsOnCurve.push([point[0], point[1]]); + t -= 0.05; + } + if (pointsOnCurve.length) { + if (arePointsEqual(pointsOnCurve.at(-1)!, endPoint)) { + pointsOnCurve.push([endPoint[0], endPoint[1]]); + } + } + return pointsOnCurve; +}; + +export const getBezierCurveArcLengths = ( + element: NonDeleted, + endPoint: Point, +) => { + const arcLengths: number[] = []; + arcLengths[0] = 0; + const points = getPointsInBezierCurve(element, endPoint); + let index = 0; + let distance = 0; + while (index < points.length - 1) { + const segmentDistance = distance2d( + points[index][0], + points[index][1], + points[index + 1][0], + points[index + 1][1], + ); + distance += segmentDistance; + arcLengths.push(distance); + index++; + } + + return arcLengths; +}; + +export const getBezierCurveLength = ( + element: NonDeleted, + endPoint: Point, +) => { + const arcLengths = getBezierCurveArcLengths(element, endPoint); + return arcLengths.at(-1) as number; +}; + +// This maps interval to actual interval t on the curve so that when t = 0.5, its actually the point at 50% of the length +export const mapIntervalToBezierT = ( + element: NonDeleted, + endPoint: Point, + interval: number, // The interval between 0 to 1 for which you want to find the point on the curve, +) => { + const arcLengths = getBezierCurveArcLengths(element, endPoint); + const pointsCount = arcLengths.length - 1; + const curveLength = arcLengths.at(-1) as number; + const targetLength = interval * curveLength; + let low = 0; + let high = pointsCount; + let index = 0; + // Doing a binary search to find the largest length that is less than the target length + while (low < high) { + index = Math.floor(low + (high - low) / 2); + if (arcLengths[index] < targetLength) { + low = index + 1; + } else { + high = index; + } + } + if (arcLengths[index] > targetLength) { + index--; + } + if (arcLengths[index] === targetLength) { + return index / pointsCount; + } + + return ( + 1 - + (index + + (targetLength - arcLengths[index]) / + (arcLengths[index + 1] - arcLengths[index])) / + pointsCount + ); +}; + +export const arePointsEqual = (p1: Point, p2: Point) => { + return p1[0] === p2[0] && p1[1] === p2[1]; +}; diff --git a/src/renderer/renderScene.ts b/src/renderer/renderScene.ts index 8ac1e1ab..d4c6e19d 100644 --- a/src/renderer/renderScene.ts +++ b/src/renderer/renderScene.ts @@ -197,12 +197,7 @@ const renderLinearPointHandles = ( context.translate(renderConfig.scrollX, renderConfig.scrollY); context.lineWidth = 1 / renderConfig.zoom.value; const points = LinearElementEditor.getPointsGlobalCoordinates(element); - const centerPoint = LinearElementEditor.getMidPoint( - appState.selectedLinearElement, - ); - if (!centerPoint) { - return; - } + const { POINT_HANDLE_SIZE } = LinearElementEditor; const radius = appState.editingLinearElement ? POINT_HANDLE_SIZE @@ -221,11 +216,20 @@ const renderLinearPointHandles = ( ); }); - if (points.length < 3) { - if (appState.selectedLinearElement.midPointHovered) { - const centerPoint = LinearElementEditor.getMidPoint( - appState.selectedLinearElement, - )!; + //Rendering segment mid points + const midPoints = LinearElementEditor.getEditorMidPoints( + element, + appState, + ).filter((midPoint) => midPoint !== null) as Point[]; + + midPoints.forEach((segmentMidPoint) => { + if ( + appState?.selectedLinearElement?.segmentMidPointHoveredCoords && + LinearElementEditor.arePointsEqual( + segmentMidPoint, + appState.selectedLinearElement.segmentMidPointHoveredCoords, + ) + ) { // The order of renderingSingleLinearPoint and highLight points is different // inside vs outside editor as hover states are different, // in editor when hovered the original point is not visible as hover state fully covers it whereas outside the @@ -235,34 +239,34 @@ const renderLinearPointHandles = ( context, appState, renderConfig, - centerPoint, + segmentMidPoint, radius, false, ); - highlightPoint(centerPoint, context, renderConfig); + highlightPoint(segmentMidPoint, context, renderConfig); } else { - highlightPoint(centerPoint, context, renderConfig); + highlightPoint(segmentMidPoint, context, renderConfig); renderSingleLinearPoint( context, appState, renderConfig, - centerPoint, + segmentMidPoint, radius, false, ); } - } else { + } else if (appState.editingLinearElement || points.length === 2) { renderSingleLinearPoint( context, appState, renderConfig, - centerPoint, + segmentMidPoint, POINT_HANDLE_SIZE / 2, false, true, ); } - } + }); context.restore(); }; @@ -403,6 +407,20 @@ export const _renderScene = ({ visibleElements.forEach((element) => { try { renderElement(element, rc, context, renderConfig); + // Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to + // ShapeCache returns empty hence making sure that we get the + // correct element from visible elements + if (appState.editingLinearElement?.elementId === element.id) { + if (element) { + renderLinearPointHandles( + context, + appState, + renderConfig, + element as NonDeleted, + ); + } + } + if (!isExporting) { renderLinkIcon(element, context, appState); } @@ -411,15 +429,6 @@ export const _renderScene = ({ } }); - if (appState.editingLinearElement) { - const element = LinearElementEditor.getElement( - appState.editingLinearElement.elementId, - ); - if (element) { - renderLinearPointHandles(context, appState, renderConfig, element); - } - } - // Paint selection element if (appState.selectionElement) { try { diff --git a/src/tests/__snapshots__/linearElementEditor.test.tsx.snap b/src/tests/__snapshots__/linearElementEditor.test.tsx.snap new file mode 100644 index 00000000..945d8197 --- /dev/null +++ b/src/tests/__snapshots__/linearElementEditor.test.tsx.snap @@ -0,0 +1,60 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` Test Linear Elements Inside editor should allow dragging line from midpoint in 2 pointer lines 1`] = ` +Array [ + Array [ + 0, + 0, + ], + Array [ + 70, + 50, + ], + Array [ + 40, + 0, + ], +] +`; + +exports[` Test Linear Elements Inside editor should allow dragging lines from midpoints in between segments 1`] = ` +Array [ + Array [ + 0, + 0, + ], + Array [ + 85, + 75, + ], + Array [ + 70, + 50, + ], + Array [ + 105, + 75, + ], + Array [ + 40, + 0, + ], +] +`; + +exports[` Test Linear Elements should allow dragging line from midpoint in 2 pointer lines outside editor 1`] = ` +Array [ + Array [ + 0, + 0, + ], + Array [ + 70, + 50, + ], + Array [ + 40, + 0, + ], +] +`; diff --git a/src/tests/__snapshots__/regressionTests.test.tsx.snap b/src/tests/__snapshots__/regressionTests.test.tsx.snap index 3f023df4..1a30d90a 100644 --- a/src/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/src/tests/__snapshots__/regressionTests.test.tsx.snap @@ -10982,7 +10982,6 @@ Object { "hoverPointIndex": -1, "isDragging": false, "lastUncommittedPoint": null, - "midPointHovered": false, "pointerDownState": Object { "lastClickedPoint": -1, "prevSelectedPointsIndices": null, @@ -10991,6 +10990,7 @@ Object { "x": 0, "y": 0, }, + "segmentMidPointHoveredCoords": null, "selectedPointsIndices": null, "startBindingElement": "keep", }, @@ -11208,7 +11208,6 @@ Object { "hoverPointIndex": -1, "isDragging": false, "lastUncommittedPoint": null, - "midPointHovered": false, "pointerDownState": Object { "lastClickedPoint": -1, "prevSelectedPointsIndices": null, @@ -11217,6 +11216,7 @@ Object { "x": 0, "y": 0, }, + "segmentMidPointHoveredCoords": null, "selectedPointsIndices": null, "startBindingElement": "keep", }, @@ -11661,7 +11661,6 @@ Object { "hoverPointIndex": -1, "isDragging": false, "lastUncommittedPoint": null, - "midPointHovered": false, "pointerDownState": Object { "lastClickedPoint": -1, "prevSelectedPointsIndices": null, @@ -11670,6 +11669,7 @@ Object { "x": 0, "y": 0, }, + "segmentMidPointHoveredCoords": null, "selectedPointsIndices": null, "startBindingElement": "keep", }, @@ -12066,7 +12066,6 @@ Object { "hoverPointIndex": -1, "isDragging": false, "lastUncommittedPoint": null, - "midPointHovered": false, "pointerDownState": Object { "lastClickedPoint": -1, "prevSelectedPointsIndices": null, @@ -12075,6 +12074,7 @@ Object { "x": 0, "y": 0, }, + "segmentMidPointHoveredCoords": null, "selectedPointsIndices": null, "startBindingElement": "keep", }, diff --git a/src/tests/linearElementEditor.test.tsx b/src/tests/linearElementEditor.test.tsx new file mode 100644 index 00000000..c697fa2c --- /dev/null +++ b/src/tests/linearElementEditor.test.tsx @@ -0,0 +1,146 @@ +import ReactDOM from "react-dom"; +import { ExcalidrawLinearElement } from "../element/types"; +import ExcalidrawApp from "../excalidraw-app"; +import { centerPoint } from "../math"; +import { reseed } from "../random"; +import * as Renderer from "../renderer/renderScene"; +import { Keyboard } from "./helpers/ui"; +import { screen } from "./test-utils"; + +import { render, fireEvent } from "./test-utils"; +import { Point } from "../types"; +import { KEYS } from "../keys"; +import { LinearElementEditor } from "../element/linearElementEditor"; + +const renderScene = jest.spyOn(Renderer, "renderScene"); + +const { h } = window; + +describe(" Test Linear Elements", () => { + let getByToolName: (...args: string[]) => HTMLElement; + let container: HTMLElement; + let canvas: HTMLCanvasElement; + + beforeEach(async () => { + // Unmount ReactDOM from root + ReactDOM.unmountComponentAtNode(document.getElementById("root")!); + localStorage.clear(); + renderScene.mockClear(); + reseed(7); + const comp = await render(); + getByToolName = comp.getByToolName; + container = comp.container; + canvas = container.querySelector("canvas")!; + }); + + const p1: Point = [20, 20]; + const p2: Point = [60, 20]; + const midpoint = centerPoint(p1, p2); + + const createTwoPointerLinearElement = ( + type: ExcalidrawLinearElement["type"], + edge: "Sharp" | "Round" = "Sharp", + roughness: "Architect" | "Cartoonist" | "Artist" = "Architect", + ) => { + const tool = getByToolName(type); + fireEvent.click(tool); + fireEvent.click(screen.getByTitle(edge)); + fireEvent.click(screen.getByTitle(roughness)); + fireEvent.pointerDown(canvas, { clientX: p1[0], clientY: p1[1] }); + fireEvent.pointerMove(canvas, { clientX: p2[0], clientY: p2[1] }); + fireEvent.pointerUp(canvas, { clientX: p2[0], clientY: p2[1] }); + }; + + const createThreePointerLinearElement = ( + type: ExcalidrawLinearElement["type"], + edge: "Sharp" | "Round" = "Sharp", + ) => { + createTwoPointerLinearElement("line"); + // Extending line via midpoint + fireEvent.pointerDown(canvas, { + clientX: midpoint[0], + clientY: midpoint[1], + }); + fireEvent.pointerMove(canvas, { + clientX: midpoint[0] + 50, + clientY: midpoint[1] + 50, + }); + fireEvent.pointerUp(canvas, { + clientX: midpoint[0] + 50, + clientY: midpoint[1] + 50, + }); + }; + + const dragLinearElementFromPoint = (point: Point) => { + fireEvent.pointerDown(canvas, { + clientX: point[0], + clientY: point[1], + }); + fireEvent.pointerMove(canvas, { + clientX: point[0] + 50, + clientY: point[1] + 50, + }); + fireEvent.pointerUp(canvas, { + clientX: point[0] + 50, + clientY: point[1] + 50, + }); + }; + + it("should allow dragging line from midpoint in 2 pointer lines outside editor", async () => { + createTwoPointerLinearElement("line"); + const line = h.elements[0] as ExcalidrawLinearElement; + + expect(renderScene).toHaveBeenCalledTimes(10); + expect((h.elements[0] as ExcalidrawLinearElement).points.length).toEqual(2); + + // drag line from midpoint + dragLinearElementFromPoint(midpoint); + expect(renderScene).toHaveBeenCalledTimes(13); + expect(line.points.length).toEqual(3); + expect(line.points).toMatchSnapshot(); + }); + + describe("Inside editor", () => { + it("should allow dragging line from midpoint in 2 pointer lines", async () => { + createTwoPointerLinearElement("line"); + const line = h.elements[0] as ExcalidrawLinearElement; + + fireEvent.click(canvas, { clientX: p1[0], clientY: p1[1] }); + + Keyboard.keyPress(KEYS.ENTER); + expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); + + // drag line from midpoint + dragLinearElementFromPoint(midpoint); + expect(line.points.length).toEqual(3); + expect(line.points).toMatchSnapshot(); + }); + + it("should allow dragging lines from midpoints in between segments", async () => { + createThreePointerLinearElement("line"); + + const line = h.elements[0] as ExcalidrawLinearElement; + expect(line.points.length).toEqual(3); + fireEvent.click(canvas, { clientX: p1[0], clientY: p1[1] }); + + Keyboard.keyPress(KEYS.ENTER); + expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); + + let points = LinearElementEditor.getPointsGlobalCoordinates(line); + const firstSegmentMidpoint = centerPoint(points[0], points[1]); + // drag line via first segment midpoint + dragLinearElementFromPoint(firstSegmentMidpoint); + expect(line.points.length).toEqual(4); + + // drag line from last segment midpoint + points = LinearElementEditor.getPointsGlobalCoordinates(line); + const lastSegmentMidpoint = centerPoint(points.at(-2)!, points.at(-1)!); + dragLinearElementFromPoint(lastSegmentMidpoint); + expect(line.points.length).toEqual(5); + + expect( + (h.elements[0] as ExcalidrawLinearElement).points, + ).toMatchSnapshot(); + }); + }); +});