浏览代码

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
Aakansha Doshi 2 年之前
父节点
当前提交
0d1058a596

+ 23 - 14
src/components/App.tsx

@@ -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,
           },
         });
       }

+ 223 - 58
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<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;
+      return null;
     }
     const points = LinearElementEditor.getPointsGlobalCoordinates(element);
-    if (points.length >= 3) {
-      return false;
+    if (points.length >= 3 && !appState.editingLinearElement) {
+      return null;
     }
 
-    const midPoint = LinearElementEditor.getMidPoint(linearElementEditor);
-    if (midPoint) {
-      const threshold =
-        LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value;
+    const threshold =
+      LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value;
+
+    const existingSegmentMidpointHitCoords =
+      linearElementEditor.segmentMidPointHoveredCoords;
+    if (existingSegmentMidpointHitCoords) {
       const distance = distance2d(
-        midPoint[0],
-        midPoint[1],
+        existingSegmentMidpointHitCoords[0],
+        existingSegmentMidpointHitCoords[1],
         scenePointer.x,
         scenePointer.y,
       );
-      return distance <= threshold;
+      if (distance <= threshold) {
+        return existingSegmentMidpointHitCoords;
+      }
     }
-    return false;
+    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 getMidPoint(linearElementEditor: LinearElementEditor) {
-    const { elementId } = linearElementEditor;
-    const element = LinearElementEditor.getElement(elementId);
-    if (!element) {
-      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);
     }
-    const points = LinearElementEditor.getPointsGlobalCoordinates(element);
 
-    return centerPoint(points[0], points.at(-1)!);
+    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;

+ 165 - 1
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];
+};

+ 36 - 27
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<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 - 0
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,
+  ],
+]
+`;

+ 4 - 4
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",
   },

+ 146 - 0
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(<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();
+    });
+  });
+});