bounds.ts 16 KB

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