renderScene.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822
  1. import { RoughCanvas } from "roughjs/bin/canvas";
  2. import { RoughSVG } from "roughjs/bin/svg";
  3. import oc from "open-color";
  4. import { AppState, BinaryFiles, Zoom } from "../types";
  5. import {
  6. ExcalidrawElement,
  7. NonDeletedExcalidrawElement,
  8. ExcalidrawLinearElement,
  9. NonDeleted,
  10. GroupId,
  11. ExcalidrawBindableElement,
  12. } from "../element/types";
  13. import {
  14. getElementAbsoluteCoords,
  15. OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
  16. getTransformHandlesFromCoords,
  17. getTransformHandles,
  18. getElementBounds,
  19. getCommonBounds,
  20. } from "../element";
  21. import { roundRect } from "./roundRect";
  22. import { RenderConfig } from "../scene/types";
  23. import {
  24. getScrollBars,
  25. SCROLLBAR_COLOR,
  26. SCROLLBAR_WIDTH,
  27. } from "../scene/scrollbars";
  28. import { getSelectedElements } from "../scene/selection";
  29. import { renderElement, renderElementToSvg } from "./renderElement";
  30. import { getClientColors } from "../clients";
  31. import { LinearElementEditor } from "../element/linearElementEditor";
  32. import {
  33. isSelectedViaGroup,
  34. getSelectedGroupIds,
  35. getElementsInGroup,
  36. } from "../groups";
  37. import { maxBindingGap } from "../element/collision";
  38. import {
  39. SuggestedBinding,
  40. SuggestedPointBinding,
  41. isBindingEnabled,
  42. } from "../element/binding";
  43. import {
  44. TransformHandles,
  45. TransformHandleType,
  46. } from "../element/transformHandles";
  47. import { viewportCoordsToSceneCoords, supportsEmoji } from "../utils";
  48. import { UserIdleState } from "../types";
  49. import { THEME_FILTER } from "../constants";
  50. const hasEmojiSupport = supportsEmoji();
  51. const strokeRectWithRotation = (
  52. context: CanvasRenderingContext2D,
  53. x: number,
  54. y: number,
  55. width: number,
  56. height: number,
  57. cx: number,
  58. cy: number,
  59. angle: number,
  60. fill: boolean = false,
  61. ) => {
  62. context.save();
  63. context.translate(cx, cy);
  64. context.rotate(angle);
  65. if (fill) {
  66. context.fillRect(x - cx, y - cy, width, height);
  67. }
  68. context.strokeRect(x - cx, y - cy, width, height);
  69. context.restore();
  70. };
  71. const strokeDiamondWithRotation = (
  72. context: CanvasRenderingContext2D,
  73. width: number,
  74. height: number,
  75. cx: number,
  76. cy: number,
  77. angle: number,
  78. ) => {
  79. context.save();
  80. context.translate(cx, cy);
  81. context.rotate(angle);
  82. context.beginPath();
  83. context.moveTo(0, height / 2);
  84. context.lineTo(width / 2, 0);
  85. context.lineTo(0, -height / 2);
  86. context.lineTo(-width / 2, 0);
  87. context.closePath();
  88. context.stroke();
  89. context.restore();
  90. };
  91. const strokeEllipseWithRotation = (
  92. context: CanvasRenderingContext2D,
  93. width: number,
  94. height: number,
  95. cx: number,
  96. cy: number,
  97. angle: number,
  98. ) => {
  99. context.beginPath();
  100. context.ellipse(cx, cy, width / 2, height / 2, angle, 0, Math.PI * 2);
  101. context.stroke();
  102. };
  103. const fillCircle = (
  104. context: CanvasRenderingContext2D,
  105. cx: number,
  106. cy: number,
  107. radius: number,
  108. ) => {
  109. context.beginPath();
  110. context.arc(cx, cy, radius, 0, Math.PI * 2);
  111. context.fill();
  112. context.stroke();
  113. };
  114. const strokeGrid = (
  115. context: CanvasRenderingContext2D,
  116. gridSize: number,
  117. offsetX: number,
  118. offsetY: number,
  119. width: number,
  120. height: number,
  121. ) => {
  122. context.save();
  123. context.strokeStyle = "rgba(0,0,0,0.1)";
  124. context.beginPath();
  125. for (let x = offsetX; x < offsetX + width + gridSize * 2; x += gridSize) {
  126. context.moveTo(x, offsetY - gridSize);
  127. context.lineTo(x, offsetY + height + gridSize * 2);
  128. }
  129. for (let y = offsetY; y < offsetY + height + gridSize * 2; y += gridSize) {
  130. context.moveTo(offsetX - gridSize, y);
  131. context.lineTo(offsetX + width + gridSize * 2, y);
  132. }
  133. context.stroke();
  134. context.restore();
  135. };
  136. const renderLinearPointHandles = (
  137. context: CanvasRenderingContext2D,
  138. appState: AppState,
  139. renderConfig: RenderConfig,
  140. element: NonDeleted<ExcalidrawLinearElement>,
  141. ) => {
  142. context.save();
  143. context.translate(renderConfig.scrollX, renderConfig.scrollY);
  144. context.lineWidth = 1 / renderConfig.zoom.value;
  145. LinearElementEditor.getPointsGlobalCoordinates(element).forEach(
  146. (point, idx) => {
  147. context.strokeStyle = "red";
  148. context.setLineDash([]);
  149. context.fillStyle =
  150. appState.editingLinearElement?.activePointIndex === idx
  151. ? "rgba(255, 127, 127, 0.9)"
  152. : "rgba(255, 255, 255, 0.9)";
  153. const { POINT_HANDLE_SIZE } = LinearElementEditor;
  154. fillCircle(
  155. context,
  156. point[0],
  157. point[1],
  158. POINT_HANDLE_SIZE / 2 / renderConfig.zoom.value,
  159. );
  160. },
  161. );
  162. context.restore();
  163. };
  164. export const renderScene = (
  165. elements: readonly NonDeletedExcalidrawElement[],
  166. appState: AppState,
  167. selectionElement: NonDeletedExcalidrawElement | null,
  168. scale: number,
  169. rc: RoughCanvas,
  170. canvas: HTMLCanvasElement,
  171. renderConfig: RenderConfig,
  172. // extra options passed to the renderer
  173. ) => {
  174. if (canvas === null) {
  175. return { atLeastOneVisibleElement: false };
  176. }
  177. const {
  178. renderScrollbars = true,
  179. renderSelection = true,
  180. renderGrid = true,
  181. isExporting,
  182. } = renderConfig;
  183. const context = canvas.getContext("2d")!;
  184. context.setTransform(1, 0, 0, 1, 0, 0);
  185. context.save();
  186. context.scale(scale, scale);
  187. // When doing calculations based on canvas width we should used normalized one
  188. const normalizedCanvasWidth = canvas.width / scale;
  189. const normalizedCanvasHeight = canvas.height / scale;
  190. if (isExporting && renderConfig.theme === "dark") {
  191. context.filter = THEME_FILTER;
  192. }
  193. // Paint background
  194. if (typeof renderConfig.viewBackgroundColor === "string") {
  195. const hasTransparence =
  196. renderConfig.viewBackgroundColor === "transparent" ||
  197. renderConfig.viewBackgroundColor.length === 5 || // #RGBA
  198. renderConfig.viewBackgroundColor.length === 9 || // #RRGGBBA
  199. /(hsla|rgba)\(/.test(renderConfig.viewBackgroundColor);
  200. if (hasTransparence) {
  201. context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
  202. }
  203. context.save();
  204. context.fillStyle = renderConfig.viewBackgroundColor;
  205. context.fillRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
  206. context.restore();
  207. } else {
  208. context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
  209. }
  210. // Apply zoom
  211. const zoomTranslationX = renderConfig.zoom.translation.x;
  212. const zoomTranslationY = renderConfig.zoom.translation.y;
  213. context.save();
  214. context.translate(zoomTranslationX, zoomTranslationY);
  215. context.scale(renderConfig.zoom.value, renderConfig.zoom.value);
  216. // Grid
  217. if (renderGrid && appState.gridSize) {
  218. strokeGrid(
  219. context,
  220. appState.gridSize,
  221. -Math.ceil(
  222. zoomTranslationX / renderConfig.zoom.value / appState.gridSize,
  223. ) *
  224. appState.gridSize +
  225. (renderConfig.scrollX % appState.gridSize),
  226. -Math.ceil(
  227. zoomTranslationY / renderConfig.zoom.value / appState.gridSize,
  228. ) *
  229. appState.gridSize +
  230. (renderConfig.scrollY % appState.gridSize),
  231. normalizedCanvasWidth / renderConfig.zoom.value,
  232. normalizedCanvasHeight / renderConfig.zoom.value,
  233. );
  234. }
  235. // Paint visible elements
  236. const visibleElements = elements.filter((element) =>
  237. isVisibleElement(element, normalizedCanvasWidth, normalizedCanvasHeight, {
  238. zoom: renderConfig.zoom,
  239. offsetLeft: appState.offsetLeft,
  240. offsetTop: appState.offsetTop,
  241. scrollX: renderConfig.scrollX,
  242. scrollY: renderConfig.scrollY,
  243. }),
  244. );
  245. visibleElements.forEach((element) => {
  246. try {
  247. renderElement(element, rc, context, renderConfig);
  248. } catch (error: any) {
  249. console.error(error);
  250. }
  251. });
  252. if (appState.editingLinearElement) {
  253. const element = LinearElementEditor.getElement(
  254. appState.editingLinearElement.elementId,
  255. );
  256. if (element) {
  257. renderLinearPointHandles(context, appState, renderConfig, element);
  258. }
  259. }
  260. // Paint selection element
  261. if (selectionElement) {
  262. try {
  263. renderElement(selectionElement, rc, context, renderConfig);
  264. } catch (error: any) {
  265. console.error(error);
  266. }
  267. }
  268. if (isBindingEnabled(appState)) {
  269. appState.suggestedBindings
  270. .filter((binding) => binding != null)
  271. .forEach((suggestedBinding) => {
  272. renderBindingHighlight(context, renderConfig, suggestedBinding!);
  273. });
  274. }
  275. // Paint selected elements
  276. if (
  277. renderSelection &&
  278. !appState.multiElement &&
  279. !appState.editingLinearElement
  280. ) {
  281. const selections = elements.reduce((acc, element) => {
  282. const selectionColors = [];
  283. // local user
  284. if (
  285. appState.selectedElementIds[element.id] &&
  286. !isSelectedViaGroup(appState, element)
  287. ) {
  288. selectionColors.push(oc.black);
  289. }
  290. // remote users
  291. if (renderConfig.remoteSelectedElementIds[element.id]) {
  292. selectionColors.push(
  293. ...renderConfig.remoteSelectedElementIds[element.id].map(
  294. (socketId) => {
  295. const { background } = getClientColors(socketId, appState);
  296. return background;
  297. },
  298. ),
  299. );
  300. }
  301. if (selectionColors.length) {
  302. const [elementX1, elementY1, elementX2, elementY2] =
  303. getElementAbsoluteCoords(element);
  304. acc.push({
  305. angle: element.angle,
  306. elementX1,
  307. elementY1,
  308. elementX2,
  309. elementY2,
  310. selectionColors,
  311. });
  312. }
  313. return acc;
  314. }, [] as { angle: number; elementX1: number; elementY1: number; elementX2: number; elementY2: number; selectionColors: string[] }[]);
  315. const addSelectionForGroupId = (groupId: GroupId) => {
  316. const groupElements = getElementsInGroup(elements, groupId);
  317. const [elementX1, elementY1, elementX2, elementY2] =
  318. getCommonBounds(groupElements);
  319. selections.push({
  320. angle: 0,
  321. elementX1,
  322. elementX2,
  323. elementY1,
  324. elementY2,
  325. selectionColors: [oc.black],
  326. });
  327. };
  328. for (const groupId of getSelectedGroupIds(appState)) {
  329. // TODO: support multiplayer selected group IDs
  330. addSelectionForGroupId(groupId);
  331. }
  332. if (appState.editingGroupId) {
  333. addSelectionForGroupId(appState.editingGroupId);
  334. }
  335. selections.forEach((selection) =>
  336. renderSelectionBorder(context, renderConfig, selection),
  337. );
  338. const locallySelectedElements = getSelectedElements(elements, appState);
  339. // Paint resize transformHandles
  340. context.save();
  341. context.translate(renderConfig.scrollX, renderConfig.scrollY);
  342. if (locallySelectedElements.length === 1) {
  343. context.fillStyle = oc.white;
  344. const transformHandles = getTransformHandles(
  345. locallySelectedElements[0],
  346. renderConfig.zoom,
  347. "mouse", // when we render we don't know which pointer type so use mouse
  348. );
  349. if (!appState.viewModeEnabled) {
  350. renderTransformHandles(
  351. context,
  352. renderConfig,
  353. transformHandles,
  354. locallySelectedElements[0].angle,
  355. );
  356. }
  357. } else if (locallySelectedElements.length > 1 && !appState.isRotating) {
  358. const dashedLinePadding = 4 / renderConfig.zoom.value;
  359. context.fillStyle = oc.white;
  360. const [x1, y1, x2, y2] = getCommonBounds(locallySelectedElements);
  361. const initialLineDash = context.getLineDash();
  362. context.setLineDash([2 / renderConfig.zoom.value]);
  363. const lineWidth = context.lineWidth;
  364. context.lineWidth = 1 / renderConfig.zoom.value;
  365. strokeRectWithRotation(
  366. context,
  367. x1 - dashedLinePadding,
  368. y1 - dashedLinePadding,
  369. x2 - x1 + dashedLinePadding * 2,
  370. y2 - y1 + dashedLinePadding * 2,
  371. (x1 + x2) / 2,
  372. (y1 + y2) / 2,
  373. 0,
  374. );
  375. context.lineWidth = lineWidth;
  376. context.setLineDash(initialLineDash);
  377. const transformHandles = getTransformHandlesFromCoords(
  378. [x1, y1, x2, y2],
  379. 0,
  380. renderConfig.zoom,
  381. "mouse",
  382. OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
  383. );
  384. renderTransformHandles(context, renderConfig, transformHandles, 0);
  385. }
  386. context.restore();
  387. }
  388. // Reset zoom
  389. context.restore();
  390. // Paint remote pointers
  391. for (const clientId in renderConfig.remotePointerViewportCoords) {
  392. let { x, y } = renderConfig.remotePointerViewportCoords[clientId];
  393. x -= appState.offsetLeft;
  394. y -= appState.offsetTop;
  395. const width = 9;
  396. const height = 14;
  397. const isOutOfBounds =
  398. x < 0 ||
  399. x > normalizedCanvasWidth - width ||
  400. y < 0 ||
  401. y > normalizedCanvasHeight - height;
  402. x = Math.max(x, 0);
  403. x = Math.min(x, normalizedCanvasWidth - width);
  404. y = Math.max(y, 0);
  405. y = Math.min(y, normalizedCanvasHeight - height);
  406. const { background, stroke } = getClientColors(clientId, appState);
  407. context.save();
  408. context.strokeStyle = stroke;
  409. context.fillStyle = background;
  410. const userState = renderConfig.remotePointerUserStates[clientId];
  411. if (isOutOfBounds || userState === UserIdleState.AWAY) {
  412. context.globalAlpha = 0.48;
  413. }
  414. if (
  415. renderConfig.remotePointerButton &&
  416. renderConfig.remotePointerButton[clientId] === "down"
  417. ) {
  418. context.beginPath();
  419. context.arc(x, y, 15, 0, 2 * Math.PI, false);
  420. context.lineWidth = 3;
  421. context.strokeStyle = "#ffffff88";
  422. context.stroke();
  423. context.closePath();
  424. context.beginPath();
  425. context.arc(x, y, 15, 0, 2 * Math.PI, false);
  426. context.lineWidth = 1;
  427. context.strokeStyle = stroke;
  428. context.stroke();
  429. context.closePath();
  430. }
  431. context.beginPath();
  432. context.moveTo(x, y);
  433. context.lineTo(x + 1, y + 14);
  434. context.lineTo(x + 4, y + 9);
  435. context.lineTo(x + 9, y + 10);
  436. context.lineTo(x, y);
  437. context.fill();
  438. context.stroke();
  439. const username = renderConfig.remotePointerUsernames[clientId];
  440. let idleState = "";
  441. if (userState === UserIdleState.AWAY) {
  442. idleState = hasEmojiSupport ? "⚫️" : ` (${UserIdleState.AWAY})`;
  443. } else if (userState === UserIdleState.IDLE) {
  444. idleState = hasEmojiSupport ? "💤" : ` (${UserIdleState.IDLE})`;
  445. } else if (userState === UserIdleState.ACTIVE) {
  446. idleState = hasEmojiSupport ? "🟢" : "";
  447. }
  448. const usernameAndIdleState = `${
  449. username ? `${username} ` : ""
  450. }${idleState}`;
  451. if (!isOutOfBounds && usernameAndIdleState) {
  452. const offsetX = x + width;
  453. const offsetY = y + height;
  454. const paddingHorizontal = 4;
  455. const paddingVertical = 4;
  456. const measure = context.measureText(usernameAndIdleState);
  457. const measureHeight =
  458. measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent;
  459. // Border
  460. context.fillStyle = stroke;
  461. context.fillRect(
  462. offsetX - 1,
  463. offsetY - 1,
  464. measure.width + 2 * paddingHorizontal + 2,
  465. measureHeight + 2 * paddingVertical + 2,
  466. );
  467. // Background
  468. context.fillStyle = background;
  469. context.fillRect(
  470. offsetX,
  471. offsetY,
  472. measure.width + 2 * paddingHorizontal,
  473. measureHeight + 2 * paddingVertical,
  474. );
  475. context.fillStyle = oc.white;
  476. context.fillText(
  477. usernameAndIdleState,
  478. offsetX + paddingHorizontal,
  479. offsetY + paddingVertical + measure.actualBoundingBoxAscent,
  480. );
  481. }
  482. context.restore();
  483. context.closePath();
  484. }
  485. // Paint scrollbars
  486. let scrollBars;
  487. if (renderScrollbars) {
  488. scrollBars = getScrollBars(
  489. elements,
  490. normalizedCanvasWidth,
  491. normalizedCanvasHeight,
  492. renderConfig,
  493. );
  494. context.save();
  495. context.fillStyle = SCROLLBAR_COLOR;
  496. context.strokeStyle = "rgba(255,255,255,0.8)";
  497. [scrollBars.horizontal, scrollBars.vertical].forEach((scrollBar) => {
  498. if (scrollBar) {
  499. roundRect(
  500. context,
  501. scrollBar.x,
  502. scrollBar.y,
  503. scrollBar.width,
  504. scrollBar.height,
  505. SCROLLBAR_WIDTH / 2,
  506. );
  507. }
  508. });
  509. context.restore();
  510. }
  511. context.restore();
  512. return { atLeastOneVisibleElement: visibleElements.length > 0, scrollBars };
  513. };
  514. const renderTransformHandles = (
  515. context: CanvasRenderingContext2D,
  516. renderConfig: RenderConfig,
  517. transformHandles: TransformHandles,
  518. angle: number,
  519. ): void => {
  520. Object.keys(transformHandles).forEach((key) => {
  521. const transformHandle = transformHandles[key as TransformHandleType];
  522. if (transformHandle !== undefined) {
  523. context.save();
  524. context.lineWidth = 1 / renderConfig.zoom.value;
  525. if (key === "rotation") {
  526. fillCircle(
  527. context,
  528. transformHandle[0] + transformHandle[2] / 2,
  529. transformHandle[1] + transformHandle[3] / 2,
  530. transformHandle[2] / 2,
  531. );
  532. } else {
  533. strokeRectWithRotation(
  534. context,
  535. transformHandle[0],
  536. transformHandle[1],
  537. transformHandle[2],
  538. transformHandle[3],
  539. transformHandle[0] + transformHandle[2] / 2,
  540. transformHandle[1] + transformHandle[3] / 2,
  541. angle,
  542. true, // fill before stroke
  543. );
  544. }
  545. context.restore();
  546. }
  547. });
  548. };
  549. const renderSelectionBorder = (
  550. context: CanvasRenderingContext2D,
  551. renderConfig: RenderConfig,
  552. elementProperties: {
  553. angle: number;
  554. elementX1: number;
  555. elementY1: number;
  556. elementX2: number;
  557. elementY2: number;
  558. selectionColors: string[];
  559. },
  560. ) => {
  561. const { angle, elementX1, elementY1, elementX2, elementY2, selectionColors } =
  562. elementProperties;
  563. const elementWidth = elementX2 - elementX1;
  564. const elementHeight = elementY2 - elementY1;
  565. const dashedLinePadding = 4 / renderConfig.zoom.value;
  566. const dashWidth = 8 / renderConfig.zoom.value;
  567. const spaceWidth = 4 / renderConfig.zoom.value;
  568. context.save();
  569. context.translate(renderConfig.scrollX, renderConfig.scrollY);
  570. context.lineWidth = 1 / renderConfig.zoom.value;
  571. const count = selectionColors.length;
  572. for (let index = 0; index < count; ++index) {
  573. context.strokeStyle = selectionColors[index];
  574. context.setLineDash([
  575. dashWidth,
  576. spaceWidth + (dashWidth + spaceWidth) * (count - 1),
  577. ]);
  578. context.lineDashOffset = (dashWidth + spaceWidth) * index;
  579. strokeRectWithRotation(
  580. context,
  581. elementX1 - dashedLinePadding,
  582. elementY1 - dashedLinePadding,
  583. elementWidth + dashedLinePadding * 2,
  584. elementHeight + dashedLinePadding * 2,
  585. elementX1 + elementWidth / 2,
  586. elementY1 + elementHeight / 2,
  587. angle,
  588. );
  589. }
  590. context.restore();
  591. };
  592. const renderBindingHighlight = (
  593. context: CanvasRenderingContext2D,
  594. renderConfig: RenderConfig,
  595. suggestedBinding: SuggestedBinding,
  596. ) => {
  597. const renderHighlight = Array.isArray(suggestedBinding)
  598. ? renderBindingHighlightForSuggestedPointBinding
  599. : renderBindingHighlightForBindableElement;
  600. context.save();
  601. context.translate(renderConfig.scrollX, renderConfig.scrollY);
  602. renderHighlight(context, suggestedBinding as any);
  603. context.restore();
  604. };
  605. const renderBindingHighlightForBindableElement = (
  606. context: CanvasRenderingContext2D,
  607. element: ExcalidrawBindableElement,
  608. ) => {
  609. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  610. const width = x2 - x1;
  611. const height = y2 - y1;
  612. const threshold = maxBindingGap(element, width, height);
  613. // So that we don't overlap the element itself
  614. const strokeOffset = 4;
  615. context.strokeStyle = "rgba(0,0,0,.05)";
  616. context.lineWidth = threshold - strokeOffset;
  617. const padding = strokeOffset / 2 + threshold / 2;
  618. switch (element.type) {
  619. case "rectangle":
  620. case "text":
  621. strokeRectWithRotation(
  622. context,
  623. x1 - padding,
  624. y1 - padding,
  625. width + padding * 2,
  626. height + padding * 2,
  627. x1 + width / 2,
  628. y1 + height / 2,
  629. element.angle,
  630. );
  631. break;
  632. case "diamond":
  633. const side = Math.hypot(width, height);
  634. const wPadding = (padding * side) / height;
  635. const hPadding = (padding * side) / width;
  636. strokeDiamondWithRotation(
  637. context,
  638. width + wPadding * 2,
  639. height + hPadding * 2,
  640. x1 + width / 2,
  641. y1 + height / 2,
  642. element.angle,
  643. );
  644. break;
  645. case "ellipse":
  646. strokeEllipseWithRotation(
  647. context,
  648. width + padding * 2,
  649. height + padding * 2,
  650. x1 + width / 2,
  651. y1 + height / 2,
  652. element.angle,
  653. );
  654. break;
  655. }
  656. };
  657. const renderBindingHighlightForSuggestedPointBinding = (
  658. context: CanvasRenderingContext2D,
  659. suggestedBinding: SuggestedPointBinding,
  660. ) => {
  661. const [element, startOrEnd, bindableElement] = suggestedBinding;
  662. const threshold = maxBindingGap(
  663. bindableElement,
  664. bindableElement.width,
  665. bindableElement.height,
  666. );
  667. context.strokeStyle = "rgba(0,0,0,0)";
  668. context.fillStyle = "rgba(0,0,0,.05)";
  669. const pointIndices =
  670. startOrEnd === "both" ? [0, -1] : startOrEnd === "start" ? [0] : [-1];
  671. pointIndices.forEach((index) => {
  672. const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
  673. element,
  674. index,
  675. );
  676. fillCircle(context, x, y, threshold);
  677. });
  678. };
  679. const isVisibleElement = (
  680. element: ExcalidrawElement,
  681. canvasWidth: number,
  682. canvasHeight: number,
  683. viewTransformations: {
  684. zoom: Zoom;
  685. offsetLeft: number;
  686. offsetTop: number;
  687. scrollX: number;
  688. scrollY: number;
  689. },
  690. ) => {
  691. const [x1, y1, x2, y2] = getElementBounds(element); // scene coordinates
  692. const topLeftSceneCoords = viewportCoordsToSceneCoords(
  693. {
  694. clientX: viewTransformations.offsetLeft,
  695. clientY: viewTransformations.offsetTop,
  696. },
  697. viewTransformations,
  698. );
  699. const bottomRightSceneCoords = viewportCoordsToSceneCoords(
  700. {
  701. clientX: viewTransformations.offsetLeft + canvasWidth,
  702. clientY: viewTransformations.offsetTop + canvasHeight,
  703. },
  704. viewTransformations,
  705. );
  706. return (
  707. topLeftSceneCoords.x <= x2 &&
  708. topLeftSceneCoords.y <= y2 &&
  709. bottomRightSceneCoords.x >= x1 &&
  710. bottomRightSceneCoords.y >= y1
  711. );
  712. };
  713. // This should be only called for exporting purposes
  714. export const renderSceneToSvg = (
  715. elements: readonly NonDeletedExcalidrawElement[],
  716. rsvg: RoughSVG,
  717. svgRoot: SVGElement,
  718. files: BinaryFiles,
  719. {
  720. offsetX = 0,
  721. offsetY = 0,
  722. exportWithDarkMode = false,
  723. }: {
  724. offsetX?: number;
  725. offsetY?: number;
  726. exportWithDarkMode?: boolean;
  727. } = {},
  728. ) => {
  729. if (!svgRoot) {
  730. return;
  731. }
  732. // render elements
  733. elements.forEach((element) => {
  734. if (!element.isDeleted) {
  735. try {
  736. renderElementToSvg(
  737. element,
  738. rsvg,
  739. svgRoot,
  740. files,
  741. element.x + offsetX,
  742. element.y + offsetY,
  743. exportWithDarkMode,
  744. );
  745. } catch (error: any) {
  746. console.error(error);
  747. }
  748. }
  749. });
  750. };