snapping.ts 33 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361
  1. import {
  2. Bounds,
  3. getCommonBounds,
  4. getDraggedElementsBounds,
  5. getElementAbsoluteCoords,
  6. } from "./element/bounds";
  7. import { MaybeTransformHandleType } from "./element/transformHandles";
  8. import { isBoundToContainer, isFrameElement } from "./element/typeChecks";
  9. import {
  10. ExcalidrawElement,
  11. NonDeletedExcalidrawElement,
  12. } from "./element/types";
  13. import { getMaximumGroups } from "./groups";
  14. import { KEYS } from "./keys";
  15. import { rangeIntersection, rangesOverlap, rotatePoint } from "./math";
  16. import { getVisibleAndNonSelectedElements } from "./scene/selection";
  17. import { AppState, KeyboardModifiersObject, Point } from "./types";
  18. const SNAP_DISTANCE = 8;
  19. // do not comput more gaps per axis than this limit
  20. // TODO increase or remove once we optimize
  21. const VISIBLE_GAPS_LIMIT_PER_AXIS = 99999;
  22. // snap distance with zoom value taken into consideration
  23. export const getSnapDistance = (zoomValue: number) => {
  24. return SNAP_DISTANCE / zoomValue;
  25. };
  26. type Vector2D = {
  27. x: number;
  28. y: number;
  29. };
  30. type PointPair = [Point, Point];
  31. export type PointSnap = {
  32. type: "point";
  33. points: PointPair;
  34. offset: number;
  35. };
  36. export type Gap = {
  37. // start side ↓ length
  38. // ┌───────────┐◄───────────────►
  39. // │ │-----------------┌───────────┐
  40. // │ start │ ↑ │ │
  41. // │ element │ overlap │ end │
  42. // │ │ ↓ │ element │
  43. // └───────────┘-----------------│ │
  44. // └───────────┘
  45. // ↑ end side
  46. startBounds: Bounds;
  47. endBounds: Bounds;
  48. startSide: [Point, Point];
  49. endSide: [Point, Point];
  50. overlap: [number, number];
  51. length: number;
  52. };
  53. export type GapSnap = {
  54. type: "gap";
  55. direction:
  56. | "center_horizontal"
  57. | "center_vertical"
  58. | "side_left"
  59. | "side_right"
  60. | "side_top"
  61. | "side_bottom";
  62. gap: Gap;
  63. offset: number;
  64. };
  65. export type GapSnaps = GapSnap[];
  66. export type Snap = GapSnap | PointSnap;
  67. export type Snaps = Snap[];
  68. export type PointSnapLine = {
  69. type: "points";
  70. points: Point[];
  71. };
  72. export type PointerSnapLine = {
  73. type: "pointer";
  74. points: PointPair;
  75. direction: "horizontal" | "vertical";
  76. };
  77. export type GapSnapLine = {
  78. type: "gap";
  79. direction: "horizontal" | "vertical";
  80. points: PointPair;
  81. };
  82. export type SnapLine = PointSnapLine | GapSnapLine | PointerSnapLine;
  83. // -----------------------------------------------------------------------------
  84. export class SnapCache {
  85. private static referenceSnapPoints: Point[] | null = null;
  86. private static visibleGaps: {
  87. verticalGaps: Gap[];
  88. horizontalGaps: Gap[];
  89. } | null = null;
  90. public static setReferenceSnapPoints = (snapPoints: Point[] | null) => {
  91. SnapCache.referenceSnapPoints = snapPoints;
  92. };
  93. public static getReferenceSnapPoints = () => {
  94. return SnapCache.referenceSnapPoints;
  95. };
  96. public static setVisibleGaps = (
  97. gaps: {
  98. verticalGaps: Gap[];
  99. horizontalGaps: Gap[];
  100. } | null,
  101. ) => {
  102. SnapCache.visibleGaps = gaps;
  103. };
  104. public static getVisibleGaps = () => {
  105. return SnapCache.visibleGaps;
  106. };
  107. public static destroy = () => {
  108. SnapCache.referenceSnapPoints = null;
  109. SnapCache.visibleGaps = null;
  110. };
  111. }
  112. // -----------------------------------------------------------------------------
  113. export const isSnappingEnabled = ({
  114. event,
  115. appState,
  116. selectedElements,
  117. }: {
  118. appState: AppState;
  119. event: KeyboardModifiersObject;
  120. selectedElements: NonDeletedExcalidrawElement[];
  121. }) => {
  122. if (event) {
  123. return (
  124. (appState.objectsSnapModeEnabled && !event[KEYS.CTRL_OR_CMD]) ||
  125. (!appState.objectsSnapModeEnabled &&
  126. event[KEYS.CTRL_OR_CMD] &&
  127. appState.gridSize === null)
  128. );
  129. }
  130. // do not suggest snaps for an arrow to give way to binding
  131. if (selectedElements.length === 1 && selectedElements[0].type === "arrow") {
  132. return false;
  133. }
  134. return appState.objectsSnapModeEnabled;
  135. };
  136. export const areRoughlyEqual = (a: number, b: number, precision = 0.01) => {
  137. return Math.abs(a - b) <= precision;
  138. };
  139. export const getElementsCorners = (
  140. elements: ExcalidrawElement[],
  141. {
  142. omitCenter,
  143. boundingBoxCorners,
  144. dragOffset,
  145. }: {
  146. omitCenter?: boolean;
  147. boundingBoxCorners?: boolean;
  148. dragOffset?: Vector2D;
  149. } = {
  150. omitCenter: false,
  151. boundingBoxCorners: false,
  152. },
  153. ): Point[] => {
  154. let result: Point[] = [];
  155. if (elements.length === 1) {
  156. const element = elements[0];
  157. let [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
  158. if (dragOffset) {
  159. x1 += dragOffset.x;
  160. x2 += dragOffset.x;
  161. cx += dragOffset.x;
  162. y1 += dragOffset.y;
  163. y2 += dragOffset.y;
  164. cy += dragOffset.y;
  165. }
  166. const halfWidth = (x2 - x1) / 2;
  167. const halfHeight = (y2 - y1) / 2;
  168. if (
  169. (element.type === "diamond" || element.type === "ellipse") &&
  170. !boundingBoxCorners
  171. ) {
  172. const leftMid = rotatePoint(
  173. [x1, y1 + halfHeight],
  174. [cx, cy],
  175. element.angle,
  176. );
  177. const topMid = rotatePoint([x1 + halfWidth, y1], [cx, cy], element.angle);
  178. const rightMid = rotatePoint(
  179. [x2, y1 + halfHeight],
  180. [cx, cy],
  181. element.angle,
  182. );
  183. const bottomMid = rotatePoint(
  184. [x1 + halfWidth, y2],
  185. [cx, cy],
  186. element.angle,
  187. );
  188. const center: Point = [cx, cy];
  189. result = omitCenter
  190. ? [leftMid, topMid, rightMid, bottomMid]
  191. : [leftMid, topMid, rightMid, bottomMid, center];
  192. } else {
  193. const topLeft = rotatePoint([x1, y1], [cx, cy], element.angle);
  194. const topRight = rotatePoint([x2, y1], [cx, cy], element.angle);
  195. const bottomLeft = rotatePoint([x1, y2], [cx, cy], element.angle);
  196. const bottomRight = rotatePoint([x2, y2], [cx, cy], element.angle);
  197. const center: Point = [cx, cy];
  198. result = omitCenter
  199. ? [topLeft, topRight, bottomLeft, bottomRight]
  200. : [topLeft, topRight, bottomLeft, bottomRight, center];
  201. }
  202. } else if (elements.length > 1) {
  203. const [minX, minY, maxX, maxY] = getDraggedElementsBounds(
  204. elements,
  205. dragOffset ?? { x: 0, y: 0 },
  206. );
  207. const width = maxX - minX;
  208. const height = maxY - minY;
  209. const topLeft: Point = [minX, minY];
  210. const topRight: Point = [maxX, minY];
  211. const bottomLeft: Point = [minX, maxY];
  212. const bottomRight: Point = [maxX, maxY];
  213. const center: Point = [minX + width / 2, minY + height / 2];
  214. result = omitCenter
  215. ? [topLeft, topRight, bottomLeft, bottomRight]
  216. : [topLeft, topRight, bottomLeft, bottomRight, center];
  217. }
  218. return result.map((point) => [round(point[0]), round(point[1])] as Point);
  219. };
  220. const getReferenceElements = (
  221. elements: readonly NonDeletedExcalidrawElement[],
  222. selectedElements: NonDeletedExcalidrawElement[],
  223. appState: AppState,
  224. ) => {
  225. const selectedFrames = selectedElements
  226. .filter((element) => isFrameElement(element))
  227. .map((frame) => frame.id);
  228. return getVisibleAndNonSelectedElements(
  229. elements,
  230. selectedElements,
  231. appState,
  232. ).filter(
  233. (element) => !(element.frameId && selectedFrames.includes(element.frameId)),
  234. );
  235. };
  236. export const getVisibleGaps = (
  237. elements: readonly NonDeletedExcalidrawElement[],
  238. selectedElements: ExcalidrawElement[],
  239. appState: AppState,
  240. ) => {
  241. const referenceElements: ExcalidrawElement[] = getReferenceElements(
  242. elements,
  243. selectedElements,
  244. appState,
  245. );
  246. const referenceBounds = getMaximumGroups(referenceElements)
  247. .filter(
  248. (elementsGroup) =>
  249. !(elementsGroup.length === 1 && isBoundToContainer(elementsGroup[0])),
  250. )
  251. .map(
  252. (group) =>
  253. getCommonBounds(group).map((bound) =>
  254. round(bound),
  255. ) as unknown as Bounds,
  256. );
  257. const horizontallySorted = referenceBounds.sort((a, b) => a[0] - b[0]);
  258. const horizontalGaps: Gap[] = [];
  259. let c = 0;
  260. horizontal: for (let i = 0; i < horizontallySorted.length; i++) {
  261. const startBounds = horizontallySorted[i];
  262. for (let j = i + 1; j < horizontallySorted.length; j++) {
  263. if (++c > VISIBLE_GAPS_LIMIT_PER_AXIS) {
  264. break horizontal;
  265. }
  266. const endBounds = horizontallySorted[j];
  267. const [, startMinY, startMaxX, startMaxY] = startBounds;
  268. const [endMinX, endMinY, , endMaxY] = endBounds;
  269. if (
  270. startMaxX < endMinX &&
  271. rangesOverlap([startMinY, startMaxY], [endMinY, endMaxY])
  272. ) {
  273. horizontalGaps.push({
  274. startBounds,
  275. endBounds,
  276. startSide: [
  277. [startMaxX, startMinY],
  278. [startMaxX, startMaxY],
  279. ],
  280. endSide: [
  281. [endMinX, endMinY],
  282. [endMinX, endMaxY],
  283. ],
  284. length: endMinX - startMaxX,
  285. overlap: rangeIntersection(
  286. [startMinY, startMaxY],
  287. [endMinY, endMaxY],
  288. )!,
  289. });
  290. }
  291. }
  292. }
  293. const verticallySorted = referenceBounds.sort((a, b) => a[1] - b[1]);
  294. const verticalGaps: Gap[] = [];
  295. c = 0;
  296. vertical: for (let i = 0; i < verticallySorted.length; i++) {
  297. const startBounds = verticallySorted[i];
  298. for (let j = i + 1; j < verticallySorted.length; j++) {
  299. if (++c > VISIBLE_GAPS_LIMIT_PER_AXIS) {
  300. break vertical;
  301. }
  302. const endBounds = verticallySorted[j];
  303. const [startMinX, , startMaxX, startMaxY] = startBounds;
  304. const [endMinX, endMinY, endMaxX] = endBounds;
  305. if (
  306. startMaxY < endMinY &&
  307. rangesOverlap([startMinX, startMaxX], [endMinX, endMaxX])
  308. ) {
  309. verticalGaps.push({
  310. startBounds,
  311. endBounds,
  312. startSide: [
  313. [startMinX, startMaxY],
  314. [startMaxX, startMaxY],
  315. ],
  316. endSide: [
  317. [endMinX, endMinY],
  318. [endMaxX, endMinY],
  319. ],
  320. length: endMinY - startMaxY,
  321. overlap: rangeIntersection(
  322. [startMinX, startMaxX],
  323. [endMinX, endMaxX],
  324. )!,
  325. });
  326. }
  327. }
  328. }
  329. return {
  330. horizontalGaps,
  331. verticalGaps,
  332. };
  333. };
  334. const getGapSnaps = (
  335. selectedElements: ExcalidrawElement[],
  336. dragOffset: Vector2D,
  337. appState: AppState,
  338. event: KeyboardModifiersObject,
  339. nearestSnapsX: Snaps,
  340. nearestSnapsY: Snaps,
  341. minOffset: Vector2D,
  342. ) => {
  343. if (!isSnappingEnabled({ appState, event, selectedElements })) {
  344. return [];
  345. }
  346. if (selectedElements.length === 0) {
  347. return [];
  348. }
  349. const visibleGaps = SnapCache.getVisibleGaps();
  350. if (visibleGaps) {
  351. const { horizontalGaps, verticalGaps } = visibleGaps;
  352. const [minX, minY, maxX, maxY] = getDraggedElementsBounds(
  353. selectedElements,
  354. dragOffset,
  355. ).map((bound) => round(bound));
  356. const centerX = (minX + maxX) / 2;
  357. const centerY = (minY + maxY) / 2;
  358. for (const gap of horizontalGaps) {
  359. if (!rangesOverlap([minY, maxY], gap.overlap)) {
  360. continue;
  361. }
  362. // center gap
  363. const gapMidX = gap.startSide[0][0] + gap.length / 2;
  364. const centerOffset = round(gapMidX - centerX);
  365. const gapIsLargerThanSelection = gap.length > maxX - minX;
  366. if (gapIsLargerThanSelection && Math.abs(centerOffset) <= minOffset.x) {
  367. if (Math.abs(centerOffset) < minOffset.x) {
  368. nearestSnapsX.length = 0;
  369. }
  370. minOffset.x = Math.abs(centerOffset);
  371. const snap: GapSnap = {
  372. type: "gap",
  373. direction: "center_horizontal",
  374. gap,
  375. offset: centerOffset,
  376. };
  377. nearestSnapsX.push(snap);
  378. continue;
  379. }
  380. // side gap, from the right
  381. const [, , endMaxX] = gap.endBounds;
  382. const distanceToEndElementX = minX - endMaxX;
  383. const sideOffsetRight = round(gap.length - distanceToEndElementX);
  384. if (Math.abs(sideOffsetRight) <= minOffset.x) {
  385. if (Math.abs(sideOffsetRight) < minOffset.x) {
  386. nearestSnapsX.length = 0;
  387. }
  388. minOffset.x = Math.abs(sideOffsetRight);
  389. const snap: GapSnap = {
  390. type: "gap",
  391. direction: "side_right",
  392. gap,
  393. offset: sideOffsetRight,
  394. };
  395. nearestSnapsX.push(snap);
  396. continue;
  397. }
  398. // side gap, from the left
  399. const [startMinX, , ,] = gap.startBounds;
  400. const distanceToStartElementX = startMinX - maxX;
  401. const sideOffsetLeft = round(distanceToStartElementX - gap.length);
  402. if (Math.abs(sideOffsetLeft) <= minOffset.x) {
  403. if (Math.abs(sideOffsetLeft) < minOffset.x) {
  404. nearestSnapsX.length = 0;
  405. }
  406. minOffset.x = Math.abs(sideOffsetLeft);
  407. const snap: GapSnap = {
  408. type: "gap",
  409. direction: "side_left",
  410. gap,
  411. offset: sideOffsetLeft,
  412. };
  413. nearestSnapsX.push(snap);
  414. continue;
  415. }
  416. }
  417. for (const gap of verticalGaps) {
  418. if (!rangesOverlap([minX, maxX], gap.overlap)) {
  419. continue;
  420. }
  421. // center gap
  422. const gapMidY = gap.startSide[0][1] + gap.length / 2;
  423. const centerOffset = round(gapMidY - centerY);
  424. const gapIsLargerThanSelection = gap.length > maxY - minY;
  425. if (gapIsLargerThanSelection && Math.abs(centerOffset) <= minOffset.y) {
  426. if (Math.abs(centerOffset) < minOffset.y) {
  427. nearestSnapsY.length = 0;
  428. }
  429. minOffset.y = Math.abs(centerOffset);
  430. const snap: GapSnap = {
  431. type: "gap",
  432. direction: "center_vertical",
  433. gap,
  434. offset: centerOffset,
  435. };
  436. nearestSnapsY.push(snap);
  437. continue;
  438. }
  439. // side gap, from the top
  440. const [, startMinY, ,] = gap.startBounds;
  441. const distanceToStartElementY = startMinY - maxY;
  442. const sideOffsetTop = round(distanceToStartElementY - gap.length);
  443. if (Math.abs(sideOffsetTop) <= minOffset.y) {
  444. if (Math.abs(sideOffsetTop) < minOffset.y) {
  445. nearestSnapsY.length = 0;
  446. }
  447. minOffset.y = Math.abs(sideOffsetTop);
  448. const snap: GapSnap = {
  449. type: "gap",
  450. direction: "side_top",
  451. gap,
  452. offset: sideOffsetTop,
  453. };
  454. nearestSnapsY.push(snap);
  455. continue;
  456. }
  457. // side gap, from the bottom
  458. const [, , , endMaxY] = gap.endBounds;
  459. const distanceToEndElementY = round(minY - endMaxY);
  460. const sideOffsetBottom = gap.length - distanceToEndElementY;
  461. if (Math.abs(sideOffsetBottom) <= minOffset.y) {
  462. if (Math.abs(sideOffsetBottom) < minOffset.y) {
  463. nearestSnapsY.length = 0;
  464. }
  465. minOffset.y = Math.abs(sideOffsetBottom);
  466. const snap: GapSnap = {
  467. type: "gap",
  468. direction: "side_bottom",
  469. gap,
  470. offset: sideOffsetBottom,
  471. };
  472. nearestSnapsY.push(snap);
  473. continue;
  474. }
  475. }
  476. }
  477. };
  478. export const getReferenceSnapPoints = (
  479. elements: readonly NonDeletedExcalidrawElement[],
  480. selectedElements: ExcalidrawElement[],
  481. appState: AppState,
  482. ) => {
  483. const referenceElements = getReferenceElements(
  484. elements,
  485. selectedElements,
  486. appState,
  487. );
  488. return getMaximumGroups(referenceElements)
  489. .filter(
  490. (elementsGroup) =>
  491. !(elementsGroup.length === 1 && isBoundToContainer(elementsGroup[0])),
  492. )
  493. .flatMap((elementGroup) => getElementsCorners(elementGroup));
  494. };
  495. const getPointSnaps = (
  496. selectedElements: ExcalidrawElement[],
  497. selectionSnapPoints: Point[],
  498. appState: AppState,
  499. event: KeyboardModifiersObject,
  500. nearestSnapsX: Snaps,
  501. nearestSnapsY: Snaps,
  502. minOffset: Vector2D,
  503. ) => {
  504. if (
  505. !isSnappingEnabled({ appState, event, selectedElements }) ||
  506. (selectedElements.length === 0 && selectionSnapPoints.length === 0)
  507. ) {
  508. return [];
  509. }
  510. const referenceSnapPoints = SnapCache.getReferenceSnapPoints();
  511. if (referenceSnapPoints) {
  512. for (const thisSnapPoint of selectionSnapPoints) {
  513. for (const otherSnapPoint of referenceSnapPoints) {
  514. const offsetX = otherSnapPoint[0] - thisSnapPoint[0];
  515. const offsetY = otherSnapPoint[1] - thisSnapPoint[1];
  516. if (Math.abs(offsetX) <= minOffset.x) {
  517. if (Math.abs(offsetX) < minOffset.x) {
  518. nearestSnapsX.length = 0;
  519. }
  520. nearestSnapsX.push({
  521. type: "point",
  522. points: [thisSnapPoint, otherSnapPoint],
  523. offset: offsetX,
  524. });
  525. minOffset.x = Math.abs(offsetX);
  526. }
  527. if (Math.abs(offsetY) <= minOffset.y) {
  528. if (Math.abs(offsetY) < minOffset.y) {
  529. nearestSnapsY.length = 0;
  530. }
  531. nearestSnapsY.push({
  532. type: "point",
  533. points: [thisSnapPoint, otherSnapPoint],
  534. offset: offsetY,
  535. });
  536. minOffset.y = Math.abs(offsetY);
  537. }
  538. }
  539. }
  540. }
  541. };
  542. export const snapDraggedElements = (
  543. selectedElements: ExcalidrawElement[],
  544. dragOffset: Vector2D,
  545. appState: AppState,
  546. event: KeyboardModifiersObject,
  547. ) => {
  548. if (
  549. !isSnappingEnabled({ appState, event, selectedElements }) ||
  550. selectedElements.length === 0
  551. ) {
  552. return {
  553. snapOffset: {
  554. x: 0,
  555. y: 0,
  556. },
  557. snapLines: [],
  558. };
  559. }
  560. dragOffset.x = round(dragOffset.x);
  561. dragOffset.y = round(dragOffset.y);
  562. const nearestSnapsX: Snaps = [];
  563. const nearestSnapsY: Snaps = [];
  564. const snapDistance = getSnapDistance(appState.zoom.value);
  565. const minOffset = {
  566. x: snapDistance,
  567. y: snapDistance,
  568. };
  569. const selectionPoints = getElementsCorners(selectedElements, {
  570. dragOffset,
  571. });
  572. // get the nearest horizontal and vertical point and gap snaps
  573. getPointSnaps(
  574. selectedElements,
  575. selectionPoints,
  576. appState,
  577. event,
  578. nearestSnapsX,
  579. nearestSnapsY,
  580. minOffset,
  581. );
  582. getGapSnaps(
  583. selectedElements,
  584. dragOffset,
  585. appState,
  586. event,
  587. nearestSnapsX,
  588. nearestSnapsY,
  589. minOffset,
  590. );
  591. // using the nearest snaps to figure out how
  592. // much the elements need to be offset to be snapped
  593. // to some reference elements
  594. const snapOffset = {
  595. x: nearestSnapsX[0]?.offset ?? 0,
  596. y: nearestSnapsY[0]?.offset ?? 0,
  597. };
  598. // once the elements are snapped
  599. // and moved to the snapped position
  600. // we want to use the element's snapped position
  601. // to update nearest snaps so that we can create
  602. // point and gap snap lines correctly without any shifting
  603. minOffset.x = 0;
  604. minOffset.y = 0;
  605. nearestSnapsX.length = 0;
  606. nearestSnapsY.length = 0;
  607. const newDragOffset = {
  608. x: round(dragOffset.x + snapOffset.x),
  609. y: round(dragOffset.y + snapOffset.y),
  610. };
  611. getPointSnaps(
  612. selectedElements,
  613. getElementsCorners(selectedElements, {
  614. dragOffset: newDragOffset,
  615. }),
  616. appState,
  617. event,
  618. nearestSnapsX,
  619. nearestSnapsY,
  620. minOffset,
  621. );
  622. getGapSnaps(
  623. selectedElements,
  624. newDragOffset,
  625. appState,
  626. event,
  627. nearestSnapsX,
  628. nearestSnapsY,
  629. minOffset,
  630. );
  631. const pointSnapLines = createPointSnapLines(nearestSnapsX, nearestSnapsY);
  632. const gapSnapLines = createGapSnapLines(
  633. selectedElements,
  634. newDragOffset,
  635. [...nearestSnapsX, ...nearestSnapsY].filter(
  636. (snap) => snap.type === "gap",
  637. ) as GapSnap[],
  638. );
  639. return {
  640. snapOffset,
  641. snapLines: [...pointSnapLines, ...gapSnapLines],
  642. };
  643. };
  644. const round = (x: number) => {
  645. const decimalPlaces = 6;
  646. return Math.round(x * 10 ** decimalPlaces) / 10 ** decimalPlaces;
  647. };
  648. const dedupePoints = (points: Point[]): Point[] => {
  649. const map = new Map<string, Point>();
  650. for (const point of points) {
  651. const key = point.join(",");
  652. if (!map.has(key)) {
  653. map.set(key, point);
  654. }
  655. }
  656. return Array.from(map.values());
  657. };
  658. const createPointSnapLines = (
  659. nearestSnapsX: Snaps,
  660. nearestSnapsY: Snaps,
  661. ): PointSnapLine[] => {
  662. const snapsX = {} as { [key: string]: Point[] };
  663. const snapsY = {} as { [key: string]: Point[] };
  664. if (nearestSnapsX.length > 0) {
  665. for (const snap of nearestSnapsX) {
  666. if (snap.type === "point") {
  667. // key = thisPoint.x
  668. const key = round(snap.points[0][0]);
  669. if (!snapsX[key]) {
  670. snapsX[key] = [];
  671. }
  672. snapsX[key].push(
  673. ...snap.points.map(
  674. (point) => [round(point[0]), round(point[1])] as Point,
  675. ),
  676. );
  677. }
  678. }
  679. }
  680. if (nearestSnapsY.length > 0) {
  681. for (const snap of nearestSnapsY) {
  682. if (snap.type === "point") {
  683. // key = thisPoint.y
  684. const key = round(snap.points[0][1]);
  685. if (!snapsY[key]) {
  686. snapsY[key] = [];
  687. }
  688. snapsY[key].push(
  689. ...snap.points.map(
  690. (point) => [round(point[0]), round(point[1])] as Point,
  691. ),
  692. );
  693. }
  694. }
  695. }
  696. return Object.entries(snapsX)
  697. .map(([key, points]) => {
  698. return {
  699. type: "points",
  700. points: dedupePoints(
  701. points
  702. .map((point) => {
  703. return [Number(key), point[1]] as Point;
  704. })
  705. .sort((a, b) => a[1] - b[1]),
  706. ),
  707. } as PointSnapLine;
  708. })
  709. .concat(
  710. Object.entries(snapsY).map(([key, points]) => {
  711. return {
  712. type: "points",
  713. points: dedupePoints(
  714. points
  715. .map((point) => {
  716. return [point[0], Number(key)] as Point;
  717. })
  718. .sort((a, b) => a[0] - b[0]),
  719. ),
  720. } as PointSnapLine;
  721. }),
  722. );
  723. };
  724. const dedupeGapSnapLines = (gapSnapLines: GapSnapLine[]) => {
  725. const map = new Map<string, GapSnapLine>();
  726. for (const gapSnapLine of gapSnapLines) {
  727. const key = gapSnapLine.points
  728. .flat()
  729. .map((point) => [round(point)])
  730. .join(",");
  731. if (!map.has(key)) {
  732. map.set(key, gapSnapLine);
  733. }
  734. }
  735. return Array.from(map.values());
  736. };
  737. const createGapSnapLines = (
  738. selectedElements: ExcalidrawElement[],
  739. dragOffset: Vector2D,
  740. gapSnaps: GapSnap[],
  741. ): GapSnapLine[] => {
  742. const [minX, minY, maxX, maxY] = getDraggedElementsBounds(
  743. selectedElements,
  744. dragOffset,
  745. );
  746. const gapSnapLines: GapSnapLine[] = [];
  747. for (const gapSnap of gapSnaps) {
  748. const [startMinX, startMinY, startMaxX, startMaxY] =
  749. gapSnap.gap.startBounds;
  750. const [endMinX, endMinY, endMaxX, endMaxY] = gapSnap.gap.endBounds;
  751. const verticalIntersection = rangeIntersection(
  752. [minY, maxY],
  753. gapSnap.gap.overlap,
  754. );
  755. const horizontalGapIntersection = rangeIntersection(
  756. [minX, maxX],
  757. gapSnap.gap.overlap,
  758. );
  759. switch (gapSnap.direction) {
  760. case "center_horizontal": {
  761. if (verticalIntersection) {
  762. const gapLineY =
  763. (verticalIntersection[0] + verticalIntersection[1]) / 2;
  764. gapSnapLines.push(
  765. {
  766. type: "gap",
  767. direction: "horizontal",
  768. points: [
  769. [gapSnap.gap.startSide[0][0], gapLineY],
  770. [minX, gapLineY],
  771. ],
  772. },
  773. {
  774. type: "gap",
  775. direction: "horizontal",
  776. points: [
  777. [maxX, gapLineY],
  778. [gapSnap.gap.endSide[0][0], gapLineY],
  779. ],
  780. },
  781. );
  782. }
  783. break;
  784. }
  785. case "center_vertical": {
  786. if (horizontalGapIntersection) {
  787. const gapLineX =
  788. (horizontalGapIntersection[0] + horizontalGapIntersection[1]) / 2;
  789. gapSnapLines.push(
  790. {
  791. type: "gap",
  792. direction: "vertical",
  793. points: [
  794. [gapLineX, gapSnap.gap.startSide[0][1]],
  795. [gapLineX, minY],
  796. ],
  797. },
  798. {
  799. type: "gap",
  800. direction: "vertical",
  801. points: [
  802. [gapLineX, maxY],
  803. [gapLineX, gapSnap.gap.endSide[0][1]],
  804. ],
  805. },
  806. );
  807. }
  808. break;
  809. }
  810. case "side_right": {
  811. if (verticalIntersection) {
  812. const gapLineY =
  813. (verticalIntersection[0] + verticalIntersection[1]) / 2;
  814. gapSnapLines.push(
  815. {
  816. type: "gap",
  817. direction: "horizontal",
  818. points: [
  819. [startMaxX, gapLineY],
  820. [endMinX, gapLineY],
  821. ],
  822. },
  823. {
  824. type: "gap",
  825. direction: "horizontal",
  826. points: [
  827. [endMaxX, gapLineY],
  828. [minX, gapLineY],
  829. ],
  830. },
  831. );
  832. }
  833. break;
  834. }
  835. case "side_left": {
  836. if (verticalIntersection) {
  837. const gapLineY =
  838. (verticalIntersection[0] + verticalIntersection[1]) / 2;
  839. gapSnapLines.push(
  840. {
  841. type: "gap",
  842. direction: "horizontal",
  843. points: [
  844. [maxX, gapLineY],
  845. [startMinX, gapLineY],
  846. ],
  847. },
  848. {
  849. type: "gap",
  850. direction: "horizontal",
  851. points: [
  852. [startMaxX, gapLineY],
  853. [endMinX, gapLineY],
  854. ],
  855. },
  856. );
  857. }
  858. break;
  859. }
  860. case "side_top": {
  861. if (horizontalGapIntersection) {
  862. const gapLineX =
  863. (horizontalGapIntersection[0] + horizontalGapIntersection[1]) / 2;
  864. gapSnapLines.push(
  865. {
  866. type: "gap",
  867. direction: "vertical",
  868. points: [
  869. [gapLineX, maxY],
  870. [gapLineX, startMinY],
  871. ],
  872. },
  873. {
  874. type: "gap",
  875. direction: "vertical",
  876. points: [
  877. [gapLineX, startMaxY],
  878. [gapLineX, endMinY],
  879. ],
  880. },
  881. );
  882. }
  883. break;
  884. }
  885. case "side_bottom": {
  886. if (horizontalGapIntersection) {
  887. const gapLineX =
  888. (horizontalGapIntersection[0] + horizontalGapIntersection[1]) / 2;
  889. gapSnapLines.push(
  890. {
  891. type: "gap",
  892. direction: "vertical",
  893. points: [
  894. [gapLineX, startMaxY],
  895. [gapLineX, endMinY],
  896. ],
  897. },
  898. {
  899. type: "gap",
  900. direction: "vertical",
  901. points: [
  902. [gapLineX, endMaxY],
  903. [gapLineX, minY],
  904. ],
  905. },
  906. );
  907. }
  908. break;
  909. }
  910. }
  911. }
  912. return dedupeGapSnapLines(
  913. gapSnapLines.map((gapSnapLine) => {
  914. return {
  915. ...gapSnapLine,
  916. points: gapSnapLine.points.map(
  917. (point) => [round(point[0]), round(point[1])] as Point,
  918. ) as PointPair,
  919. };
  920. }),
  921. );
  922. };
  923. export const snapResizingElements = (
  924. // use the latest elements to create snap lines
  925. selectedElements: ExcalidrawElement[],
  926. // while using the original elements to appy dragOffset to calculate snaps
  927. selectedOriginalElements: ExcalidrawElement[],
  928. appState: AppState,
  929. event: KeyboardModifiersObject,
  930. dragOffset: Vector2D,
  931. transformHandle: MaybeTransformHandleType,
  932. ) => {
  933. if (
  934. !isSnappingEnabled({ event, selectedElements, appState }) ||
  935. selectedElements.length === 0 ||
  936. (selectedElements.length === 1 &&
  937. !areRoughlyEqual(selectedElements[0].angle, 0))
  938. ) {
  939. return {
  940. snapOffset: { x: 0, y: 0 },
  941. snapLines: [],
  942. };
  943. }
  944. let [minX, minY, maxX, maxY] = getCommonBounds(selectedOriginalElements);
  945. if (transformHandle) {
  946. if (transformHandle.includes("e")) {
  947. maxX += dragOffset.x;
  948. } else if (transformHandle.includes("w")) {
  949. minX += dragOffset.x;
  950. }
  951. if (transformHandle.includes("n")) {
  952. minY += dragOffset.y;
  953. } else if (transformHandle.includes("s")) {
  954. maxY += dragOffset.y;
  955. }
  956. }
  957. const selectionSnapPoints: Point[] = [];
  958. if (transformHandle) {
  959. switch (transformHandle) {
  960. case "e": {
  961. selectionSnapPoints.push([maxX, minY], [maxX, maxY]);
  962. break;
  963. }
  964. case "w": {
  965. selectionSnapPoints.push([minX, minY], [minX, maxY]);
  966. break;
  967. }
  968. case "n": {
  969. selectionSnapPoints.push([minX, minY], [maxX, minY]);
  970. break;
  971. }
  972. case "s": {
  973. selectionSnapPoints.push([minX, maxY], [maxX, maxY]);
  974. break;
  975. }
  976. case "ne": {
  977. selectionSnapPoints.push([maxX, minY]);
  978. break;
  979. }
  980. case "nw": {
  981. selectionSnapPoints.push([minX, minY]);
  982. break;
  983. }
  984. case "se": {
  985. selectionSnapPoints.push([maxX, maxY]);
  986. break;
  987. }
  988. case "sw": {
  989. selectionSnapPoints.push([minX, maxY]);
  990. break;
  991. }
  992. }
  993. }
  994. const snapDistance = getSnapDistance(appState.zoom.value);
  995. const minOffset = {
  996. x: snapDistance,
  997. y: snapDistance,
  998. };
  999. const nearestSnapsX: Snaps = [];
  1000. const nearestSnapsY: Snaps = [];
  1001. getPointSnaps(
  1002. selectedOriginalElements,
  1003. selectionSnapPoints,
  1004. appState,
  1005. event,
  1006. nearestSnapsX,
  1007. nearestSnapsY,
  1008. minOffset,
  1009. );
  1010. const snapOffset = {
  1011. x: nearestSnapsX[0]?.offset ?? 0,
  1012. y: nearestSnapsY[0]?.offset ?? 0,
  1013. };
  1014. // again, once snap offset is calculated
  1015. // reset to recompute for creating snap lines to be rendered
  1016. minOffset.x = 0;
  1017. minOffset.y = 0;
  1018. nearestSnapsX.length = 0;
  1019. nearestSnapsY.length = 0;
  1020. const [x1, y1, x2, y2] = getCommonBounds(selectedElements).map((bound) =>
  1021. round(bound),
  1022. );
  1023. const corners: Point[] = [
  1024. [x1, y1],
  1025. [x1, y2],
  1026. [x2, y1],
  1027. [x2, y2],
  1028. ];
  1029. getPointSnaps(
  1030. selectedElements,
  1031. corners,
  1032. appState,
  1033. event,
  1034. nearestSnapsX,
  1035. nearestSnapsY,
  1036. minOffset,
  1037. );
  1038. const pointSnapLines = createPointSnapLines(nearestSnapsX, nearestSnapsY);
  1039. return {
  1040. snapOffset,
  1041. snapLines: pointSnapLines,
  1042. };
  1043. };
  1044. export const snapNewElement = (
  1045. draggingElement: ExcalidrawElement,
  1046. appState: AppState,
  1047. event: KeyboardModifiersObject,
  1048. origin: Vector2D,
  1049. dragOffset: Vector2D,
  1050. ) => {
  1051. if (
  1052. !isSnappingEnabled({ event, selectedElements: [draggingElement], appState })
  1053. ) {
  1054. return {
  1055. snapOffset: { x: 0, y: 0 },
  1056. snapLines: [],
  1057. };
  1058. }
  1059. const selectionSnapPoints: Point[] = [
  1060. [origin.x + dragOffset.x, origin.y + dragOffset.y],
  1061. ];
  1062. const snapDistance = getSnapDistance(appState.zoom.value);
  1063. const minOffset = {
  1064. x: snapDistance,
  1065. y: snapDistance,
  1066. };
  1067. const nearestSnapsX: Snaps = [];
  1068. const nearestSnapsY: Snaps = [];
  1069. getPointSnaps(
  1070. [draggingElement],
  1071. selectionSnapPoints,
  1072. appState,
  1073. event,
  1074. nearestSnapsX,
  1075. nearestSnapsY,
  1076. minOffset,
  1077. );
  1078. const snapOffset = {
  1079. x: nearestSnapsX[0]?.offset ?? 0,
  1080. y: nearestSnapsY[0]?.offset ?? 0,
  1081. };
  1082. minOffset.x = 0;
  1083. minOffset.y = 0;
  1084. nearestSnapsX.length = 0;
  1085. nearestSnapsY.length = 0;
  1086. const corners = getElementsCorners([draggingElement], {
  1087. boundingBoxCorners: true,
  1088. omitCenter: true,
  1089. });
  1090. getPointSnaps(
  1091. [draggingElement],
  1092. corners,
  1093. appState,
  1094. event,
  1095. nearestSnapsX,
  1096. nearestSnapsY,
  1097. minOffset,
  1098. );
  1099. const pointSnapLines = createPointSnapLines(nearestSnapsX, nearestSnapsY);
  1100. return {
  1101. snapOffset,
  1102. snapLines: pointSnapLines,
  1103. };
  1104. };
  1105. export const getSnapLinesAtPointer = (
  1106. elements: readonly ExcalidrawElement[],
  1107. appState: AppState,
  1108. pointer: Vector2D,
  1109. event: KeyboardModifiersObject,
  1110. ) => {
  1111. if (!isSnappingEnabled({ event, selectedElements: [], appState })) {
  1112. return {
  1113. originOffset: { x: 0, y: 0 },
  1114. snapLines: [],
  1115. };
  1116. }
  1117. const referenceElements = getVisibleAndNonSelectedElements(
  1118. elements,
  1119. [],
  1120. appState,
  1121. );
  1122. const snapDistance = getSnapDistance(appState.zoom.value);
  1123. const minOffset = {
  1124. x: snapDistance,
  1125. y: snapDistance,
  1126. };
  1127. const horizontalSnapLines: PointerSnapLine[] = [];
  1128. const verticalSnapLines: PointerSnapLine[] = [];
  1129. for (const referenceElement of referenceElements) {
  1130. const corners = getElementsCorners([referenceElement]);
  1131. for (const corner of corners) {
  1132. const offsetX = corner[0] - pointer.x;
  1133. if (Math.abs(offsetX) <= Math.abs(minOffset.x)) {
  1134. if (Math.abs(offsetX) < Math.abs(minOffset.x)) {
  1135. verticalSnapLines.length = 0;
  1136. }
  1137. verticalSnapLines.push({
  1138. type: "pointer",
  1139. points: [corner, [corner[0], pointer.y]],
  1140. direction: "vertical",
  1141. });
  1142. minOffset.x = offsetX;
  1143. }
  1144. const offsetY = corner[1] - pointer.y;
  1145. if (Math.abs(offsetY) <= Math.abs(minOffset.y)) {
  1146. if (Math.abs(offsetY) < Math.abs(minOffset.y)) {
  1147. horizontalSnapLines.length = 0;
  1148. }
  1149. horizontalSnapLines.push({
  1150. type: "pointer",
  1151. points: [corner, [pointer.x, corner[1]]],
  1152. direction: "horizontal",
  1153. });
  1154. minOffset.y = offsetY;
  1155. }
  1156. }
  1157. }
  1158. return {
  1159. originOffset: {
  1160. x:
  1161. verticalSnapLines.length > 0
  1162. ? verticalSnapLines[0].points[0][0] - pointer.x
  1163. : 0,
  1164. y:
  1165. horizontalSnapLines.length > 0
  1166. ? horizontalSnapLines[0].points[0][1] - pointer.y
  1167. : 0,
  1168. },
  1169. snapLines: [...verticalSnapLines, ...horizontalSnapLines],
  1170. };
  1171. };
  1172. export const isActiveToolNonLinearSnappable = (
  1173. activeToolType: AppState["activeTool"]["type"],
  1174. ) => {
  1175. return (
  1176. activeToolType === "rectangle" ||
  1177. activeToolType === "ellipse" ||
  1178. activeToolType === "diamond" ||
  1179. activeToolType === "frame" ||
  1180. activeToolType === "image"
  1181. );
  1182. };