mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-10-30 18:34:22 +01:00 
			
		
		
		
	feat: support segment midpoints in line editor (#5641)
* feat: support segment midpoints in line editor * fix tests * midpoints working in bezier curve * midpoint working with non zero roughness * calculate beizer curve control points for points >2 * unnecessary rerender * don't show phantom points inside editor for short segments * don't show phantom points for small curves * improve the algo for plotting midpoints on bezier curve by taking arc lengths and doing binary search * fix tests finally * fix naming * cache editor midpoints * clear midpoint cache when undo * fix caching * calculate index properly when not all segments have midpoints * make sure correct element version is fetched from cache * chore * fix * direct comparison for equal points * create arePointsEqual util * upate name * don't update cache except inside getter * don't compute midpoints outside editor unless 2pointer lines * update cache to object and burst when Zoom updated as well * early return if midpoints not present outside editor * don't early return * cleanup * Add specs * fix
This commit is contained in:
		| @@ -2718,18 +2718,23 @@ class App extends React.Component<AppProps, AppState> { | ||||
|         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<AppProps, AppState> { | ||||
|     } | ||||
|     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<AppProps, AppState> { | ||||
|           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<AppProps, AppState> { | ||||
|       } | ||||
|  | ||||
|       if ( | ||||
|         this.state.selectedLinearElement.midPointHovered !== midPointHovered | ||||
|         !LinearElementEditor.arePointsEqual( | ||||
|           this.state.selectedLinearElement.segmentMidPointHoveredCoords, | ||||
|           segmentMidPointHoveredCoords, | ||||
|         ) | ||||
|       ) { | ||||
|         this.setState({ | ||||
|           selectedLinearElement: { | ||||
|             ...this.state.selectedLinearElement, | ||||
|             midPointHovered, | ||||
|             segmentMidPointHoveredCoords, | ||||
|           }, | ||||
|         }); | ||||
|       } | ||||
|   | ||||
| @@ -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<ExcalidrawLinearElement>, 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<ExcalidrawLinearElement>, | ||||
|     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<ExcalidrawLinearElement>, | ||||
|     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<ExcalidrawLinearElement>, | ||||
|     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<ExcalidrawLinearElement>, | ||||
|     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<HTMLCanvasElement>, | ||||
|     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<ExcalidrawLinearElement>, | ||||
|     appState: AppState, | ||||
|     targetPoints: { point: Point }[], | ||||
|   ) { | ||||
|     const offsetX = 0; | ||||
|   | ||||
							
								
								
									
										166
									
								
								src/math.ts
									
									
									
									
									
								
							
							
						
						
									
										166
									
								
								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<ExcalidrawLinearElement>, | ||||
|   endPoint: Point, | ||||
| ) => { | ||||
|   const shape = getShapeForElement(element as ExcalidrawLinearElement); | ||||
|   if (!shape) { | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   const ops = getCurvePathOps(shape[0]); | ||||
|   let currentP: Mutable<Point> = [0, 0]; | ||||
|   let index = 0; | ||||
|   let minDistance = Infinity; | ||||
|   let controlPoints: Mutable<Point>[] | null = null; | ||||
|  | ||||
|   while (index < ops.length) { | ||||
|     const { op, data } = ops[index]; | ||||
|     if (op === "move") { | ||||
|       currentP = data as unknown as Mutable<Point>; | ||||
|     } | ||||
|     if (op === "bcurveTo") { | ||||
|       const p0 = currentP; | ||||
|       const p1 = [data[0], data[1]] as Mutable<Point>; | ||||
|       const p2 = [data[2], data[3]] as Mutable<Point>; | ||||
|       const p3 = [data[4], data[5]] as Mutable<Point>; | ||||
|       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<ExcalidrawLinearElement>, | ||||
|   endPoint: Point, | ||||
| ) => { | ||||
|   const controlPoints: Mutable<Point>[] = getControlPointsForBezierCurve( | ||||
|     element, | ||||
|     endPoint, | ||||
|   )!; | ||||
|   if (!controlPoints) { | ||||
|     return []; | ||||
|   } | ||||
|   const pointsOnCurve: Mutable<Point>[] = []; | ||||
|   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<ExcalidrawLinearElement>, | ||||
|   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<ExcalidrawLinearElement>, | ||||
|   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<ExcalidrawLinearElement>, | ||||
|   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]; | ||||
| }; | ||||
|   | ||||
| @@ -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<ExcalidrawLinearElement>, | ||||
|             ); | ||||
|           } | ||||
|         } | ||||
|  | ||||
|         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 { | ||||
|   | ||||
							
								
								
									
										60
									
								
								src/tests/__snapshots__/linearElementEditor.test.tsx.snap
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								src/tests/__snapshots__/linearElementEditor.test.tsx.snap
									
									
									
									
									
										Normal file
									
								
							| @@ -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, | ||||
|   ], | ||||
| ] | ||||
| `; | ||||
| @@ -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", | ||||
|   }, | ||||
|   | ||||
							
								
								
									
										146
									
								
								src/tests/linearElementEditor.test.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										146
									
								
								src/tests/linearElementEditor.test.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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(<ExcalidrawApp />); | ||||
|     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(); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
		Reference in New Issue
	
	Block a user
	 Aakansha Doshi
					Aakansha Doshi