bounds.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657
  1. import {
  2. ExcalidrawElement,
  3. ExcalidrawLinearElement,
  4. Arrowhead,
  5. ExcalidrawFreeDrawElement,
  6. NonDeleted,
  7. ExcalidrawTextElementWithContainer,
  8. } from "./types";
  9. import { distance2d, rotate } from "../math";
  10. import rough from "roughjs/bin/rough";
  11. import { Drawable, Op } from "roughjs/bin/core";
  12. import { Point } from "../types";
  13. import {
  14. getShapeForElement,
  15. generateRoughOptions,
  16. } from "../renderer/renderElement";
  17. import {
  18. isArrowElement,
  19. isFreeDrawElement,
  20. isLinearElement,
  21. isTextElement,
  22. } from "./typeChecks";
  23. import { rescalePoints } from "../points";
  24. import { getBoundTextElement, getContainerElement } from "./textElement";
  25. import { LinearElementEditor } from "./linearElementEditor";
  26. import { Mutable } from "../utility-types";
  27. // x and y position of top left corner, x and y position of bottom right corner
  28. export type Bounds = readonly [number, number, number, number];
  29. type MaybeQuadraticSolution = [number | null, number | null] | false;
  30. // If the element is created from right to left, the width is going to be negative
  31. // This set of functions retrieves the absolute position of the 4 points.
  32. export const getElementAbsoluteCoords = (
  33. element: ExcalidrawElement,
  34. includeBoundText: boolean = false,
  35. ): [number, number, number, number, number, number] => {
  36. if (isFreeDrawElement(element)) {
  37. return getFreeDrawElementAbsoluteCoords(element);
  38. } else if (isLinearElement(element)) {
  39. return LinearElementEditor.getElementAbsoluteCoords(
  40. element,
  41. includeBoundText,
  42. );
  43. } else if (isTextElement(element)) {
  44. const container = getContainerElement(element);
  45. if (isArrowElement(container)) {
  46. const coords = LinearElementEditor.getBoundTextElementPosition(
  47. container,
  48. element as ExcalidrawTextElementWithContainer,
  49. );
  50. return [
  51. coords.x,
  52. coords.y,
  53. coords.x + element.width,
  54. coords.y + element.height,
  55. coords.x + element.width / 2,
  56. coords.y + element.height / 2,
  57. ];
  58. }
  59. }
  60. return [
  61. element.x,
  62. element.y,
  63. element.x + element.width,
  64. element.y + element.height,
  65. element.x + element.width / 2,
  66. element.y + element.height / 2,
  67. ];
  68. };
  69. export const pointRelativeTo = (
  70. element: ExcalidrawElement,
  71. absoluteCoords: Point,
  72. ): Point => {
  73. return [absoluteCoords[0] - element.x, absoluteCoords[1] - element.y];
  74. };
  75. export const getDiamondPoints = (element: ExcalidrawElement) => {
  76. // Here we add +1 to avoid these numbers to be 0
  77. // otherwise rough.js will throw an error complaining about it
  78. const topX = Math.floor(element.width / 2) + 1;
  79. const topY = 0;
  80. const rightX = element.width;
  81. const rightY = Math.floor(element.height / 2) + 1;
  82. const bottomX = topX;
  83. const bottomY = element.height;
  84. const leftX = 0;
  85. const leftY = rightY;
  86. return [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY];
  87. };
  88. export const getCurvePathOps = (shape: Drawable): Op[] => {
  89. for (const set of shape.sets) {
  90. if (set.type === "path") {
  91. return set.ops;
  92. }
  93. }
  94. return shape.sets[0].ops;
  95. };
  96. // reference: https://eliot-jones.com/2019/12/cubic-bezier-curve-bounding-boxes
  97. const getBezierValueForT = (
  98. t: number,
  99. p0: number,
  100. p1: number,
  101. p2: number,
  102. p3: number,
  103. ) => {
  104. const oneMinusT = 1 - t;
  105. return (
  106. Math.pow(oneMinusT, 3) * p0 +
  107. 3 * Math.pow(oneMinusT, 2) * t * p1 +
  108. 3 * oneMinusT * Math.pow(t, 2) * p2 +
  109. Math.pow(t, 3) * p3
  110. );
  111. };
  112. const solveQuadratic = (
  113. p0: number,
  114. p1: number,
  115. p2: number,
  116. p3: number,
  117. ): MaybeQuadraticSolution => {
  118. const i = p1 - p0;
  119. const j = p2 - p1;
  120. const k = p3 - p2;
  121. const a = 3 * i - 6 * j + 3 * k;
  122. const b = 6 * j - 6 * i;
  123. const c = 3 * i;
  124. const sqrtPart = b * b - 4 * a * c;
  125. const hasSolution = sqrtPart >= 0;
  126. if (!hasSolution) {
  127. return false;
  128. }
  129. let s1 = null;
  130. let s2 = null;
  131. let t1 = Infinity;
  132. let t2 = Infinity;
  133. if (a === 0) {
  134. t1 = t2 = -c / b;
  135. } else {
  136. t1 = (-b + Math.sqrt(sqrtPart)) / (2 * a);
  137. t2 = (-b - Math.sqrt(sqrtPart)) / (2 * a);
  138. }
  139. if (t1 >= 0 && t1 <= 1) {
  140. s1 = getBezierValueForT(t1, p0, p1, p2, p3);
  141. }
  142. if (t2 >= 0 && t2 <= 1) {
  143. s2 = getBezierValueForT(t2, p0, p1, p2, p3);
  144. }
  145. return [s1, s2];
  146. };
  147. const getCubicBezierCurveBound = (
  148. p0: Point,
  149. p1: Point,
  150. p2: Point,
  151. p3: Point,
  152. ): Bounds => {
  153. const solX = solveQuadratic(p0[0], p1[0], p2[0], p3[0]);
  154. const solY = solveQuadratic(p0[1], p1[1], p2[1], p3[1]);
  155. let minX = Math.min(p0[0], p3[0]);
  156. let maxX = Math.max(p0[0], p3[0]);
  157. if (solX) {
  158. const xs = solX.filter((x) => x !== null) as number[];
  159. minX = Math.min(minX, ...xs);
  160. maxX = Math.max(maxX, ...xs);
  161. }
  162. let minY = Math.min(p0[1], p3[1]);
  163. let maxY = Math.max(p0[1], p3[1]);
  164. if (solY) {
  165. const ys = solY.filter((y) => y !== null) as number[];
  166. minY = Math.min(minY, ...ys);
  167. maxY = Math.max(maxY, ...ys);
  168. }
  169. return [minX, minY, maxX, maxY];
  170. };
  171. export const getMinMaxXYFromCurvePathOps = (
  172. ops: Op[],
  173. transformXY?: (x: number, y: number) => [number, number],
  174. ): [number, number, number, number] => {
  175. let currentP: Point = [0, 0];
  176. const { minX, minY, maxX, maxY } = ops.reduce(
  177. (limits, { op, data }) => {
  178. // There are only four operation types:
  179. // move, bcurveTo, lineTo, and curveTo
  180. if (op === "move") {
  181. // change starting point
  182. currentP = data as unknown as Point;
  183. // move operation does not draw anything; so, it always
  184. // returns false
  185. } else if (op === "bcurveTo") {
  186. const _p1 = [data[0], data[1]] as Point;
  187. const _p2 = [data[2], data[3]] as Point;
  188. const _p3 = [data[4], data[5]] as Point;
  189. const p1 = transformXY ? transformXY(..._p1) : _p1;
  190. const p2 = transformXY ? transformXY(..._p2) : _p2;
  191. const p3 = transformXY ? transformXY(..._p3) : _p3;
  192. const p0 = transformXY ? transformXY(...currentP) : currentP;
  193. currentP = _p3;
  194. const [minX, minY, maxX, maxY] = getCubicBezierCurveBound(
  195. p0,
  196. p1,
  197. p2,
  198. p3,
  199. );
  200. limits.minX = Math.min(limits.minX, minX);
  201. limits.minY = Math.min(limits.minY, minY);
  202. limits.maxX = Math.max(limits.maxX, maxX);
  203. limits.maxY = Math.max(limits.maxY, maxY);
  204. } else if (op === "lineTo") {
  205. // TODO: Implement this
  206. } else if (op === "qcurveTo") {
  207. // TODO: Implement this
  208. }
  209. return limits;
  210. },
  211. { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
  212. );
  213. return [minX, minY, maxX, maxY];
  214. };
  215. const getBoundsFromPoints = (
  216. points: ExcalidrawFreeDrawElement["points"],
  217. ): [number, number, number, number] => {
  218. let minX = Infinity;
  219. let minY = Infinity;
  220. let maxX = -Infinity;
  221. let maxY = -Infinity;
  222. for (const [x, y] of points) {
  223. minX = Math.min(minX, x);
  224. minY = Math.min(minY, y);
  225. maxX = Math.max(maxX, x);
  226. maxY = Math.max(maxY, y);
  227. }
  228. return [minX, minY, maxX, maxY];
  229. };
  230. const getFreeDrawElementAbsoluteCoords = (
  231. element: ExcalidrawFreeDrawElement,
  232. ): [number, number, number, number, number, number] => {
  233. const [minX, minY, maxX, maxY] = getBoundsFromPoints(element.points);
  234. const x1 = minX + element.x;
  235. const y1 = minY + element.y;
  236. const x2 = maxX + element.x;
  237. const y2 = maxY + element.y;
  238. return [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2];
  239. };
  240. export const getArrowheadPoints = (
  241. element: ExcalidrawLinearElement,
  242. shape: Drawable[],
  243. position: "start" | "end",
  244. arrowhead: Arrowhead,
  245. ) => {
  246. const ops = getCurvePathOps(shape[0]);
  247. if (ops.length < 1) {
  248. return null;
  249. }
  250. // The index of the bCurve operation to examine.
  251. const index = position === "start" ? 1 : ops.length - 1;
  252. const data = ops[index].data;
  253. const p3 = [data[4], data[5]] as Point;
  254. const p2 = [data[2], data[3]] as Point;
  255. const p1 = [data[0], data[1]] as Point;
  256. // We need to find p0 of the bezier curve.
  257. // It is typically the last point of the previous
  258. // curve; it can also be the position of moveTo operation.
  259. const prevOp = ops[index - 1];
  260. let p0: Point = [0, 0];
  261. if (prevOp.op === "move") {
  262. p0 = prevOp.data as unknown as Point;
  263. } else if (prevOp.op === "bcurveTo") {
  264. p0 = [prevOp.data[4], prevOp.data[5]];
  265. }
  266. // B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3
  267. const equation = (t: number, idx: number) =>
  268. Math.pow(1 - t, 3) * p3[idx] +
  269. 3 * t * Math.pow(1 - t, 2) * p2[idx] +
  270. 3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
  271. p0[idx] * Math.pow(t, 3);
  272. // Ee know the last point of the arrow (or the first, if start arrowhead).
  273. const [x2, y2] = position === "start" ? p0 : p3;
  274. // By using cubic bezier equation (B(t)) and the given parameters,
  275. // we calculate a point that is closer to the last point.
  276. // The value 0.3 is chosen arbitrarily and it works best for all
  277. // the tested cases.
  278. const [x1, y1] = [equation(0.3, 0), equation(0.3, 1)];
  279. // Find the normalized direction vector based on the
  280. // previously calculated points.
  281. const distance = Math.hypot(x2 - x1, y2 - y1);
  282. const nx = (x2 - x1) / distance;
  283. const ny = (y2 - y1) / distance;
  284. const size = {
  285. arrow: 30,
  286. bar: 15,
  287. dot: 15,
  288. triangle: 15,
  289. }[arrowhead]; // pixels (will differ for each arrowhead)
  290. let length = 0;
  291. if (arrowhead === "arrow") {
  292. // Length for -> arrows is based on the length of the last section
  293. const [cx, cy] = element.points[element.points.length - 1];
  294. const [px, py] =
  295. element.points.length > 1
  296. ? element.points[element.points.length - 2]
  297. : [0, 0];
  298. length = Math.hypot(cx - px, cy - py);
  299. } else {
  300. // Length for other arrowhead types is based on the total length of the line
  301. for (let i = 0; i < element.points.length; i++) {
  302. const [px, py] = element.points[i - 1] || [0, 0];
  303. const [cx, cy] = element.points[i];
  304. length += Math.hypot(cx - px, cy - py);
  305. }
  306. }
  307. // Scale down the arrowhead until we hit a certain size so that it doesn't look weird.
  308. // This value is selected by minimizing a minimum size with the last segment of the arrowhead
  309. const minSize = Math.min(size, length / 2);
  310. const xs = x2 - nx * minSize;
  311. const ys = y2 - ny * minSize;
  312. if (arrowhead === "dot") {
  313. const r = Math.hypot(ys - y2, xs - x2) + element.strokeWidth;
  314. return [x2, y2, r];
  315. }
  316. const angle = {
  317. arrow: 20,
  318. bar: 90,
  319. triangle: 25,
  320. }[arrowhead]; // degrees
  321. // Return points
  322. const [x3, y3] = rotate(xs, ys, x2, y2, (-angle * Math.PI) / 180);
  323. const [x4, y4] = rotate(xs, ys, x2, y2, (angle * Math.PI) / 180);
  324. return [x2, y2, x3, y3, x4, y4];
  325. };
  326. const generateLinearElementShape = (
  327. element: ExcalidrawLinearElement,
  328. ): Drawable => {
  329. const generator = rough.generator();
  330. const options = generateRoughOptions(element);
  331. const method = (() => {
  332. if (element.roundness) {
  333. return "curve";
  334. }
  335. if (options.fill) {
  336. return "polygon";
  337. }
  338. return "linearPath";
  339. })();
  340. return generator[method](element.points as Mutable<Point>[], options);
  341. };
  342. const getLinearElementRotatedBounds = (
  343. element: ExcalidrawLinearElement,
  344. cx: number,
  345. cy: number,
  346. ): [number, number, number, number] => {
  347. if (element.points.length < 2) {
  348. const [pointX, pointY] = element.points[0];
  349. const [x, y] = rotate(
  350. element.x + pointX,
  351. element.y + pointY,
  352. cx,
  353. cy,
  354. element.angle,
  355. );
  356. let coords: [number, number, number, number] = [x, y, x, y];
  357. const boundTextElement = getBoundTextElement(element);
  358. if (boundTextElement) {
  359. const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
  360. element,
  361. [x, y, x, y],
  362. boundTextElement,
  363. );
  364. coords = [
  365. coordsWithBoundText[0],
  366. coordsWithBoundText[1],
  367. coordsWithBoundText[2],
  368. coordsWithBoundText[3],
  369. ];
  370. }
  371. return coords;
  372. }
  373. // first element is always the curve
  374. const cachedShape = getShapeForElement(element)?.[0];
  375. const shape = cachedShape ?? generateLinearElementShape(element);
  376. const ops = getCurvePathOps(shape);
  377. const transformXY = (x: number, y: number) =>
  378. rotate(element.x + x, element.y + y, cx, cy, element.angle);
  379. const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
  380. let coords: [number, number, number, number] = [
  381. res[0],
  382. res[1],
  383. res[2],
  384. res[3],
  385. ];
  386. const boundTextElement = getBoundTextElement(element);
  387. if (boundTextElement) {
  388. const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
  389. element,
  390. coords,
  391. boundTextElement,
  392. );
  393. coords = [
  394. coordsWithBoundText[0],
  395. coordsWithBoundText[1],
  396. coordsWithBoundText[2],
  397. coordsWithBoundText[3],
  398. ];
  399. }
  400. return coords;
  401. };
  402. // We could cache this stuff
  403. export const getElementBounds = (
  404. element: ExcalidrawElement,
  405. ): [number, number, number, number] => {
  406. let bounds: [number, number, number, number];
  407. const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
  408. if (isFreeDrawElement(element)) {
  409. const [minX, minY, maxX, maxY] = getBoundsFromPoints(
  410. element.points.map(([x, y]) =>
  411. rotate(x, y, cx - element.x, cy - element.y, element.angle),
  412. ),
  413. );
  414. return [
  415. minX + element.x,
  416. minY + element.y,
  417. maxX + element.x,
  418. maxY + element.y,
  419. ];
  420. } else if (isLinearElement(element)) {
  421. bounds = getLinearElementRotatedBounds(element, cx, cy);
  422. } else if (element.type === "diamond") {
  423. const [x11, y11] = rotate(cx, y1, cx, cy, element.angle);
  424. const [x12, y12] = rotate(cx, y2, cx, cy, element.angle);
  425. const [x22, y22] = rotate(x1, cy, cx, cy, element.angle);
  426. const [x21, y21] = rotate(x2, cy, cx, cy, element.angle);
  427. const minX = Math.min(x11, x12, x22, x21);
  428. const minY = Math.min(y11, y12, y22, y21);
  429. const maxX = Math.max(x11, x12, x22, x21);
  430. const maxY = Math.max(y11, y12, y22, y21);
  431. bounds = [minX, minY, maxX, maxY];
  432. } else if (element.type === "ellipse") {
  433. const w = (x2 - x1) / 2;
  434. const h = (y2 - y1) / 2;
  435. const cos = Math.cos(element.angle);
  436. const sin = Math.sin(element.angle);
  437. const ww = Math.hypot(w * cos, h * sin);
  438. const hh = Math.hypot(h * cos, w * sin);
  439. bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
  440. } else {
  441. const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
  442. const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
  443. const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
  444. const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
  445. const minX = Math.min(x11, x12, x22, x21);
  446. const minY = Math.min(y11, y12, y22, y21);
  447. const maxX = Math.max(x11, x12, x22, x21);
  448. const maxY = Math.max(y11, y12, y22, y21);
  449. bounds = [minX, minY, maxX, maxY];
  450. }
  451. return bounds;
  452. };
  453. export const getCommonBounds = (
  454. elements: readonly ExcalidrawElement[],
  455. ): [number, number, number, number] => {
  456. if (!elements.length) {
  457. return [0, 0, 0, 0];
  458. }
  459. let minX = Infinity;
  460. let maxX = -Infinity;
  461. let minY = Infinity;
  462. let maxY = -Infinity;
  463. elements.forEach((element) => {
  464. const [x1, y1, x2, y2] = getElementBounds(element);
  465. minX = Math.min(minX, x1);
  466. minY = Math.min(minY, y1);
  467. maxX = Math.max(maxX, x2);
  468. maxY = Math.max(maxY, y2);
  469. });
  470. return [minX, minY, maxX, maxY];
  471. };
  472. export const getResizedElementAbsoluteCoords = (
  473. element: ExcalidrawElement,
  474. nextWidth: number,
  475. nextHeight: number,
  476. normalizePoints: boolean,
  477. ): [number, number, number, number] => {
  478. if (!(isLinearElement(element) || isFreeDrawElement(element))) {
  479. return [
  480. element.x,
  481. element.y,
  482. element.x + nextWidth,
  483. element.y + nextHeight,
  484. ];
  485. }
  486. const points = rescalePoints(
  487. 0,
  488. nextWidth,
  489. rescalePoints(1, nextHeight, element.points, normalizePoints),
  490. normalizePoints,
  491. );
  492. let bounds: [number, number, number, number];
  493. if (isFreeDrawElement(element)) {
  494. // Free Draw
  495. bounds = getBoundsFromPoints(points);
  496. } else {
  497. // Line
  498. const gen = rough.generator();
  499. const curve = !element.roundness
  500. ? gen.linearPath(
  501. points as [number, number][],
  502. generateRoughOptions(element),
  503. )
  504. : gen.curve(points as [number, number][], generateRoughOptions(element));
  505. const ops = getCurvePathOps(curve);
  506. bounds = getMinMaxXYFromCurvePathOps(ops);
  507. }
  508. const [minX, minY, maxX, maxY] = bounds;
  509. return [
  510. minX + element.x,
  511. minY + element.y,
  512. maxX + element.x,
  513. maxY + element.y,
  514. ];
  515. };
  516. export const getElementPointsCoords = (
  517. element: ExcalidrawLinearElement,
  518. points: readonly (readonly [number, number])[],
  519. ): [number, number, number, number] => {
  520. // This might be computationally heavey
  521. const gen = rough.generator();
  522. const curve =
  523. element.roundness == null
  524. ? gen.linearPath(
  525. points as [number, number][],
  526. generateRoughOptions(element),
  527. )
  528. : gen.curve(points as [number, number][], generateRoughOptions(element));
  529. const ops = getCurvePathOps(curve);
  530. const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
  531. return [
  532. minX + element.x,
  533. minY + element.y,
  534. maxX + element.x,
  535. maxY + element.y,
  536. ];
  537. };
  538. export const getClosestElementBounds = (
  539. elements: readonly ExcalidrawElement[],
  540. from: { x: number; y: number },
  541. ): [number, number, number, number] => {
  542. if (!elements.length) {
  543. return [0, 0, 0, 0];
  544. }
  545. let minDistance = Infinity;
  546. let closestElement = elements[0];
  547. elements.forEach((element) => {
  548. const [x1, y1, x2, y2] = getElementBounds(element);
  549. const distance = distance2d((x1 + x2) / 2, (y1 + y2) / 2, from.x, from.y);
  550. if (distance < minDistance) {
  551. minDistance = distance;
  552. closestElement = element;
  553. }
  554. });
  555. return getElementBounds(closestElement);
  556. };
  557. export interface Box {
  558. minX: number;
  559. minY: number;
  560. maxX: number;
  561. maxY: number;
  562. midX: number;
  563. midY: number;
  564. width: number;
  565. height: number;
  566. }
  567. export const getCommonBoundingBox = (
  568. elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
  569. ): Box => {
  570. const [minX, minY, maxX, maxY] = getCommonBounds(elements);
  571. return {
  572. minX,
  573. minY,
  574. maxX,
  575. maxY,
  576. width: maxX - minX,
  577. height: maxY - minY,
  578. midX: (minX + maxX) / 2,
  579. midY: (minY + maxY) / 2,
  580. };
  581. };