linearElementEditor.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  1. import {
  2. NonDeleted,
  3. ExcalidrawLinearElement,
  4. ExcalidrawElement,
  5. } from "./types";
  6. import { distance2d, rotate, isPathALoop } from "../math";
  7. import { getElementAbsoluteCoords } from ".";
  8. import { getElementPointsCoords } from "./bounds";
  9. import { Point, AppState } from "../types";
  10. import { mutateElement } from "./mutateElement";
  11. import { SceneHistory } from "../history";
  12. import { globalSceneState } from "../scene";
  13. export class LinearElementEditor {
  14. public elementId: ExcalidrawElement["id"];
  15. public activePointIndex: number | null;
  16. public draggingElementPointIndex: number | null;
  17. public lastUncommittedPoint: Point | null;
  18. constructor(element: NonDeleted<ExcalidrawLinearElement>) {
  19. LinearElementEditor.normalizePoints(element);
  20. this.elementId = element.id;
  21. this.activePointIndex = null;
  22. this.lastUncommittedPoint = null;
  23. this.draggingElementPointIndex = null;
  24. }
  25. // ---------------------------------------------------------------------------
  26. // static methods
  27. // ---------------------------------------------------------------------------
  28. static POINT_HANDLE_SIZE = 20;
  29. static getElement(id: ExcalidrawElement["id"]) {
  30. const element = globalSceneState.getNonDeletedElement(id);
  31. if (element) {
  32. return element as NonDeleted<ExcalidrawLinearElement>;
  33. }
  34. return null;
  35. }
  36. /** @returns whether point was dragged */
  37. static handlePointDragging(
  38. appState: AppState,
  39. setState: React.Component<any, AppState>["setState"],
  40. scenePointerX: number,
  41. scenePointerY: number,
  42. lastX: number,
  43. lastY: number,
  44. ): boolean {
  45. if (!appState.editingLinearElement) {
  46. return false;
  47. }
  48. const { editingLinearElement } = appState;
  49. let { draggingElementPointIndex, elementId } = editingLinearElement;
  50. const element = LinearElementEditor.getElement(elementId);
  51. if (!element) {
  52. return false;
  53. }
  54. const clickedPointIndex =
  55. draggingElementPointIndex ??
  56. LinearElementEditor.getPointIndexUnderCursor(
  57. element,
  58. appState.zoom,
  59. scenePointerX,
  60. scenePointerY,
  61. );
  62. draggingElementPointIndex = draggingElementPointIndex ?? clickedPointIndex;
  63. if (draggingElementPointIndex > -1) {
  64. if (
  65. editingLinearElement.draggingElementPointIndex !==
  66. draggingElementPointIndex ||
  67. editingLinearElement.activePointIndex !== clickedPointIndex
  68. ) {
  69. setState({
  70. editingLinearElement: {
  71. ...editingLinearElement,
  72. draggingElementPointIndex,
  73. activePointIndex: clickedPointIndex,
  74. },
  75. });
  76. }
  77. const [deltaX, deltaY] = rotate(
  78. scenePointerX - lastX,
  79. scenePointerY - lastY,
  80. 0,
  81. 0,
  82. -element.angle,
  83. );
  84. const targetPoint = element.points[clickedPointIndex];
  85. LinearElementEditor.movePoint(element, clickedPointIndex, [
  86. targetPoint[0] + deltaX,
  87. targetPoint[1] + deltaY,
  88. ]);
  89. return true;
  90. }
  91. return false;
  92. }
  93. static handlePointerUp(
  94. editingLinearElement: LinearElementEditor,
  95. ): LinearElementEditor {
  96. const { elementId, draggingElementPointIndex } = editingLinearElement;
  97. const element = LinearElementEditor.getElement(elementId);
  98. if (!element) {
  99. return editingLinearElement;
  100. }
  101. if (
  102. draggingElementPointIndex !== null &&
  103. (draggingElementPointIndex === 0 ||
  104. draggingElementPointIndex === element.points.length - 1) &&
  105. isPathALoop(element.points)
  106. ) {
  107. LinearElementEditor.movePoint(
  108. element,
  109. draggingElementPointIndex,
  110. draggingElementPointIndex === 0
  111. ? element.points[element.points.length - 1]
  112. : element.points[0],
  113. );
  114. }
  115. if (draggingElementPointIndex !== null) {
  116. return {
  117. ...editingLinearElement,
  118. draggingElementPointIndex: null,
  119. };
  120. }
  121. return editingLinearElement;
  122. }
  123. static handlePointerDown(
  124. event: React.PointerEvent<HTMLCanvasElement>,
  125. appState: AppState,
  126. setState: React.Component<any, AppState>["setState"],
  127. history: SceneHistory,
  128. scenePointerX: number,
  129. scenePointerY: number,
  130. ): {
  131. didAddPoint: boolean;
  132. hitElement: ExcalidrawElement | null;
  133. } {
  134. const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
  135. didAddPoint: false,
  136. hitElement: null,
  137. };
  138. if (!appState.editingLinearElement) {
  139. return ret;
  140. }
  141. const { elementId } = appState.editingLinearElement;
  142. const element = LinearElementEditor.getElement(elementId);
  143. if (!element) {
  144. return ret;
  145. }
  146. if (event.altKey) {
  147. if (!appState.editingLinearElement.lastUncommittedPoint) {
  148. mutateElement(element, {
  149. points: [
  150. ...element.points,
  151. LinearElementEditor.createPointAt(
  152. element,
  153. scenePointerX,
  154. scenePointerY,
  155. ),
  156. ],
  157. });
  158. }
  159. history.resumeRecording();
  160. setState({
  161. editingLinearElement: {
  162. ...appState.editingLinearElement,
  163. activePointIndex: element.points.length - 1,
  164. lastUncommittedPoint: null,
  165. },
  166. });
  167. ret.didAddPoint = true;
  168. return ret;
  169. }
  170. const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor(
  171. element,
  172. appState.zoom,
  173. scenePointerX,
  174. scenePointerY,
  175. );
  176. // if we clicked on a point, set the element as hitElement otherwise
  177. // it would get deselected if the point is outside the hitbox area
  178. if (clickedPointIndex > -1) {
  179. ret.hitElement = element;
  180. }
  181. setState({
  182. editingLinearElement: {
  183. ...appState.editingLinearElement,
  184. activePointIndex: clickedPointIndex > -1 ? clickedPointIndex : null,
  185. },
  186. });
  187. return ret;
  188. }
  189. static handlePointerMove(
  190. event: React.PointerEvent<HTMLCanvasElement>,
  191. scenePointerX: number,
  192. scenePointerY: number,
  193. editingLinearElement: LinearElementEditor,
  194. ): LinearElementEditor {
  195. const { elementId, lastUncommittedPoint } = editingLinearElement;
  196. const element = LinearElementEditor.getElement(elementId);
  197. if (!element) {
  198. return editingLinearElement;
  199. }
  200. const { points } = element;
  201. const lastPoint = points[points.length - 1];
  202. if (!event.altKey) {
  203. if (lastPoint === lastUncommittedPoint) {
  204. LinearElementEditor.movePoint(element, points.length - 1, "delete");
  205. }
  206. return editingLinearElement;
  207. }
  208. const newPoint = LinearElementEditor.createPointAt(
  209. element,
  210. scenePointerX,
  211. scenePointerY,
  212. );
  213. if (lastPoint === lastUncommittedPoint) {
  214. LinearElementEditor.movePoint(
  215. element,
  216. element.points.length - 1,
  217. newPoint,
  218. );
  219. } else {
  220. LinearElementEditor.movePoint(element, "new", newPoint);
  221. }
  222. return {
  223. ...editingLinearElement,
  224. lastUncommittedPoint: element.points[element.points.length - 1],
  225. };
  226. }
  227. static getPointsGlobalCoordinates(
  228. element: NonDeleted<ExcalidrawLinearElement>,
  229. ) {
  230. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  231. const cx = (x1 + x2) / 2;
  232. const cy = (y1 + y2) / 2;
  233. return element.points.map((point) => {
  234. let { x, y } = element;
  235. [x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle);
  236. return [x, y];
  237. });
  238. }
  239. static getPointIndexUnderCursor(
  240. element: NonDeleted<ExcalidrawLinearElement>,
  241. zoom: AppState["zoom"],
  242. x: number,
  243. y: number,
  244. ) {
  245. const pointHandles = this.getPointsGlobalCoordinates(element);
  246. let idx = pointHandles.length;
  247. // loop from right to left because points on the right are rendered over
  248. // points on the left, thus should take precedence when clicking, if they
  249. // overlap
  250. while (--idx > -1) {
  251. const point = pointHandles[idx];
  252. if (
  253. distance2d(x, y, point[0], point[1]) * zoom <
  254. // +1px to account for outline stroke
  255. this.POINT_HANDLE_SIZE / 2 + 1
  256. ) {
  257. return idx;
  258. }
  259. }
  260. return -1;
  261. }
  262. static createPointAt(
  263. element: NonDeleted<ExcalidrawLinearElement>,
  264. scenePointerX: number,
  265. scenePointerY: number,
  266. ): Point {
  267. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  268. const cx = (x1 + x2) / 2;
  269. const cy = (y1 + y2) / 2;
  270. const [rotatedX, rotatedY] = rotate(
  271. scenePointerX,
  272. scenePointerY,
  273. cx,
  274. cy,
  275. -element.angle,
  276. );
  277. return [rotatedX - element.x, rotatedY - element.y];
  278. }
  279. // element-mutating methods
  280. // ---------------------------------------------------------------------------
  281. /**
  282. * Normalizes line points so that the start point is at [0,0]. This is
  283. * expected in various parts of the codebase.
  284. */
  285. static normalizePoints(element: NonDeleted<ExcalidrawLinearElement>) {
  286. const { points } = element;
  287. const offsetX = points[0][0];
  288. const offsetY = points[0][1];
  289. mutateElement(element, {
  290. points: points.map((point, _idx) => {
  291. return [point[0] - offsetX, point[1] - offsetY] as const;
  292. }),
  293. x: element.x + offsetX,
  294. y: element.y + offsetY,
  295. });
  296. }
  297. static movePoint(
  298. element: NonDeleted<ExcalidrawLinearElement>,
  299. pointIndex: number | "new",
  300. targetPosition: Point | "delete",
  301. ) {
  302. const { points } = element;
  303. // in case we're moving start point, instead of modifying its position
  304. // which would break the invariant of it being at [0,0], we move
  305. // all the other points in the opposite direction by delta to
  306. // offset it. We do the same with actual element.x/y position, so
  307. // this hacks are completely transparent to the user.
  308. let offsetX = 0;
  309. let offsetY = 0;
  310. let nextPoints: (readonly [number, number])[];
  311. if (targetPosition === "delete") {
  312. // remove point
  313. if (pointIndex === "new") {
  314. throw new Error("invalid args in movePoint");
  315. }
  316. nextPoints = points.slice();
  317. nextPoints.splice(pointIndex, 1);
  318. if (pointIndex === 0) {
  319. // if deleting first point, make the next to be [0,0] and recalculate
  320. // positions of the rest with respect to it
  321. offsetX = nextPoints[0][0];
  322. offsetY = nextPoints[0][1];
  323. nextPoints = nextPoints.map((point, idx) => {
  324. if (idx === 0) {
  325. return [0, 0];
  326. }
  327. return [point[0] - offsetX, point[1] - offsetY];
  328. });
  329. }
  330. } else if (pointIndex === "new") {
  331. nextPoints = [...points, targetPosition];
  332. } else {
  333. const deltaX = targetPosition[0] - points[pointIndex][0];
  334. const deltaY = targetPosition[1] - points[pointIndex][1];
  335. nextPoints = points.map((point, idx) => {
  336. if (idx === pointIndex) {
  337. if (idx === 0) {
  338. offsetX = deltaX;
  339. offsetY = deltaY;
  340. return point;
  341. }
  342. offsetX = 0;
  343. offsetY = 0;
  344. return [point[0] + deltaX, point[1] + deltaY] as const;
  345. }
  346. return offsetX || offsetY
  347. ? ([point[0] - offsetX, point[1] - offsetY] as const)
  348. : point;
  349. });
  350. }
  351. const nextCoords = getElementPointsCoords(element, nextPoints);
  352. const prevCoords = getElementPointsCoords(element, points);
  353. const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2;
  354. const nextCenterY = (nextCoords[1] + nextCoords[3]) / 2;
  355. const prevCenterX = (prevCoords[0] + prevCoords[2]) / 2;
  356. const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2;
  357. const dX = prevCenterX - nextCenterX;
  358. const dY = prevCenterY - nextCenterY;
  359. const rotated = rotate(offsetX, offsetY, dX, dY, element.angle);
  360. mutateElement(element, {
  361. points: nextPoints,
  362. x: element.x + rotated[0],
  363. y: element.y + rotated[1],
  364. });
  365. }
  366. }