renderScene.ts 24 KB

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