renderScene.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841
  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 { SceneState } 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. sceneState: SceneState,
  140. element: NonDeleted<ExcalidrawLinearElement>,
  141. ) => {
  142. context.save();
  143. context.translate(sceneState.scrollX, sceneState.scrollY);
  144. context.lineWidth = 1 / sceneState.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 / sceneState.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. sceneState: SceneState,
  172. // extra options passed to the renderer
  173. {
  174. renderScrollbars = true,
  175. renderSelection = true,
  176. // Whether to employ render optimizations to improve performance.
  177. // Should not be turned on for export operations and similar, because it
  178. // doesn't guarantee pixel-perfect output.
  179. renderOptimizations = false,
  180. renderGrid = true,
  181. /** when exporting the behavior is slightly different (e.g. we can't use
  182. CSS filters) */
  183. isExport = false,
  184. }: {
  185. renderScrollbars?: boolean;
  186. renderSelection?: boolean;
  187. renderOptimizations?: boolean;
  188. renderGrid?: boolean;
  189. isExport?: boolean;
  190. } = {},
  191. ) => {
  192. if (canvas === null) {
  193. return { atLeastOneVisibleElement: false };
  194. }
  195. const context = canvas.getContext("2d")!;
  196. context.setTransform(1, 0, 0, 1, 0, 0);
  197. context.save();
  198. context.scale(scale, scale);
  199. // When doing calculations based on canvas width we should used normalized one
  200. const normalizedCanvasWidth = canvas.width / scale;
  201. const normalizedCanvasHeight = canvas.height / scale;
  202. if (isExport && sceneState.theme === "dark") {
  203. context.filter = THEME_FILTER;
  204. }
  205. // Paint background
  206. if (typeof sceneState.viewBackgroundColor === "string") {
  207. const hasTransparence =
  208. sceneState.viewBackgroundColor === "transparent" ||
  209. sceneState.viewBackgroundColor.length === 5 || // #RGBA
  210. sceneState.viewBackgroundColor.length === 9 || // #RRGGBBA
  211. /(hsla|rgba)\(/.test(sceneState.viewBackgroundColor);
  212. if (hasTransparence) {
  213. context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
  214. }
  215. context.save();
  216. context.fillStyle = sceneState.viewBackgroundColor;
  217. context.fillRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
  218. context.restore();
  219. } else {
  220. context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
  221. }
  222. // Apply zoom
  223. const zoomTranslationX = sceneState.zoom.translation.x;
  224. const zoomTranslationY = sceneState.zoom.translation.y;
  225. context.save();
  226. context.translate(zoomTranslationX, zoomTranslationY);
  227. context.scale(sceneState.zoom.value, sceneState.zoom.value);
  228. // Grid
  229. if (renderGrid && appState.gridSize) {
  230. strokeGrid(
  231. context,
  232. appState.gridSize,
  233. -Math.ceil(zoomTranslationX / sceneState.zoom.value / appState.gridSize) *
  234. appState.gridSize +
  235. (sceneState.scrollX % appState.gridSize),
  236. -Math.ceil(zoomTranslationY / sceneState.zoom.value / appState.gridSize) *
  237. appState.gridSize +
  238. (sceneState.scrollY % appState.gridSize),
  239. normalizedCanvasWidth / sceneState.zoom.value,
  240. normalizedCanvasHeight / sceneState.zoom.value,
  241. );
  242. }
  243. // Paint visible elements
  244. const visibleElements = elements.filter((element) =>
  245. isVisibleElement(element, normalizedCanvasWidth, normalizedCanvasHeight, {
  246. zoom: sceneState.zoom,
  247. offsetLeft: appState.offsetLeft,
  248. offsetTop: appState.offsetTop,
  249. scrollX: sceneState.scrollX,
  250. scrollY: sceneState.scrollY,
  251. }),
  252. );
  253. visibleElements.forEach((element) => {
  254. try {
  255. renderElement(element, rc, context, renderOptimizations, sceneState);
  256. } catch (error) {
  257. console.error(error);
  258. }
  259. });
  260. if (appState.editingLinearElement) {
  261. const element = LinearElementEditor.getElement(
  262. appState.editingLinearElement.elementId,
  263. );
  264. if (element) {
  265. renderLinearPointHandles(context, appState, sceneState, element);
  266. }
  267. }
  268. // Paint selection element
  269. if (selectionElement) {
  270. try {
  271. renderElement(
  272. selectionElement,
  273. rc,
  274. context,
  275. renderOptimizations,
  276. sceneState,
  277. );
  278. } catch (error) {
  279. console.error(error);
  280. }
  281. }
  282. if (isBindingEnabled(appState)) {
  283. appState.suggestedBindings
  284. .filter((binding) => binding != null)
  285. .forEach((suggestedBinding) => {
  286. renderBindingHighlight(context, sceneState, suggestedBinding!);
  287. });
  288. }
  289. // Paint selected elements
  290. if (
  291. renderSelection &&
  292. !appState.multiElement &&
  293. !appState.editingLinearElement
  294. ) {
  295. const selections = elements.reduce((acc, element) => {
  296. const selectionColors = [];
  297. // local user
  298. if (
  299. appState.selectedElementIds[element.id] &&
  300. !isSelectedViaGroup(appState, element)
  301. ) {
  302. selectionColors.push(oc.black);
  303. }
  304. // remote users
  305. if (sceneState.remoteSelectedElementIds[element.id]) {
  306. selectionColors.push(
  307. ...sceneState.remoteSelectedElementIds[element.id].map((socketId) => {
  308. const { background } = getClientColors(socketId, appState);
  309. return background;
  310. }),
  311. );
  312. }
  313. if (selectionColors.length) {
  314. const [
  315. elementX1,
  316. elementY1,
  317. elementX2,
  318. elementY2,
  319. ] = getElementAbsoluteCoords(element);
  320. acc.push({
  321. angle: element.angle,
  322. elementX1,
  323. elementY1,
  324. elementX2,
  325. elementY2,
  326. selectionColors,
  327. });
  328. }
  329. return acc;
  330. }, [] as { angle: number; elementX1: number; elementY1: number; elementX2: number; elementY2: number; selectionColors: string[] }[]);
  331. const addSelectionForGroupId = (groupId: GroupId) => {
  332. const groupElements = getElementsInGroup(elements, groupId);
  333. const [elementX1, elementY1, elementX2, elementY2] = getCommonBounds(
  334. groupElements,
  335. );
  336. selections.push({
  337. angle: 0,
  338. elementX1,
  339. elementX2,
  340. elementY1,
  341. elementY2,
  342. selectionColors: [oc.black],
  343. });
  344. };
  345. for (const groupId of getSelectedGroupIds(appState)) {
  346. // TODO: support multiplayer selected group IDs
  347. addSelectionForGroupId(groupId);
  348. }
  349. if (appState.editingGroupId) {
  350. addSelectionForGroupId(appState.editingGroupId);
  351. }
  352. selections.forEach((selection) =>
  353. renderSelectionBorder(context, sceneState, selection),
  354. );
  355. const locallySelectedElements = getSelectedElements(elements, appState);
  356. // Paint resize transformHandles
  357. context.save();
  358. context.translate(sceneState.scrollX, sceneState.scrollY);
  359. if (locallySelectedElements.length === 1) {
  360. context.fillStyle = oc.white;
  361. const transformHandles = getTransformHandles(
  362. locallySelectedElements[0],
  363. sceneState.zoom,
  364. "mouse", // when we render we don't know which pointer type so use mouse
  365. );
  366. if (!appState.viewModeEnabled) {
  367. renderTransformHandles(
  368. context,
  369. sceneState,
  370. transformHandles,
  371. locallySelectedElements[0].angle,
  372. );
  373. }
  374. } else if (locallySelectedElements.length > 1 && !appState.isRotating) {
  375. const dashedLinePadding = 4 / sceneState.zoom.value;
  376. context.fillStyle = oc.white;
  377. const [x1, y1, x2, y2] = getCommonBounds(locallySelectedElements);
  378. const initialLineDash = context.getLineDash();
  379. context.setLineDash([2 / sceneState.zoom.value]);
  380. const lineWidth = context.lineWidth;
  381. context.lineWidth = 1 / sceneState.zoom.value;
  382. strokeRectWithRotation(
  383. context,
  384. x1 - dashedLinePadding,
  385. y1 - dashedLinePadding,
  386. x2 - x1 + dashedLinePadding * 2,
  387. y2 - y1 + dashedLinePadding * 2,
  388. (x1 + x2) / 2,
  389. (y1 + y2) / 2,
  390. 0,
  391. );
  392. context.lineWidth = lineWidth;
  393. context.setLineDash(initialLineDash);
  394. const transformHandles = getTransformHandlesFromCoords(
  395. [x1, y1, x2, y2],
  396. 0,
  397. sceneState.zoom,
  398. "mouse",
  399. OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
  400. );
  401. renderTransformHandles(context, sceneState, transformHandles, 0);
  402. }
  403. context.restore();
  404. }
  405. // Reset zoom
  406. context.restore();
  407. // Paint remote pointers
  408. for (const clientId in sceneState.remotePointerViewportCoords) {
  409. let { x, y } = sceneState.remotePointerViewportCoords[clientId];
  410. x -= appState.offsetLeft;
  411. y -= appState.offsetTop;
  412. const width = 9;
  413. const height = 14;
  414. const isOutOfBounds =
  415. x < 0 ||
  416. x > normalizedCanvasWidth - width ||
  417. y < 0 ||
  418. y > normalizedCanvasHeight - height;
  419. x = Math.max(x, 0);
  420. x = Math.min(x, normalizedCanvasWidth - width);
  421. y = Math.max(y, 0);
  422. y = Math.min(y, normalizedCanvasHeight - height);
  423. const { background, stroke } = getClientColors(clientId, appState);
  424. context.save();
  425. context.strokeStyle = stroke;
  426. context.fillStyle = background;
  427. const userState = sceneState.remotePointerUserStates[clientId];
  428. if (isOutOfBounds || userState === UserIdleState.AWAY) {
  429. context.globalAlpha = 0.48;
  430. }
  431. if (
  432. sceneState.remotePointerButton &&
  433. sceneState.remotePointerButton[clientId] === "down"
  434. ) {
  435. context.beginPath();
  436. context.arc(x, y, 15, 0, 2 * Math.PI, false);
  437. context.lineWidth = 3;
  438. context.strokeStyle = "#ffffff88";
  439. context.stroke();
  440. context.closePath();
  441. context.beginPath();
  442. context.arc(x, y, 15, 0, 2 * Math.PI, false);
  443. context.lineWidth = 1;
  444. context.strokeStyle = stroke;
  445. context.stroke();
  446. context.closePath();
  447. }
  448. context.beginPath();
  449. context.moveTo(x, y);
  450. context.lineTo(x + 1, y + 14);
  451. context.lineTo(x + 4, y + 9);
  452. context.lineTo(x + 9, y + 10);
  453. context.lineTo(x, y);
  454. context.fill();
  455. context.stroke();
  456. const username = sceneState.remotePointerUsernames[clientId];
  457. let idleState = "";
  458. if (userState === UserIdleState.AWAY) {
  459. idleState = hasEmojiSupport ? "⚫️" : ` (${UserIdleState.AWAY})`;
  460. } else if (userState === UserIdleState.IDLE) {
  461. idleState = hasEmojiSupport ? "💤" : ` (${UserIdleState.IDLE})`;
  462. } else if (userState === UserIdleState.ACTIVE) {
  463. idleState = hasEmojiSupport ? "🟢" : "";
  464. }
  465. const usernameAndIdleState = `${
  466. username ? `${username} ` : ""
  467. }${idleState}`;
  468. if (!isOutOfBounds && usernameAndIdleState) {
  469. const offsetX = x + width;
  470. const offsetY = y + height;
  471. const paddingHorizontal = 4;
  472. const paddingVertical = 4;
  473. const measure = context.measureText(usernameAndIdleState);
  474. const measureHeight =
  475. measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent;
  476. // Border
  477. context.fillStyle = stroke;
  478. context.fillRect(
  479. offsetX - 1,
  480. offsetY - 1,
  481. measure.width + 2 * paddingHorizontal + 2,
  482. measureHeight + 2 * paddingVertical + 2,
  483. );
  484. // Background
  485. context.fillStyle = background;
  486. context.fillRect(
  487. offsetX,
  488. offsetY,
  489. measure.width + 2 * paddingHorizontal,
  490. measureHeight + 2 * paddingVertical,
  491. );
  492. context.fillStyle = oc.white;
  493. context.fillText(
  494. usernameAndIdleState,
  495. offsetX + paddingHorizontal,
  496. offsetY + paddingVertical + measure.actualBoundingBoxAscent,
  497. );
  498. }
  499. context.restore();
  500. context.closePath();
  501. }
  502. // Paint scrollbars
  503. let scrollBars;
  504. if (renderScrollbars) {
  505. scrollBars = getScrollBars(
  506. elements,
  507. normalizedCanvasWidth,
  508. normalizedCanvasHeight,
  509. sceneState,
  510. );
  511. context.save();
  512. context.fillStyle = SCROLLBAR_COLOR;
  513. context.strokeStyle = "rgba(255,255,255,0.8)";
  514. [scrollBars.horizontal, scrollBars.vertical].forEach((scrollBar) => {
  515. if (scrollBar) {
  516. roundRect(
  517. context,
  518. scrollBar.x,
  519. scrollBar.y,
  520. scrollBar.width,
  521. scrollBar.height,
  522. SCROLLBAR_WIDTH / 2,
  523. );
  524. }
  525. });
  526. context.restore();
  527. }
  528. context.restore();
  529. return { atLeastOneVisibleElement: visibleElements.length > 0, scrollBars };
  530. };
  531. const renderTransformHandles = (
  532. context: CanvasRenderingContext2D,
  533. sceneState: SceneState,
  534. transformHandles: TransformHandles,
  535. angle: number,
  536. ): void => {
  537. Object.keys(transformHandles).forEach((key) => {
  538. const transformHandle = transformHandles[key as TransformHandleType];
  539. if (transformHandle !== undefined) {
  540. context.save();
  541. context.lineWidth = 1 / sceneState.zoom.value;
  542. if (key === "rotation") {
  543. fillCircle(
  544. context,
  545. transformHandle[0] + transformHandle[2] / 2,
  546. transformHandle[1] + transformHandle[3] / 2,
  547. transformHandle[2] / 2,
  548. );
  549. } else {
  550. strokeRectWithRotation(
  551. context,
  552. transformHandle[0],
  553. transformHandle[1],
  554. transformHandle[2],
  555. transformHandle[3],
  556. transformHandle[0] + transformHandle[2] / 2,
  557. transformHandle[1] + transformHandle[3] / 2,
  558. angle,
  559. true, // fill before stroke
  560. );
  561. }
  562. context.restore();
  563. }
  564. });
  565. };
  566. const renderSelectionBorder = (
  567. context: CanvasRenderingContext2D,
  568. sceneState: SceneState,
  569. elementProperties: {
  570. angle: number;
  571. elementX1: number;
  572. elementY1: number;
  573. elementX2: number;
  574. elementY2: number;
  575. selectionColors: string[];
  576. },
  577. ) => {
  578. const {
  579. angle,
  580. elementX1,
  581. elementY1,
  582. elementX2,
  583. elementY2,
  584. selectionColors,
  585. } = elementProperties;
  586. const elementWidth = elementX2 - elementX1;
  587. const elementHeight = elementY2 - elementY1;
  588. const dashedLinePadding = 4 / sceneState.zoom.value;
  589. const dashWidth = 8 / sceneState.zoom.value;
  590. const spaceWidth = 4 / sceneState.zoom.value;
  591. context.save();
  592. context.translate(sceneState.scrollX, sceneState.scrollY);
  593. context.lineWidth = 1 / sceneState.zoom.value;
  594. const count = selectionColors.length;
  595. for (let index = 0; index < count; ++index) {
  596. context.strokeStyle = selectionColors[index];
  597. context.setLineDash([
  598. dashWidth,
  599. spaceWidth + (dashWidth + spaceWidth) * (count - 1),
  600. ]);
  601. context.lineDashOffset = (dashWidth + spaceWidth) * index;
  602. strokeRectWithRotation(
  603. context,
  604. elementX1 - dashedLinePadding,
  605. elementY1 - dashedLinePadding,
  606. elementWidth + dashedLinePadding * 2,
  607. elementHeight + dashedLinePadding * 2,
  608. elementX1 + elementWidth / 2,
  609. elementY1 + elementHeight / 2,
  610. angle,
  611. );
  612. }
  613. context.restore();
  614. };
  615. const renderBindingHighlight = (
  616. context: CanvasRenderingContext2D,
  617. sceneState: SceneState,
  618. suggestedBinding: SuggestedBinding,
  619. ) => {
  620. const renderHighlight = Array.isArray(suggestedBinding)
  621. ? renderBindingHighlightForSuggestedPointBinding
  622. : renderBindingHighlightForBindableElement;
  623. context.save();
  624. context.translate(sceneState.scrollX, sceneState.scrollY);
  625. renderHighlight(context, suggestedBinding as any);
  626. context.restore();
  627. };
  628. const renderBindingHighlightForBindableElement = (
  629. context: CanvasRenderingContext2D,
  630. element: ExcalidrawBindableElement,
  631. ) => {
  632. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  633. const width = x2 - x1;
  634. const height = y2 - y1;
  635. const threshold = maxBindingGap(element, width, height);
  636. // So that we don't overlap the element itself
  637. const strokeOffset = 4;
  638. context.strokeStyle = "rgba(0,0,0,.05)";
  639. context.lineWidth = threshold - strokeOffset;
  640. const padding = strokeOffset / 2 + threshold / 2;
  641. switch (element.type) {
  642. case "rectangle":
  643. case "text":
  644. strokeRectWithRotation(
  645. context,
  646. x1 - padding,
  647. y1 - padding,
  648. width + padding * 2,
  649. height + padding * 2,
  650. x1 + width / 2,
  651. y1 + height / 2,
  652. element.angle,
  653. );
  654. break;
  655. case "diamond":
  656. const side = Math.hypot(width, height);
  657. const wPadding = (padding * side) / height;
  658. const hPadding = (padding * side) / width;
  659. strokeDiamondWithRotation(
  660. context,
  661. width + wPadding * 2,
  662. height + hPadding * 2,
  663. x1 + width / 2,
  664. y1 + height / 2,
  665. element.angle,
  666. );
  667. break;
  668. case "ellipse":
  669. strokeEllipseWithRotation(
  670. context,
  671. width + padding * 2,
  672. height + padding * 2,
  673. x1 + width / 2,
  674. y1 + height / 2,
  675. element.angle,
  676. );
  677. break;
  678. }
  679. };
  680. const renderBindingHighlightForSuggestedPointBinding = (
  681. context: CanvasRenderingContext2D,
  682. suggestedBinding: SuggestedPointBinding,
  683. ) => {
  684. const [element, startOrEnd, bindableElement] = suggestedBinding;
  685. const threshold = maxBindingGap(
  686. bindableElement,
  687. bindableElement.width,
  688. bindableElement.height,
  689. );
  690. context.strokeStyle = "rgba(0,0,0,0)";
  691. context.fillStyle = "rgba(0,0,0,.05)";
  692. const pointIndices =
  693. startOrEnd === "both" ? [0, -1] : startOrEnd === "start" ? [0] : [-1];
  694. pointIndices.forEach((index) => {
  695. const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
  696. element,
  697. index,
  698. );
  699. fillCircle(context, x, y, threshold);
  700. });
  701. };
  702. const isVisibleElement = (
  703. element: ExcalidrawElement,
  704. canvasWidth: number,
  705. canvasHeight: number,
  706. viewTransformations: {
  707. zoom: Zoom;
  708. offsetLeft: number;
  709. offsetTop: number;
  710. scrollX: number;
  711. scrollY: number;
  712. },
  713. ) => {
  714. const [x1, y1, x2, y2] = getElementBounds(element); // scene coordinates
  715. const topLeftSceneCoords = viewportCoordsToSceneCoords(
  716. {
  717. clientX: viewTransformations.offsetLeft,
  718. clientY: viewTransformations.offsetTop,
  719. },
  720. viewTransformations,
  721. );
  722. const bottomRightSceneCoords = viewportCoordsToSceneCoords(
  723. {
  724. clientX: viewTransformations.offsetLeft + canvasWidth,
  725. clientY: viewTransformations.offsetTop + canvasHeight,
  726. },
  727. viewTransformations,
  728. );
  729. return (
  730. topLeftSceneCoords.x <= x2 &&
  731. topLeftSceneCoords.y <= y2 &&
  732. bottomRightSceneCoords.x >= x1 &&
  733. bottomRightSceneCoords.y >= y1
  734. );
  735. };
  736. // This should be only called for exporting purposes
  737. export const renderSceneToSvg = (
  738. elements: readonly NonDeletedExcalidrawElement[],
  739. rsvg: RoughSVG,
  740. svgRoot: SVGElement,
  741. files: BinaryFiles,
  742. {
  743. offsetX = 0,
  744. offsetY = 0,
  745. }: {
  746. offsetX?: number;
  747. offsetY?: number;
  748. } = {},
  749. ) => {
  750. if (!svgRoot) {
  751. return;
  752. }
  753. // render elements
  754. elements.forEach((element) => {
  755. if (!element.isDeleted) {
  756. try {
  757. renderElementToSvg(
  758. element,
  759. rsvg,
  760. svgRoot,
  761. files,
  762. element.x + offsetX,
  763. element.y + offsetY,
  764. );
  765. } catch (error) {
  766. console.error(error);
  767. }
  768. }
  769. });
  770. };