Quellcode durchsuchen

Implement line editing (#1616)

* implement line editing

* line editing with rotation

* ensure adding new points is disabled on point dragging

* fix hotkey replacement

* don't paint bounding box when creating new multipoint

* tweak points style, account for zoom and z-index

* don't persist editingLinearElement to localStorage

* don't mutate on noop points updates

* account for rotation when adding new point

* ensure clicking on points doesn't deselect element

* tweak history handling around editingline element

* update snapshots

* refactor pointerMove handling

* factor out point dragging

* factor out pointerDown

* improve positioning with rotation

* revert to use roughjs for calculating points bounds

* migrate from storing editingLinearElement.element to id

* make GlobalScene.getElement into O(1)

* use Alt for adding new points

* fix adding and deleting a point with rotation

* disable resize handlers & bounding box on line edit

Co-authored-by: daishi <daishi@axlight.com>
David Luzar vor 5 Jahren
Ursprung
Commit
14a66956d7

+ 67 - 12
src/actions/actionDeleteSelected.tsx

@@ -10,6 +10,7 @@ import { ExcalidrawElement } from "../element/types";
 import { AppState } from "../types";
 import { newElementWith } from "../element/mutateElement";
 import { getElementsInGroup } from "../groups";
+import { LinearElementEditor } from "../element/linearElementEditor";
 
 const deleteSelectedElements = (
   elements: readonly ExcalidrawElement[],
@@ -29,26 +30,80 @@ const deleteSelectedElements = (
   };
 };
 
+function handleGroupEditingState(
+  appState: AppState,
+  elements: readonly ExcalidrawElement[],
+): AppState {
+  if (appState.editingGroupId) {
+    const siblingElements = getElementsInGroup(
+      getNonDeletedElements(elements),
+      appState.editingGroupId!,
+    );
+    if (siblingElements.length) {
+      return {
+        ...appState,
+        selectedElementIds: { [siblingElements[0].id]: true },
+      };
+    }
+  }
+  return appState;
+}
+
 export const actionDeleteSelected = register({
   name: "deleteSelectedElements",
   perform: (elements, appState) => {
+    if (
+      appState.editingLinearElement?.activePointIndex != null &&
+      appState.editingLinearElement?.activePointIndex > -1
+    ) {
+      const { elementId } = appState.editingLinearElement;
+      const element = LinearElementEditor.getElement(elementId);
+      if (element) {
+        // case: deleting last point
+        if (element.points.length < 2) {
+          const nextElements = elements.filter((el) => el.id !== element.id);
+          const nextAppState = handleGroupEditingState(appState, nextElements);
+
+          return {
+            elements: nextElements,
+            appState: {
+              ...nextAppState,
+              editingLinearElement: null,
+            },
+            commitToHistory: false,
+          };
+        }
+
+        LinearElementEditor.movePoint(
+          element,
+          appState.editingLinearElement.activePointIndex,
+          "delete",
+        );
+
+        return {
+          elements: elements,
+          appState: {
+            ...appState,
+            editingLinearElement: {
+              ...appState.editingLinearElement,
+              activePointIndex:
+                appState.editingLinearElement.activePointIndex > 0
+                  ? appState.editingLinearElement.activePointIndex - 1
+                  : 0,
+            },
+          },
+          commitToHistory: true,
+        };
+      }
+    }
+
     let {
       elements: nextElements,
       appState: nextAppState,
     } = deleteSelectedElements(elements, appState);
 
-    if (appState.editingGroupId) {
-      const siblingElements = getElementsInGroup(
-        getNonDeletedElements(nextElements),
-        appState.editingGroupId!,
-      );
-      if (siblingElements.length) {
-        nextAppState = {
-          ...nextAppState,
-          selectedElementIds: { [siblingElements[0].id]: true },
-        };
-      }
-    }
+    nextAppState = handleGroupEditingState(nextAppState, nextElements);
+
     return {
       elements: nextElements,
       appState: {

+ 22 - 2
src/actions/actionFinalize.tsx

@@ -8,10 +8,30 @@ import { t } from "../i18n";
 import { register } from "./register";
 import { mutateElement } from "../element/mutateElement";
 import { isPathALoop } from "../math";
+import { LinearElementEditor } from "../element/linearElementEditor";
 
 export const actionFinalize = register({
   name: "finalize",
   perform: (elements, appState) => {
+    if (appState.editingLinearElement) {
+      const { elementId } = appState.editingLinearElement;
+      const element = LinearElementEditor.getElement(elementId);
+
+      if (element) {
+        return {
+          elements:
+            element.points.length < 2 || isInvisiblySmallElement(element)
+              ? elements.filter((el) => el.id !== element.id)
+              : undefined,
+          appState: {
+            ...appState,
+            editingLinearElement: null,
+          },
+          commitToHistory: true,
+        };
+      }
+    }
+
     let newElements = elements;
     if (window.document.activeElement instanceof HTMLElement) {
       window.document.activeElement.blur();
@@ -94,8 +114,8 @@ export const actionFinalize = register({
   },
   keyTest: (event, appState) =>
     (event.key === KEYS.ESCAPE &&
-      !appState.draggingElement &&
-      appState.multiElement === null) ||
+      (appState.editingLinearElement !== null ||
+        (!appState.draggingElement && appState.multiElement === null))) ||
     ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
       appState.multiElement !== null),
   PanelComponent: ({ appState, updateData }) => (

+ 19 - 16
src/actions/actionHistory.tsx

@@ -30,23 +30,26 @@ const writeData = (
     const prevElementMap = getElementMap(prevElements);
     const nextElements = data.elements;
     const nextElementMap = getElementMap(nextElements);
-    return {
-      elements: nextElements
-        .map((nextElement) =>
-          newElementWith(
-            prevElementMap[nextElement.id] || nextElement,
-            nextElement,
-          ),
-        )
-        .concat(
-          prevElements
-            .filter(
-              (prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
-            )
-            .map((prevElement) =>
-              newElementWith(prevElement, { isDeleted: true }),
-            ),
+
+    const elements = nextElements
+      .map((nextElement) =>
+        newElementWith(
+          prevElementMap[nextElement.id] || nextElement,
+          nextElement,
         ),
+      )
+      .concat(
+        prevElements
+          .filter(
+            (prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
+          )
+          .map((prevElement) =>
+            newElementWith(prevElement, { isDeleted: true }),
+          ),
+      );
+
+    return {
+      elements,
       appState: { ...appState, ...data.appState },
       commitToHistory,
       syncHistory: true,

+ 2 - 0
src/appState.ts

@@ -16,6 +16,7 @@ export const getDefaultAppState = (): AppState => {
     resizingElement: null,
     multiElement: null,
     editingElement: null,
+    editingLinearElement: null,
     elementType: "selection",
     elementLocked: false,
     exportBackground: true,
@@ -70,6 +71,7 @@ export const clearAppStateForLocalStorage = (appState: AppState) => {
     isLoading,
     errorMessage,
     showShortcutsDialog,
+    editingLinearElement,
     ...exportedState
   } = appState;
   return exportedState;

+ 140 - 22
src/components/App.tsx

@@ -132,6 +132,7 @@ import {
 } from "../data/localStorage";
 
 import throttle from "lodash.throttle";
+import { LinearElementEditor } from "../element/linearElementEditor";
 import {
   getSelectedGroupIds,
   selectGroupsForSelectedElements,
@@ -502,6 +503,16 @@ class App extends React.Component<any, AppState> {
       this.initializeSocketClient({ showLoadingState: true });
     }
 
+    if (
+      this.state.editingLinearElement &&
+      !this.state.selectedElementIds[this.state.editingLinearElement.elementId]
+    ) {
+      // defer so that the commitToHistory flag isn't reset via current update
+      setTimeout(() => {
+        this.actionManager.executeAction(actionFinalize);
+      });
+    }
+
     const cursorButton: {
       [id: string]: string | undefined;
     } = {};
@@ -1183,6 +1194,19 @@ class App extends React.Component<any, AppState> {
 
       if (
         selectedElements.length === 1 &&
+        isLinearElement(selectedElements[0])
+      ) {
+        if (
+          !this.state.editingLinearElement ||
+          this.state.editingLinearElement.elementId !== selectedElements[0].id
+        ) {
+          history.resumeRecording();
+          this.setState({
+            editingLinearElement: new LinearElementEditor(selectedElements[0]),
+          });
+        }
+      } else if (
+        selectedElements.length === 1 &&
         !isLinearElement(selectedElements[0])
       ) {
         const selectedElement = selectedElements[0];
@@ -1482,6 +1506,26 @@ class App extends React.Component<any, AppState> {
       return;
     }
 
+    const selectedElements = getSelectedElements(
+      globalSceneState.getElements(),
+      this.state,
+    );
+
+    if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
+      if (
+        !this.state.editingLinearElement ||
+        this.state.editingLinearElement.elementId !== selectedElements[0].id
+      ) {
+        history.resumeRecording();
+        this.setState({
+          editingLinearElement: new LinearElementEditor(selectedElements[0]),
+        });
+      }
+      return;
+    }
+
+    resetCursor();
+
     const { x, y } = viewportCoordsToSceneCoords(
       event,
       this.state,
@@ -1581,12 +1625,28 @@ class App extends React.Component<any, AppState> {
       }
     }
 
-    const { x, y } = viewportCoordsToSceneCoords(
+    const { x: scenePointerX, y: scenePointerY } = viewportCoordsToSceneCoords(
       event,
       this.state,
       this.canvas,
       window.devicePixelRatio,
     );
+
+    if (
+      this.state.editingLinearElement &&
+      this.state.editingLinearElement.draggingElementPointIndex === null
+    ) {
+      const editingLinearElement = LinearElementEditor.handlePointerMove(
+        event,
+        scenePointerX,
+        scenePointerY,
+        this.state.editingLinearElement,
+      );
+      if (editingLinearElement !== this.state.editingLinearElement) {
+        this.setState({ editingLinearElement });
+      }
+    }
+
     if (this.state.multiElement) {
       const { multiElement } = this.state;
       const { x: rx, y: ry } = multiElement;
@@ -1600,11 +1660,15 @@ class App extends React.Component<any, AppState> {
         // if we haven't yet created a temp point and we're beyond commit-zone
         //  threshold, add a point
         if (
-          distance2d(x - rx, y - ry, lastPoint[0], lastPoint[1]) >=
-          LINE_CONFIRM_THRESHOLD
+          distance2d(
+            scenePointerX - rx,
+            scenePointerY - ry,
+            lastPoint[0],
+            lastPoint[1],
+          ) >= LINE_CONFIRM_THRESHOLD
         ) {
           mutateElement(multiElement, {
-            points: [...points, [x - rx, y - ry]],
+            points: [...points, [scenePointerX - rx, scenePointerY - ry]],
           });
         } else {
           document.documentElement.style.cursor = CURSOR_TYPE.POINTER;
@@ -1618,8 +1682,8 @@ class App extends React.Component<any, AppState> {
           points.length > 2 &&
           lastCommittedPoint &&
           distance2d(
-            x - rx,
-            y - ry,
+            scenePointerX - rx,
+            scenePointerY - ry,
             lastCommittedPoint[0],
             lastCommittedPoint[1],
           ) < LINE_CONFIRM_THRESHOLD
@@ -1634,7 +1698,10 @@ class App extends React.Component<any, AppState> {
           }
           // update last uncommitted point
           mutateElement(multiElement, {
-            points: [...points.slice(0, -1), [x - rx, y - ry]],
+            points: [
+              ...points.slice(0, -1),
+              [scenePointerX - rx, scenePointerY - ry],
+            ],
           });
         }
       }
@@ -1653,11 +1720,16 @@ class App extends React.Component<any, AppState> {
     const elements = globalSceneState.getElements();
 
     const selectedElements = getSelectedElements(elements, this.state);
-    if (selectedElements.length === 1 && !isOverScrollBar) {
+    if (
+      selectedElements.length === 1 &&
+      !isOverScrollBar &&
+      !this.state.editingLinearElement
+    ) {
       const elementWithResizeHandler = getElementWithResizeHandler(
         elements,
         this.state,
-        { x, y },
+        scenePointerX,
+        scenePointerY,
         this.state.zoom,
         event.pointerType,
       );
@@ -1671,7 +1743,8 @@ class App extends React.Component<any, AppState> {
       if (canResizeMutlipleElements(selectedElements)) {
         const resizeHandle = getResizeHandlerFromCoords(
           getCommonBounds(selectedElements),
-          { x, y },
+          scenePointerX,
+          scenePointerY,
           this.state.zoom,
           event.pointerType,
         );
@@ -1686,8 +1759,8 @@ class App extends React.Component<any, AppState> {
     const hitElement = getElementAtPosition(
       elements,
       this.state,
-      x,
-      y,
+      scenePointerX,
+      scenePointerY,
       this.state.zoom,
     );
     if (this.state.elementType === "text") {
@@ -1928,11 +2001,12 @@ class App extends React.Component<any, AppState> {
     if (this.state.elementType === "selection") {
       const elements = globalSceneState.getElements();
       const selectedElements = getSelectedElements(elements, this.state);
-      if (selectedElements.length === 1) {
+      if (selectedElements.length === 1 && !this.state.editingLinearElement) {
         const elementWithResizeHandler = getElementWithResizeHandler(
           elements,
           this.state,
-          { x, y },
+          x,
+          y,
           this.state.zoom,
           event.pointerType,
         );
@@ -1952,7 +2026,8 @@ class App extends React.Component<any, AppState> {
         if (canResizeMutlipleElements(selectedElements)) {
           resizeHandle = getResizeHandlerFromCoords(
             getCommonBounds(selectedElements),
-            { x, y },
+            x,
+            y,
             this.state.zoom,
             event.pointerType,
           );
@@ -1985,13 +2060,28 @@ class App extends React.Component<any, AppState> {
         }
       }
       if (!isResizingElements) {
-        hitElement = getElementAtPosition(
-          elements,
-          this.state,
-          x,
-          y,
-          this.state.zoom,
-        );
+        if (this.state.editingLinearElement) {
+          const ret = LinearElementEditor.handlePointerDown(
+            event,
+            this.state,
+            (appState) => this.setState(appState),
+            history,
+            x,
+            y,
+          );
+          if (ret.hitElement) {
+            hitElement = ret.hitElement;
+          }
+          if (ret.didAddPoint) {
+            return;
+          }
+        }
+
+        // hitElement may already be set above, so check first
+        hitElement =
+          hitElement ||
+          getElementAtPosition(elements, this.state, x, y, this.state.zoom);
+
         // clear selection if shift is not clicked
         if (
           !(hitElement && this.state.selectedElementIds[hitElement.id]) &&
@@ -2271,6 +2361,23 @@ class App extends React.Component<any, AppState> {
         }
       }
 
+      if (this.state.editingLinearElement) {
+        const didDrag = LinearElementEditor.handlePointDragging(
+          this.state,
+          (appState) => this.setState(appState),
+          x,
+          y,
+          lastX,
+          lastY,
+        );
+
+        if (didDrag) {
+          lastX = x;
+          lastY = y;
+          return;
+        }
+      }
+
       if (hitElement && this.state.selectedElementIds[hitElement.id]) {
         // Marking that click was used for dragging to check
         // if elements should be deselected on pointerup
@@ -2457,6 +2564,17 @@ class App extends React.Component<any, AppState> {
 
       this.savePointer(childEvent.clientX, childEvent.clientY, "up");
 
+      // if moving start/end point towards start/end point within threshold,
+      //  close the loop
+      if (this.state.editingLinearElement) {
+        const editingLinearElement = LinearElementEditor.handlePointerUp(
+          this.state.editingLinearElement,
+        );
+        if (editingLinearElement !== this.state.editingLinearElement) {
+          this.setState({ editingLinearElement });
+        }
+      }
+
       lastPointerUp = null;
 
       window.removeEventListener(EVENT.POINTER_MOVE, onPointerMove);

+ 13 - 1
src/components/HintViewer.tsx

@@ -6,6 +6,7 @@ import { getSelectedElements } from "../scene";
 import "./HintViewer.scss";
 import { AppState } from "../types";
 import { isLinearElement } from "../element/typeChecks";
+import { getShortcutKey } from "../utils";
 
 interface Hint {
   appState: AppState;
@@ -43,11 +44,20 @@ const getHints = ({ appState, elements }: Hint) => {
     return t("hints.rotate");
   }
 
+  if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
+    if (appState.editingLinearElement) {
+      return appState.editingLinearElement.activePointIndex
+        ? t("hints.lineEditor_pointSelected")
+        : t("hints.lineEditor_nothingSelected");
+    }
+    return t("hints.lineEditor_info");
+  }
+
   return null;
 };
 
 export const HintViewer = ({ appState, elements }: Hint) => {
-  const hint = getHints({
+  let hint = getHints({
     appState,
     elements,
   });
@@ -55,6 +65,8 @@ export const HintViewer = ({ appState, elements }: Hint) => {
     return null;
   }
 
+  hint = getShortcutKey(hint);
+
   return (
     <div className="HintViewer">
       <span>{hint}</span>

+ 20 - 0
src/element/bounds.ts

@@ -343,6 +343,26 @@ export const getResizedElementAbsoluteCoords = (
   ];
 };
 
+export const getElementPointsCoords = (
+  element: ExcalidrawLinearElement,
+  points: readonly (readonly [number, number])[],
+): [number, number, number, number] => {
+  // This might be computationally heavey
+  const gen = rough.generator();
+  const curve = gen.curve(
+    points as [number, number][],
+    generateRoughOptions(element),
+  );
+  const ops = getCurvePathOps(curve);
+  const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
+  return [
+    minX + element.x,
+    minY + element.y,
+    maxX + element.x,
+    maxY + element.y,
+  ];
+};
+
 export const getClosestElementBounds = (
   elements: readonly ExcalidrawElement[],
   from: { x: number; y: number },

+ 409 - 0
src/element/linearElementEditor.ts

@@ -0,0 +1,409 @@
+import {
+  NonDeleted,
+  ExcalidrawLinearElement,
+  ExcalidrawElement,
+} from "./types";
+import { distance2d, rotate, isPathALoop } from "../math";
+import { getElementAbsoluteCoords } from ".";
+import { getElementPointsCoords } from "./bounds";
+import { Point, AppState } from "../types";
+import { mutateElement } from "./mutateElement";
+import { SceneHistory } from "../history";
+import { globalSceneState } from "../scene";
+
+export class LinearElementEditor {
+  public elementId: ExcalidrawElement["id"];
+  public activePointIndex: number | null;
+  public draggingElementPointIndex: number | null;
+  public lastUncommittedPoint: Point | null;
+
+  constructor(element: NonDeleted<ExcalidrawLinearElement>) {
+    LinearElementEditor.normalizePoints(element);
+
+    this.elementId = element.id;
+    this.activePointIndex = null;
+    this.lastUncommittedPoint = null;
+    this.draggingElementPointIndex = null;
+  }
+
+  // ---------------------------------------------------------------------------
+  // static methods
+  // ---------------------------------------------------------------------------
+
+  static POINT_HANDLE_SIZE = 20;
+
+  static getElement(id: ExcalidrawElement["id"]) {
+    const element = globalSceneState.getNonDeletedElement(id);
+    if (element) {
+      return element as NonDeleted<ExcalidrawLinearElement>;
+    }
+    return null;
+  }
+
+  /** @returns whether point was dragged */
+  static handlePointDragging(
+    appState: AppState,
+    setState: React.Component<any, AppState>["setState"],
+    scenePointerX: number,
+    scenePointerY: number,
+    lastX: number,
+    lastY: number,
+  ): boolean {
+    if (!appState.editingLinearElement) {
+      return false;
+    }
+    const { editingLinearElement } = appState;
+    let { draggingElementPointIndex, elementId } = editingLinearElement;
+
+    const element = LinearElementEditor.getElement(elementId);
+    if (!element) {
+      return false;
+    }
+
+    const clickedPointIndex =
+      draggingElementPointIndex ??
+      LinearElementEditor.getPointIndexUnderCursor(
+        element,
+        appState.zoom,
+        scenePointerX,
+        scenePointerY,
+      );
+
+    draggingElementPointIndex = draggingElementPointIndex ?? clickedPointIndex;
+    if (draggingElementPointIndex > -1) {
+      if (
+        editingLinearElement.draggingElementPointIndex !==
+          draggingElementPointIndex ||
+        editingLinearElement.activePointIndex !== clickedPointIndex
+      ) {
+        setState({
+          editingLinearElement: {
+            ...editingLinearElement,
+            draggingElementPointIndex,
+            activePointIndex: clickedPointIndex,
+          },
+        });
+      }
+
+      const [deltaX, deltaY] = rotate(
+        scenePointerX - lastX,
+        scenePointerY - lastY,
+        0,
+        0,
+        -element.angle,
+      );
+      const targetPoint = element.points[clickedPointIndex];
+      LinearElementEditor.movePoint(element, clickedPointIndex, [
+        targetPoint[0] + deltaX,
+        targetPoint[1] + deltaY,
+      ]);
+      return true;
+    }
+    return false;
+  }
+
+  static handlePointerUp(
+    editingLinearElement: LinearElementEditor,
+  ): LinearElementEditor {
+    const { elementId, draggingElementPointIndex } = editingLinearElement;
+    const element = LinearElementEditor.getElement(elementId);
+    if (!element) {
+      return editingLinearElement;
+    }
+
+    if (
+      draggingElementPointIndex !== null &&
+      (draggingElementPointIndex === 0 ||
+        draggingElementPointIndex === element.points.length - 1) &&
+      isPathALoop(element.points)
+    ) {
+      LinearElementEditor.movePoint(
+        element,
+        draggingElementPointIndex,
+        draggingElementPointIndex === 0
+          ? element.points[element.points.length - 1]
+          : element.points[0],
+      );
+    }
+    if (draggingElementPointIndex !== null) {
+      return {
+        ...editingLinearElement,
+        draggingElementPointIndex: null,
+      };
+    }
+    return editingLinearElement;
+  }
+
+  static handlePointerDown(
+    event: React.PointerEvent<HTMLCanvasElement>,
+    appState: AppState,
+    setState: React.Component<any, AppState>["setState"],
+    history: SceneHistory,
+    scenePointerX: number,
+    scenePointerY: number,
+  ): {
+    didAddPoint: boolean;
+    hitElement: ExcalidrawElement | null;
+  } {
+    const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
+      didAddPoint: false,
+      hitElement: null,
+    };
+
+    if (!appState.editingLinearElement) {
+      return ret;
+    }
+
+    const { elementId } = appState.editingLinearElement;
+    const element = LinearElementEditor.getElement(elementId);
+
+    if (!element) {
+      return ret;
+    }
+
+    if (event.altKey) {
+      if (!appState.editingLinearElement.lastUncommittedPoint) {
+        mutateElement(element, {
+          points: [
+            ...element.points,
+            LinearElementEditor.createPointAt(
+              element,
+              scenePointerX,
+              scenePointerY,
+            ),
+          ],
+        });
+      }
+      if (appState.editingLinearElement.lastUncommittedPoint !== null) {
+        history.resumeRecording();
+      }
+      setState({
+        editingLinearElement: {
+          ...appState.editingLinearElement,
+          activePointIndex: element.points.length - 1,
+          lastUncommittedPoint: null,
+        },
+      });
+      ret.didAddPoint = true;
+      return ret;
+    }
+
+    const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor(
+      element,
+      appState.zoom,
+      scenePointerX,
+      scenePointerY,
+    );
+
+    // 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 > -1) {
+      ret.hitElement = element;
+    }
+
+    setState({
+      editingLinearElement: {
+        ...appState.editingLinearElement,
+        activePointIndex: clickedPointIndex > -1 ? clickedPointIndex : null,
+      },
+    });
+    return ret;
+  }
+
+  static handlePointerMove(
+    event: React.PointerEvent<HTMLCanvasElement>,
+    scenePointerX: number,
+    scenePointerY: number,
+    editingLinearElement: LinearElementEditor,
+  ): LinearElementEditor {
+    const { elementId, lastUncommittedPoint } = editingLinearElement;
+    const element = LinearElementEditor.getElement(elementId);
+    if (!element) {
+      return editingLinearElement;
+    }
+
+    const { points } = element;
+    const lastPoint = points[points.length - 1];
+
+    if (!event.altKey) {
+      if (lastPoint === lastUncommittedPoint) {
+        LinearElementEditor.movePoint(element, points.length - 1, "delete");
+      }
+      return editingLinearElement;
+    }
+
+    const newPoint = LinearElementEditor.createPointAt(
+      element,
+      scenePointerX,
+      scenePointerY,
+    );
+
+    if (lastPoint === lastUncommittedPoint) {
+      LinearElementEditor.movePoint(
+        element,
+        element.points.length - 1,
+        newPoint,
+      );
+    } else {
+      LinearElementEditor.movePoint(element, "new", newPoint);
+    }
+
+    return {
+      ...editingLinearElement,
+      lastUncommittedPoint: element.points[element.points.length - 1],
+    };
+  }
+
+  static getPointsGlobalCoordinates(
+    element: NonDeleted<ExcalidrawLinearElement>,
+  ) {
+    const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+    const cx = (x1 + x2) / 2;
+    const cy = (y1 + y2) / 2;
+    return element.points.map((point) => {
+      let { x, y } = element;
+      [x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle);
+      return [x, y];
+    });
+  }
+
+  static getPointIndexUnderCursor(
+    element: NonDeleted<ExcalidrawLinearElement>,
+    zoom: AppState["zoom"],
+    x: number,
+    y: number,
+  ) {
+    const pointHandles = this.getPointsGlobalCoordinates(element);
+    let idx = pointHandles.length;
+    // loop from right to left because points on the right are rendered over
+    //  points on the left, thus should take precedence when clicking, if they
+    //  overlap
+    while (--idx > -1) {
+      const point = pointHandles[idx];
+      if (
+        distance2d(x, y, point[0], point[1]) * zoom <
+        // +1px to account for outline stroke
+        this.POINT_HANDLE_SIZE / 2 + 1
+      ) {
+        return idx;
+      }
+    }
+    return -1;
+  }
+
+  static createPointAt(
+    element: NonDeleted<ExcalidrawLinearElement>,
+    scenePointerX: number,
+    scenePointerY: number,
+  ): Point {
+    const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+    const cx = (x1 + x2) / 2;
+    const cy = (y1 + y2) / 2;
+    const [rotatedX, rotatedY] = rotate(
+      scenePointerX,
+      scenePointerY,
+      cx,
+      cy,
+      -element.angle,
+    );
+
+    return [rotatedX - element.x, rotatedY - element.y];
+  }
+
+  // element-mutating methods
+  // ---------------------------------------------------------------------------
+
+  /**
+   * Normalizes line points so that the start point is at [0,0]. This is
+   *  expected in various parts of the codebase.
+   */
+  static normalizePoints(element: NonDeleted<ExcalidrawLinearElement>) {
+    const { points } = element;
+
+    const offsetX = points[0][0];
+    const offsetY = points[0][1];
+
+    mutateElement(element, {
+      points: points.map((point, _idx) => {
+        return [point[0] - offsetX, point[1] - offsetY] as const;
+      }),
+      x: element.x + offsetX,
+      y: element.y + offsetY,
+    });
+  }
+
+  static movePoint(
+    element: NonDeleted<ExcalidrawLinearElement>,
+    pointIndex: number | "new",
+    targetPosition: Point | "delete",
+  ) {
+    const { points } = element;
+
+    // in case we're moving start point, instead of modifying its position
+    //  which would break the invariant of it being at [0,0], we move
+    //  all the other points in the opposite direction by delta to
+    //  offset it. We do the same with actual element.x/y position, so
+    //  this hacks are completely transparent to the user.
+    let offsetX = 0;
+    let offsetY = 0;
+
+    let nextPoints: (readonly [number, number])[];
+    if (targetPosition === "delete") {
+      // remove point
+      if (pointIndex === "new") {
+        throw new Error("invalid args in movePoint");
+      }
+      nextPoints = points.slice();
+      nextPoints.splice(pointIndex, 1);
+      if (pointIndex === 0) {
+        // if deleting first point, make the next to be [0,0] and recalculate
+        //  positions of the rest with respect to it
+        offsetX = nextPoints[0][0];
+        offsetY = nextPoints[0][1];
+        nextPoints = nextPoints.map((point, idx) => {
+          if (idx === 0) {
+            return [0, 0];
+          }
+          return [point[0] - offsetX, point[1] - offsetY];
+        });
+      }
+    } else if (pointIndex === "new") {
+      nextPoints = [...points, targetPosition];
+    } else {
+      const deltaX = targetPosition[0] - points[pointIndex][0];
+      const deltaY = targetPosition[1] - points[pointIndex][1];
+      nextPoints = points.map((point, idx) => {
+        if (idx === pointIndex) {
+          if (idx === 0) {
+            offsetX = deltaX;
+            offsetY = deltaY;
+            return point;
+          }
+          offsetX = 0;
+          offsetY = 0;
+
+          return [point[0] + deltaX, point[1] + deltaY] as const;
+        }
+        return offsetX || offsetY
+          ? ([point[0] - offsetX, point[1] - offsetY] as const)
+          : point;
+      });
+    }
+
+    const nextCoords = getElementPointsCoords(element, nextPoints);
+    const prevCoords = getElementPointsCoords(element, points);
+    const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2;
+    const nextCenterY = (nextCoords[1] + nextCoords[3]) / 2;
+    const prevCenterX = (prevCoords[0] + prevCoords[2]) / 2;
+    const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2;
+    const dX = prevCenterX - nextCenterX;
+    const dY = prevCenterY - nextCenterY;
+    const rotated = rotate(offsetX, offsetY, dX, dY, element.angle);
+
+    mutateElement(element, {
+      points: nextPoints,
+      x: element.x + rotated[0],
+      y: element.y + rotated[1],
+    });
+  }
+}

+ 25 - 1
src/element/mutateElement.ts

@@ -3,6 +3,7 @@ import { invalidateShapeForElement } from "../renderer/renderElement";
 import { globalSceneState } from "../scene";
 import { getSizeFromPoints } from "../points";
 import { randomInteger } from "../random";
+import { Point } from "../types";
 
 type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
   Partial<TElement>,
@@ -24,7 +25,6 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
   const { points } = updates as any;
 
   if (typeof points !== "undefined") {
-    didChange = true;
     updates = { ...getSizeFromPoints(points), ...updates };
   }
 
@@ -38,6 +38,30 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
       ) {
         continue;
       }
+
+      if (key === "points") {
+        const prevPoints = (element as any)[key];
+        const nextPoints = value;
+        if (prevPoints.length === nextPoints.length) {
+          let didChangePoints = false;
+          let i = prevPoints.length;
+          while (--i) {
+            const prevPoint: Point = prevPoints[i];
+            const nextPoint: Point = nextPoints[i];
+            if (
+              prevPoint[0] !== nextPoint[0] ||
+              prevPoint[1] !== nextPoint[1]
+            ) {
+              didChangePoints = true;
+              break;
+            }
+          }
+          if (!didChangePoints) {
+            continue;
+          }
+        }
+      }
+
       (element as any)[key] = value;
       didChange = true;
     }

+ 16 - 6
src/element/resizeTest.ts

@@ -63,21 +63,31 @@ export const resizeTest = (
 export const getElementWithResizeHandler = (
   elements: readonly NonDeletedExcalidrawElement[],
   appState: AppState,
-  { x, y }: { x: number; y: number },
+  scenePointerX: number,
+  scenePointerY: number,
   zoom: number,
   pointerType: PointerType,
-) =>
-  elements.reduce((result, element) => {
+) => {
+  return elements.reduce((result, element) => {
     if (result) {
       return result;
     }
-    const resizeHandle = resizeTest(element, appState, x, y, zoom, pointerType);
+    const resizeHandle = resizeTest(
+      element,
+      appState,
+      scenePointerX,
+      scenePointerY,
+      zoom,
+      pointerType,
+    );
     return resizeHandle ? { element, resizeHandle } : null;
   }, null as { element: NonDeletedExcalidrawElement; resizeHandle: ReturnType<typeof resizeTest> } | null);
+};
 
 export const getResizeHandlerFromCoords = (
   [x1, y1, x2, y2]: readonly [number, number, number, number],
-  { x, y }: { x: number; y: number },
+  scenePointerX: number,
+  scenePointerY: number,
   zoom: number,
   pointerType: PointerType,
 ) => {
@@ -91,7 +101,7 @@ export const getResizeHandlerFromCoords = (
 
   const found = Object.keys(handlers).find((key) => {
     const handler = handlers[key as Exclude<HandlerRectanglesRet, "rotation">]!;
-    return handler && isInHandlerRect(handler, x, y);
+    return handler && isInHandlerRect(handler, scenePointerX, scenePointerY);
   });
   return (found || false) as HandlerRectanglesRet;
 };

+ 1 - 1
src/element/types.ts

@@ -65,7 +65,7 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
 export type ExcalidrawLinearElement = _ExcalidrawElementBase &
   Readonly<{
     type: "arrow" | "line" | "draw";
-    points: Point[];
+    points: readonly Point[];
     lastCommittedPoint?: Point | null;
   }>;
 

+ 9 - 0
src/history.ts

@@ -22,6 +22,7 @@ const clearAppStatePropertiesForHistory = (appState: AppState) => {
   return {
     selectedElementIds: appState.selectedElementIds,
     viewBackgroundColor: appState.viewBackgroundColor,
+    editingLinearElement: appState.editingLinearElement,
     editingGroupId: appState.editingGroupId,
     name: appState.name,
   };
@@ -160,6 +161,14 @@ export class SceneHistory {
     // note: this is safe because entry's appState is guaranteed no excess props
     let key: keyof typeof nextEntry.appState;
     for (key in nextEntry.appState) {
+      if (key === "editingLinearElement") {
+        if (
+          nextEntry.appState[key]?.elementId ===
+          lastEntry.appState[key]?.elementId
+        ) {
+          continue;
+        }
+      }
       if (key === "selectedElementIds") {
         continue;
       }

+ 4 - 1
src/locales/en.json

@@ -121,7 +121,10 @@
     "freeDraw": "Click and drag, release when you're finished",
     "linearElementMulti": "Click on last point or press Escape or Enter to finish",
     "resize": "You can constrain proportions by holding SHIFT while resizing,\nhold ALT to resize from the center",
-    "rotate": "You can constrain angles by holding SHIFT while rotating"
+    "rotate": "You can constrain angles by holding SHIFT while rotating",
+    "lineEditor_info": "Double-click or press Enter to edit points",
+    "lineEditor_pointSelected": "Press Delete to remove point or drag to move",
+    "lineEditor_nothingSelected": "Select a point to move or remove, or hold Alt and click to add new points"
   },
   "errorSplash": {
     "headingMain_pre": "Encountered an error. Try ",

+ 4 - 1
src/math.ts

@@ -1,5 +1,6 @@
 import { Point } from "./types";
 import { LINE_CONFIRM_THRESHOLD } from "./constants";
+import { ExcalidrawLinearElement } from "./element/types";
 
 // https://stackoverflow.com/a/6853926/232122
 export const distanceBetweenPointAndSegment = (
@@ -240,7 +241,9 @@ export const distance2d = (x1: number, y1: number, x2: number, y2: number) => {
 
 // Checks if the first and last point are close enough
 // to be considered a loop
-export const isPathALoop = (points: Point[]): boolean => {
+export const isPathALoop = (
+  points: ExcalidrawLinearElement["points"],
+): boolean => {
   if (points.length >= 3) {
     const [firstPoint, lastPoint] = [points[0], points[points.length - 1]];
     return (

+ 52 - 2
src/renderer/renderScene.ts

@@ -6,6 +6,8 @@ import { FlooredNumber, AppState } from "../types";
 import {
   ExcalidrawElement,
   NonDeletedExcalidrawElement,
+  ExcalidrawLinearElement,
+  NonDeleted,
   GroupId,
 } from "../element/types";
 import {
@@ -28,6 +30,8 @@ import { getSelectedElements } from "../scene/selection";
 
 import { renderElement, renderElementToSvg } from "./renderElement";
 import colors from "../colors";
+import { isLinearElement } from "../element/typeChecks";
+import { LinearElementEditor } from "../element/linearElementEditor";
 import {
   isSelectedViaGroup,
   getSelectedGroupIds,
@@ -83,6 +87,41 @@ const strokeCircle = (
   context.stroke();
 };
 
+const renderLinearPointHandles = (
+  context: CanvasRenderingContext2D,
+  appState: AppState,
+  sceneState: SceneState,
+  element: NonDeleted<ExcalidrawLinearElement>,
+) => {
+  context.translate(sceneState.scrollX, sceneState.scrollY);
+  const origStrokeStyle = context.strokeStyle;
+  const lineWidth = context.lineWidth;
+  context.lineWidth = 1 / sceneState.zoom;
+
+  LinearElementEditor.getPointsGlobalCoordinates(element).forEach(
+    (point, idx) => {
+      context.strokeStyle = "red";
+      context.setLineDash([]);
+      context.fillStyle =
+        appState.editingLinearElement?.activePointIndex === idx
+          ? "rgba(255, 127, 127, 0.9)"
+          : "rgba(255, 255, 255, 0.9)";
+      const { POINT_HANDLE_SIZE } = LinearElementEditor;
+      strokeCircle(
+        context,
+        point[0] - POINT_HANDLE_SIZE / 2 / sceneState.zoom,
+        point[1] - POINT_HANDLE_SIZE / 2 / sceneState.zoom,
+        POINT_HANDLE_SIZE / sceneState.zoom,
+        POINT_HANDLE_SIZE / sceneState.zoom,
+      );
+    },
+  );
+  context.setLineDash([]);
+  context.lineWidth = lineWidth;
+  context.translate(-sceneState.scrollX, -sceneState.scrollY);
+  context.strokeStyle = origStrokeStyle;
+};
+
 export const renderScene = (
   elements: readonly NonDeletedExcalidrawElement[],
   appState: AppState,
@@ -153,9 +192,16 @@ export const renderScene = (
 
   visibleElements.forEach((element) => {
     renderElement(element, rc, context, renderOptimizations, sceneState);
+    if (
+      isLinearElement(element) &&
+      appState.editingLinearElement &&
+      appState.editingLinearElement.elementId === element.id
+    ) {
+      renderLinearPointHandles(context, appState, sceneState, element);
+    }
   });
 
-  // Pain selection element
+  // Paint selection element
   if (selectionElement) {
     renderElement(
       selectionElement,
@@ -167,7 +213,11 @@ export const renderScene = (
   }
 
   // Paint selected elements
-  if (renderSelection) {
+  if (
+    renderSelection &&
+    !appState.multiElement &&
+    !appState.editingLinearElement
+  ) {
     context.translate(sceneState.scrollX, sceneState.scrollY);
 
     const selections = elements.reduce((acc, element) => {

+ 29 - 6
src/scene/globalScene.ts

@@ -1,8 +1,13 @@
 import {
   ExcalidrawElement,
   NonDeletedExcalidrawElement,
+  NonDeleted,
 } from "../element/types";
-import { getNonDeletedElements } from "../element";
+import {
+  getNonDeletedElements,
+  isNonDeletedElement,
+  getElementMap,
+} from "../element";
 
 export interface SceneStateCallback {
   (): void;
@@ -13,22 +18,40 @@ export interface SceneStateCallbackRemover {
 }
 
 class GlobalScene {
-  private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = [];
   private callbacks: Set<SceneStateCallback> = new Set();
 
-  constructor(private _elements: readonly ExcalidrawElement[] = []) {}
+  private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = [];
+  private elements: readonly ExcalidrawElement[] = [];
+  private elementsMap: {
+    [id: string]: ExcalidrawElement;
+  } = {};
 
   getElementsIncludingDeleted() {
-    return this._elements;
+    return this.elements;
   }
 
   getElements(): readonly NonDeletedExcalidrawElement[] {
     return this.nonDeletedElements;
   }
 
+  getElement(id: ExcalidrawElement["id"]): ExcalidrawElement | null {
+    return this.elementsMap[id] || null;
+  }
+
+  getNonDeletedElement(
+    id: ExcalidrawElement["id"],
+  ): NonDeleted<ExcalidrawElement> | null {
+    const element = this.getElement(id);
+    if (element && isNonDeletedElement(element)) {
+      return element;
+    }
+    return null;
+  }
+
   replaceAllElements(nextElements: readonly ExcalidrawElement[]) {
-    this._elements = nextElements;
-    this.nonDeletedElements = getNonDeletedElements(this._elements);
+    this.elements = nextElements;
+    this.elementsMap = getElementMap(nextElements);
+    this.nonDeletedElements = getNonDeletedElements(this.elements);
     this.informMutation();
   }
 

Datei-Diff unterdrückt, da er zu groß ist
+ 126 - 0
src/tests/__snapshots__/regressionTests.test.tsx.snap


+ 2 - 0
src/types.ts

@@ -11,6 +11,7 @@ import {
 import { SHAPES } from "./shapes";
 import { Point as RoughPoint } from "roughjs/bin/geometry";
 import { SocketUpdateDataSource } from "./data";
+import { LinearElementEditor } from "./element/linearElementEditor";
 
 export type FlooredNumber = number & { _brand: "FlooredNumber" };
 export type Point = Readonly<RoughPoint>;
@@ -25,6 +26,7 @@ export type AppState = {
   // element being edited, but not necessarily added to elements array yet
   //  (e.g. text element when typing into the input)
   editingElement: NonDeletedExcalidrawElement | null;
+  editingLinearElement: LinearElementEditor | null;
   elementType: typeof SHAPES[number]["value"];
   elementLocked: boolean;
   exportBackground: boolean;

+ 5 - 5
src/utils.ts

@@ -168,12 +168,12 @@ export const getShortcutKey = (shortcut: string): string => {
   const isMac = /Mac|iPod|iPhone|iPad/.test(window.navigator.platform);
   if (isMac) {
     return `${shortcut
-      .replace(/CtrlOrCmd/i, "Cmd")
-      .replace(/Alt/i, "Option")
-      .replace(/Del/i, "Delete")
-      .replace(/Enter|Return/i, "Enter")}`;
+      .replace(/\bCtrlOrCmd\b/i, "Cmd")
+      .replace(/\bAlt\b/i, "Option")
+      .replace(/\bDel\b/i, "Delete")
+      .replace(/\b(Enter|Return)\b/i, "Enter")}`;
   }
-  return `${shortcut.replace(/CtrlOrCmd/i, "Ctrl")}`;
+  return `${shortcut.replace(/\bCtrlOrCmd\b/i, "Ctrl")}`;
 };
 export const viewportCoordsToSceneCoords = (
   { clientX, clientY }: { clientX: number; clientY: number },

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.