linearElementEditor.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853
  1. import {
  2. NonDeleted,
  3. ExcalidrawLinearElement,
  4. ExcalidrawElement,
  5. PointBinding,
  6. ExcalidrawBindableElement,
  7. } from "./types";
  8. import { distance2d, rotate, isPathALoop, getGridPoint } from "../math";
  9. import { getElementAbsoluteCoords } from ".";
  10. import { getElementPointsCoords } from "./bounds";
  11. import { Point, AppState } from "../types";
  12. import { mutateElement } from "./mutateElement";
  13. import History from "../history";
  14. import Scene from "../scene/Scene";
  15. import {
  16. bindOrUnbindLinearElement,
  17. getHoveredElementForBinding,
  18. isBindingEnabled,
  19. } from "./binding";
  20. import { tupleToCoors } from "../utils";
  21. import { isBindingElement } from "./typeChecks";
  22. export class LinearElementEditor {
  23. public elementId: ExcalidrawElement["id"] & {
  24. _brand: "excalidrawLinearElementId";
  25. };
  26. /** indices */
  27. public selectedPointsIndices: readonly number[] | null;
  28. public pointerDownState: Readonly<{
  29. prevSelectedPointsIndices: readonly number[] | null;
  30. /** index */
  31. lastClickedPoint: number;
  32. }>;
  33. /** whether you're dragging a point */
  34. public isDragging: boolean;
  35. public lastUncommittedPoint: Point | null;
  36. public pointerOffset: Readonly<{ x: number; y: number }>;
  37. public startBindingElement: ExcalidrawBindableElement | null | "keep";
  38. public endBindingElement: ExcalidrawBindableElement | null | "keep";
  39. constructor(element: NonDeleted<ExcalidrawLinearElement>, scene: Scene) {
  40. this.elementId = element.id as string & {
  41. _brand: "excalidrawLinearElementId";
  42. };
  43. Scene.mapElementToScene(this.elementId, scene);
  44. LinearElementEditor.normalizePoints(element);
  45. this.selectedPointsIndices = null;
  46. this.lastUncommittedPoint = null;
  47. this.isDragging = false;
  48. this.pointerOffset = { x: 0, y: 0 };
  49. this.startBindingElement = "keep";
  50. this.endBindingElement = "keep";
  51. this.pointerDownState = {
  52. prevSelectedPointsIndices: null,
  53. lastClickedPoint: -1,
  54. };
  55. }
  56. // ---------------------------------------------------------------------------
  57. // static methods
  58. // ---------------------------------------------------------------------------
  59. static POINT_HANDLE_SIZE = 20;
  60. /**
  61. * @param id the `elementId` from the instance of this class (so that we can
  62. * statically guarantee this method returns an ExcalidrawLinearElement)
  63. */
  64. static getElement(id: InstanceType<typeof LinearElementEditor>["elementId"]) {
  65. const element = Scene.getScene(id)?.getNonDeletedElement(id);
  66. if (element) {
  67. return element as NonDeleted<ExcalidrawLinearElement>;
  68. }
  69. return null;
  70. }
  71. static handleBoxSelection(
  72. event: PointerEvent,
  73. appState: AppState,
  74. setState: React.Component<any, AppState>["setState"],
  75. ) {
  76. if (
  77. !appState.editingLinearElement ||
  78. appState.draggingElement?.type !== "selection"
  79. ) {
  80. return false;
  81. }
  82. const { editingLinearElement } = appState;
  83. const { selectedPointsIndices, elementId } = editingLinearElement;
  84. const element = LinearElementEditor.getElement(elementId);
  85. if (!element) {
  86. return false;
  87. }
  88. const [selectionX1, selectionY1, selectionX2, selectionY2] =
  89. getElementAbsoluteCoords(appState.draggingElement);
  90. const pointsSceneCoords =
  91. LinearElementEditor.getPointsGlobalCoordinates(element);
  92. const nextSelectedPoints = pointsSceneCoords.reduce(
  93. (acc: number[], point, index) => {
  94. if (
  95. (point[0] >= selectionX1 &&
  96. point[0] <= selectionX2 &&
  97. point[1] >= selectionY1 &&
  98. point[1] <= selectionY2) ||
  99. (event.shiftKey && selectedPointsIndices?.includes(index))
  100. ) {
  101. acc.push(index);
  102. }
  103. return acc;
  104. },
  105. [],
  106. );
  107. setState({
  108. editingLinearElement: {
  109. ...editingLinearElement,
  110. selectedPointsIndices: nextSelectedPoints.length
  111. ? nextSelectedPoints
  112. : null,
  113. },
  114. });
  115. }
  116. /** @returns whether point was dragged */
  117. static handlePointDragging(
  118. appState: AppState,
  119. setState: React.Component<any, AppState>["setState"],
  120. scenePointerX: number,
  121. scenePointerY: number,
  122. maybeSuggestBinding: (
  123. element: NonDeleted<ExcalidrawLinearElement>,
  124. pointSceneCoords: { x: number; y: number }[],
  125. ) => void,
  126. ): boolean {
  127. if (!appState.editingLinearElement) {
  128. return false;
  129. }
  130. const { editingLinearElement } = appState;
  131. const { selectedPointsIndices, elementId, isDragging } =
  132. editingLinearElement;
  133. const element = LinearElementEditor.getElement(elementId);
  134. if (!element) {
  135. return false;
  136. }
  137. // point that's being dragged (out of all selected points)
  138. const draggingPoint = element.points[
  139. editingLinearElement.pointerDownState.lastClickedPoint
  140. ] as [number, number] | undefined;
  141. if (selectedPointsIndices && draggingPoint) {
  142. if (isDragging === false) {
  143. setState({
  144. editingLinearElement: {
  145. ...editingLinearElement,
  146. isDragging: true,
  147. },
  148. });
  149. }
  150. const newDraggingPointPosition = LinearElementEditor.createPointAt(
  151. element,
  152. scenePointerX - editingLinearElement.pointerOffset.x,
  153. scenePointerY - editingLinearElement.pointerOffset.y,
  154. appState.gridSize,
  155. );
  156. const deltaX = newDraggingPointPosition[0] - draggingPoint[0];
  157. const deltaY = newDraggingPointPosition[1] - draggingPoint[1];
  158. LinearElementEditor.movePoints(
  159. element,
  160. selectedPointsIndices.map((pointIndex) => {
  161. const newPointPosition =
  162. pointIndex ===
  163. editingLinearElement.pointerDownState.lastClickedPoint
  164. ? LinearElementEditor.createPointAt(
  165. element,
  166. scenePointerX - editingLinearElement.pointerOffset.x,
  167. scenePointerY - editingLinearElement.pointerOffset.y,
  168. appState.gridSize,
  169. )
  170. : ([
  171. element.points[pointIndex][0] + deltaX,
  172. element.points[pointIndex][1] + deltaY,
  173. ] as const);
  174. return {
  175. index: pointIndex,
  176. point: newPointPosition,
  177. isDragging:
  178. pointIndex ===
  179. editingLinearElement.pointerDownState.lastClickedPoint,
  180. };
  181. }),
  182. );
  183. // suggest bindings for first and last point if selected
  184. if (isBindingElement(element)) {
  185. const coords: { x: number; y: number }[] = [];
  186. const firstSelectedIndex = selectedPointsIndices[0];
  187. if (firstSelectedIndex === 0) {
  188. coords.push(
  189. tupleToCoors(
  190. LinearElementEditor.getPointGlobalCoordinates(
  191. element,
  192. element.points[0],
  193. ),
  194. ),
  195. );
  196. }
  197. const lastSelectedIndex =
  198. selectedPointsIndices[selectedPointsIndices.length - 1];
  199. if (lastSelectedIndex === element.points.length - 1) {
  200. coords.push(
  201. tupleToCoors(
  202. LinearElementEditor.getPointGlobalCoordinates(
  203. element,
  204. element.points[lastSelectedIndex],
  205. ),
  206. ),
  207. );
  208. }
  209. if (coords.length) {
  210. maybeSuggestBinding(element, coords);
  211. }
  212. }
  213. return true;
  214. }
  215. return false;
  216. }
  217. static handlePointerUp(
  218. event: PointerEvent,
  219. editingLinearElement: LinearElementEditor,
  220. appState: AppState,
  221. ): LinearElementEditor {
  222. const { elementId, selectedPointsIndices, isDragging, pointerDownState } =
  223. editingLinearElement;
  224. const element = LinearElementEditor.getElement(elementId);
  225. if (!element) {
  226. return editingLinearElement;
  227. }
  228. const bindings: Partial<
  229. Pick<
  230. InstanceType<typeof LinearElementEditor>,
  231. "startBindingElement" | "endBindingElement"
  232. >
  233. > = {};
  234. if (isDragging && selectedPointsIndices) {
  235. for (const selectedPoint of selectedPointsIndices) {
  236. if (
  237. selectedPoint === 0 ||
  238. selectedPoint === element.points.length - 1
  239. ) {
  240. if (isPathALoop(element.points, appState.zoom.value)) {
  241. LinearElementEditor.movePoints(element, [
  242. {
  243. index: selectedPoint,
  244. point:
  245. selectedPoint === 0
  246. ? element.points[element.points.length - 1]
  247. : element.points[0],
  248. },
  249. ]);
  250. }
  251. const bindingElement = isBindingEnabled(appState)
  252. ? getHoveredElementForBinding(
  253. tupleToCoors(
  254. LinearElementEditor.getPointAtIndexGlobalCoordinates(
  255. element,
  256. selectedPoint!,
  257. ),
  258. ),
  259. Scene.getScene(element)!,
  260. )
  261. : null;
  262. bindings[
  263. selectedPoint === 0 ? "startBindingElement" : "endBindingElement"
  264. ] = bindingElement;
  265. }
  266. }
  267. }
  268. return {
  269. ...editingLinearElement,
  270. ...bindings,
  271. // if clicking without previously dragging a point(s), and not holding
  272. // shift, deselect all points except the one clicked. If holding shift,
  273. // toggle the point.
  274. selectedPointsIndices:
  275. isDragging || event.shiftKey
  276. ? !isDragging &&
  277. event.shiftKey &&
  278. pointerDownState.prevSelectedPointsIndices?.includes(
  279. pointerDownState.lastClickedPoint,
  280. )
  281. ? selectedPointsIndices &&
  282. selectedPointsIndices.filter(
  283. (pointIndex) =>
  284. pointIndex !== pointerDownState.lastClickedPoint,
  285. )
  286. : selectedPointsIndices
  287. : selectedPointsIndices?.includes(pointerDownState.lastClickedPoint)
  288. ? [pointerDownState.lastClickedPoint]
  289. : selectedPointsIndices,
  290. isDragging: false,
  291. pointerOffset: { x: 0, y: 0 },
  292. };
  293. }
  294. static handlePointerDown(
  295. event: React.PointerEvent<HTMLCanvasElement>,
  296. appState: AppState,
  297. setState: React.Component<any, AppState>["setState"],
  298. history: History,
  299. scenePointer: { x: number; y: number },
  300. ): {
  301. didAddPoint: boolean;
  302. hitElement: NonDeleted<ExcalidrawElement> | null;
  303. } {
  304. const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
  305. didAddPoint: false,
  306. hitElement: null,
  307. };
  308. if (!appState.editingLinearElement) {
  309. return ret;
  310. }
  311. const { elementId } = appState.editingLinearElement;
  312. const element = LinearElementEditor.getElement(elementId);
  313. if (!element) {
  314. return ret;
  315. }
  316. if (event.altKey) {
  317. if (appState.editingLinearElement.lastUncommittedPoint == null) {
  318. mutateElement(element, {
  319. points: [
  320. ...element.points,
  321. LinearElementEditor.createPointAt(
  322. element,
  323. scenePointer.x,
  324. scenePointer.y,
  325. appState.gridSize,
  326. ),
  327. ],
  328. });
  329. }
  330. history.resumeRecording();
  331. setState({
  332. editingLinearElement: {
  333. ...appState.editingLinearElement,
  334. pointerDownState: {
  335. prevSelectedPointsIndices:
  336. appState.editingLinearElement.selectedPointsIndices,
  337. lastClickedPoint: -1,
  338. },
  339. selectedPointsIndices: [element.points.length - 1],
  340. lastUncommittedPoint: null,
  341. endBindingElement: getHoveredElementForBinding(
  342. scenePointer,
  343. Scene.getScene(element)!,
  344. ),
  345. },
  346. });
  347. ret.didAddPoint = true;
  348. return ret;
  349. }
  350. const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor(
  351. element,
  352. appState.zoom,
  353. scenePointer.x,
  354. scenePointer.y,
  355. );
  356. // if we clicked on a point, set the element as hitElement otherwise
  357. // it would get deselected if the point is outside the hitbox area
  358. if (clickedPointIndex > -1) {
  359. ret.hitElement = element;
  360. } else {
  361. // You might be wandering why we are storing the binding elements on
  362. // LinearElementEditor and passing them in, instead of calculating them
  363. // from the end points of the `linearElement` - this is to allow disabling
  364. // binding (which needs to happen at the point the user finishes moving
  365. // the point).
  366. const { startBindingElement, endBindingElement } =
  367. appState.editingLinearElement;
  368. if (isBindingEnabled(appState) && isBindingElement(element)) {
  369. bindOrUnbindLinearElement(
  370. element,
  371. startBindingElement,
  372. endBindingElement,
  373. );
  374. }
  375. }
  376. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  377. const cx = (x1 + x2) / 2;
  378. const cy = (y1 + y2) / 2;
  379. const targetPoint =
  380. clickedPointIndex > -1 &&
  381. rotate(
  382. element.x + element.points[clickedPointIndex][0],
  383. element.y + element.points[clickedPointIndex][1],
  384. cx,
  385. cy,
  386. element.angle,
  387. );
  388. const nextSelectedPointsIndices =
  389. clickedPointIndex > -1 || event.shiftKey
  390. ? event.shiftKey ||
  391. appState.editingLinearElement.selectedPointsIndices?.includes(
  392. clickedPointIndex,
  393. )
  394. ? normalizeSelectedPoints([
  395. ...(appState.editingLinearElement.selectedPointsIndices || []),
  396. clickedPointIndex,
  397. ])
  398. : [clickedPointIndex]
  399. : null;
  400. setState({
  401. editingLinearElement: {
  402. ...appState.editingLinearElement,
  403. pointerDownState: {
  404. prevSelectedPointsIndices:
  405. appState.editingLinearElement.selectedPointsIndices,
  406. lastClickedPoint: clickedPointIndex,
  407. },
  408. selectedPointsIndices: nextSelectedPointsIndices,
  409. pointerOffset: targetPoint
  410. ? {
  411. x: scenePointer.x - targetPoint[0],
  412. y: scenePointer.y - targetPoint[1],
  413. }
  414. : { x: 0, y: 0 },
  415. },
  416. });
  417. return ret;
  418. }
  419. static handlePointerMove(
  420. event: React.PointerEvent<HTMLCanvasElement>,
  421. scenePointerX: number,
  422. scenePointerY: number,
  423. editingLinearElement: LinearElementEditor,
  424. gridSize: number | null,
  425. ): LinearElementEditor {
  426. const { elementId, lastUncommittedPoint } = editingLinearElement;
  427. const element = LinearElementEditor.getElement(elementId);
  428. if (!element) {
  429. return editingLinearElement;
  430. }
  431. const { points } = element;
  432. const lastPoint = points[points.length - 1];
  433. if (!event.altKey) {
  434. if (lastPoint === lastUncommittedPoint) {
  435. LinearElementEditor.deletePoints(element, [points.length - 1]);
  436. }
  437. return { ...editingLinearElement, lastUncommittedPoint: null };
  438. }
  439. const newPoint = LinearElementEditor.createPointAt(
  440. element,
  441. scenePointerX - editingLinearElement.pointerOffset.x,
  442. scenePointerY - editingLinearElement.pointerOffset.y,
  443. gridSize,
  444. );
  445. if (lastPoint === lastUncommittedPoint) {
  446. LinearElementEditor.movePoints(element, [
  447. {
  448. index: element.points.length - 1,
  449. point: newPoint,
  450. },
  451. ]);
  452. } else {
  453. LinearElementEditor.addPoints(element, [{ point: newPoint }]);
  454. }
  455. return {
  456. ...editingLinearElement,
  457. lastUncommittedPoint: element.points[element.points.length - 1],
  458. };
  459. }
  460. /** scene coords */
  461. static getPointGlobalCoordinates(
  462. element: NonDeleted<ExcalidrawLinearElement>,
  463. point: Point,
  464. ) {
  465. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  466. const cx = (x1 + x2) / 2;
  467. const cy = (y1 + y2) / 2;
  468. let { x, y } = element;
  469. [x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle);
  470. return [x, y] as const;
  471. }
  472. /** scene coords */
  473. static getPointsGlobalCoordinates(
  474. element: NonDeleted<ExcalidrawLinearElement>,
  475. ) {
  476. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  477. const cx = (x1 + x2) / 2;
  478. const cy = (y1 + y2) / 2;
  479. return element.points.map((point) => {
  480. let { x, y } = element;
  481. [x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle);
  482. return [x, y];
  483. });
  484. }
  485. static getPointAtIndexGlobalCoordinates(
  486. element: NonDeleted<ExcalidrawLinearElement>,
  487. indexMaybeFromEnd: number, // -1 for last element
  488. ): Point {
  489. const index =
  490. indexMaybeFromEnd < 0
  491. ? element.points.length + indexMaybeFromEnd
  492. : indexMaybeFromEnd;
  493. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  494. const cx = (x1 + x2) / 2;
  495. const cy = (y1 + y2) / 2;
  496. const point = element.points[index];
  497. const { x, y } = element;
  498. return rotate(x + point[0], y + point[1], cx, cy, element.angle);
  499. }
  500. static pointFromAbsoluteCoords(
  501. element: NonDeleted<ExcalidrawLinearElement>,
  502. absoluteCoords: Point,
  503. ): Point {
  504. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  505. const cx = (x1 + x2) / 2;
  506. const cy = (y1 + y2) / 2;
  507. const [x, y] = rotate(
  508. absoluteCoords[0],
  509. absoluteCoords[1],
  510. cx,
  511. cy,
  512. -element.angle,
  513. );
  514. return [x - element.x, y - element.y];
  515. }
  516. static getPointIndexUnderCursor(
  517. element: NonDeleted<ExcalidrawLinearElement>,
  518. zoom: AppState["zoom"],
  519. x: number,
  520. y: number,
  521. ) {
  522. const pointHandles = this.getPointsGlobalCoordinates(element);
  523. let idx = pointHandles.length;
  524. // loop from right to left because points on the right are rendered over
  525. // points on the left, thus should take precedence when clicking, if they
  526. // overlap
  527. while (--idx > -1) {
  528. const point = pointHandles[idx];
  529. if (
  530. distance2d(x, y, point[0], point[1]) * zoom.value <
  531. // +1px to account for outline stroke
  532. this.POINT_HANDLE_SIZE / 2 + 1
  533. ) {
  534. return idx;
  535. }
  536. }
  537. return -1;
  538. }
  539. static createPointAt(
  540. element: NonDeleted<ExcalidrawLinearElement>,
  541. scenePointerX: number,
  542. scenePointerY: number,
  543. gridSize: number | null,
  544. ): Point {
  545. const pointerOnGrid = getGridPoint(scenePointerX, scenePointerY, gridSize);
  546. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  547. const cx = (x1 + x2) / 2;
  548. const cy = (y1 + y2) / 2;
  549. const [rotatedX, rotatedY] = rotate(
  550. pointerOnGrid[0],
  551. pointerOnGrid[1],
  552. cx,
  553. cy,
  554. -element.angle,
  555. );
  556. return [rotatedX - element.x, rotatedY - element.y];
  557. }
  558. /**
  559. * Normalizes line points so that the start point is at [0,0]. This is
  560. * expected in various parts of the codebase. Also returns new x/y to account
  561. * for the potential normalization.
  562. */
  563. static getNormalizedPoints(element: ExcalidrawLinearElement) {
  564. const { points } = element;
  565. const offsetX = points[0][0];
  566. const offsetY = points[0][1];
  567. return {
  568. points: points.map((point, _idx) => {
  569. return [point[0] - offsetX, point[1] - offsetY] as const;
  570. }),
  571. x: element.x + offsetX,
  572. y: element.y + offsetY,
  573. };
  574. }
  575. // element-mutating methods
  576. // ---------------------------------------------------------------------------
  577. static normalizePoints(element: NonDeleted<ExcalidrawLinearElement>) {
  578. mutateElement(element, LinearElementEditor.getNormalizedPoints(element));
  579. }
  580. static duplicateSelectedPoints(appState: AppState) {
  581. if (!appState.editingLinearElement) {
  582. return false;
  583. }
  584. const { selectedPointsIndices, elementId } = appState.editingLinearElement;
  585. const element = LinearElementEditor.getElement(elementId);
  586. if (!element || selectedPointsIndices === null) {
  587. return false;
  588. }
  589. const { points } = element;
  590. const nextSelectedIndices: number[] = [];
  591. let pointAddedToEnd = false;
  592. let indexCursor = -1;
  593. const nextPoints = points.reduce((acc: Point[], point, index) => {
  594. ++indexCursor;
  595. acc.push(point);
  596. const isSelected = selectedPointsIndices.includes(index);
  597. if (isSelected) {
  598. const nextPoint = points[index + 1];
  599. if (!nextPoint) {
  600. pointAddedToEnd = true;
  601. }
  602. acc.push(
  603. nextPoint
  604. ? [(point[0] + nextPoint[0]) / 2, (point[1] + nextPoint[1]) / 2]
  605. : [point[0], point[1]],
  606. );
  607. nextSelectedIndices.push(indexCursor + 1);
  608. ++indexCursor;
  609. }
  610. return acc;
  611. }, []);
  612. mutateElement(element, { points: nextPoints });
  613. // temp hack to ensure the line doesn't move when adding point to the end,
  614. // potentially expanding the bounding box
  615. if (pointAddedToEnd) {
  616. const lastPoint = element.points[element.points.length - 1];
  617. LinearElementEditor.movePoints(element, [
  618. {
  619. index: element.points.length - 1,
  620. point: [lastPoint[0] + 30, lastPoint[1] + 30],
  621. },
  622. ]);
  623. }
  624. return {
  625. appState: {
  626. ...appState,
  627. editingLinearElement: {
  628. ...appState.editingLinearElement,
  629. selectedPointsIndices: nextSelectedIndices,
  630. },
  631. },
  632. };
  633. }
  634. static deletePoints(
  635. element: NonDeleted<ExcalidrawLinearElement>,
  636. pointIndices: readonly number[],
  637. ) {
  638. let offsetX = 0;
  639. let offsetY = 0;
  640. const isDeletingOriginPoint = pointIndices.includes(0);
  641. // if deleting first point, make the next to be [0,0] and recalculate
  642. // positions of the rest with respect to it
  643. if (isDeletingOriginPoint) {
  644. const firstNonDeletedPoint = element.points.find((point, idx) => {
  645. return !pointIndices.includes(idx);
  646. });
  647. if (firstNonDeletedPoint) {
  648. offsetX = firstNonDeletedPoint[0];
  649. offsetY = firstNonDeletedPoint[1];
  650. }
  651. }
  652. const nextPoints = element.points.reduce((acc: Point[], point, idx) => {
  653. if (!pointIndices.includes(idx)) {
  654. acc.push(
  655. !acc.length ? [0, 0] : [point[0] - offsetX, point[1] - offsetY],
  656. );
  657. }
  658. return acc;
  659. }, []);
  660. LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
  661. }
  662. static addPoints(
  663. element: NonDeleted<ExcalidrawLinearElement>,
  664. targetPoints: { point: Point }[],
  665. ) {
  666. const offsetX = 0;
  667. const offsetY = 0;
  668. const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
  669. LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
  670. }
  671. static movePoints(
  672. element: NonDeleted<ExcalidrawLinearElement>,
  673. targetPoints: { index: number; point: Point; isDragging?: boolean }[],
  674. otherUpdates?: { startBinding?: PointBinding; endBinding?: PointBinding },
  675. ) {
  676. const { points } = element;
  677. // in case we're moving start point, instead of modifying its position
  678. // which would break the invariant of it being at [0,0], we move
  679. // all the other points in the opposite direction by delta to
  680. // offset it. We do the same with actual element.x/y position, so
  681. // this hacks are completely transparent to the user.
  682. let offsetX = 0;
  683. let offsetY = 0;
  684. const selectedOriginPoint = targetPoints.find(({ index }) => index === 0);
  685. if (selectedOriginPoint) {
  686. offsetX =
  687. selectedOriginPoint.point[0] - points[selectedOriginPoint.index][0];
  688. offsetY =
  689. selectedOriginPoint.point[1] - points[selectedOriginPoint.index][1];
  690. }
  691. const nextPoints = points.map((point, idx) => {
  692. const selectedPointData = targetPoints.find((p) => p.index === idx);
  693. if (selectedPointData) {
  694. if (selectedOriginPoint) {
  695. return point;
  696. }
  697. const deltaX =
  698. selectedPointData.point[0] - points[selectedPointData.index][0];
  699. const deltaY =
  700. selectedPointData.point[1] - points[selectedPointData.index][1];
  701. return [point[0] + deltaX, point[1] + deltaY] as const;
  702. }
  703. return offsetX || offsetY
  704. ? ([point[0] - offsetX, point[1] - offsetY] as const)
  705. : point;
  706. });
  707. LinearElementEditor._updatePoints(
  708. element,
  709. nextPoints,
  710. offsetX,
  711. offsetY,
  712. otherUpdates,
  713. );
  714. }
  715. private static _updatePoints(
  716. element: NonDeleted<ExcalidrawLinearElement>,
  717. nextPoints: readonly Point[],
  718. offsetX: number,
  719. offsetY: number,
  720. otherUpdates?: { startBinding?: PointBinding; endBinding?: PointBinding },
  721. ) {
  722. const nextCoords = getElementPointsCoords(
  723. element,
  724. nextPoints,
  725. element.strokeSharpness || "round",
  726. );
  727. const prevCoords = getElementPointsCoords(
  728. element,
  729. element.points,
  730. element.strokeSharpness || "round",
  731. );
  732. const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2;
  733. const nextCenterY = (nextCoords[1] + nextCoords[3]) / 2;
  734. const prevCenterX = (prevCoords[0] + prevCoords[2]) / 2;
  735. const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2;
  736. const dX = prevCenterX - nextCenterX;
  737. const dY = prevCenterY - nextCenterY;
  738. const rotated = rotate(offsetX, offsetY, dX, dY, element.angle);
  739. mutateElement(element, {
  740. ...otherUpdates,
  741. points: nextPoints,
  742. x: element.x + rotated[0],
  743. y: element.y + rotated[1],
  744. });
  745. }
  746. }
  747. const normalizeSelectedPoints = (
  748. points: (number | null)[],
  749. ): number[] | null => {
  750. let nextPoints = [
  751. ...new Set(points.filter((p) => p !== null && p !== -1)),
  752. ] as number[];
  753. nextPoints = nextPoints.sort((a, b) => a - b);
  754. return nextPoints.length ? nextPoints : null;
  755. };