renderScene.ts 30 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069
  1. import { RoughCanvas } from "roughjs/bin/canvas";
  2. import { RoughSVG } from "roughjs/bin/svg";
  3. import oc from "open-color";
  4. import { AppState, BinaryFiles, Point, 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. shouldShowBoundingBox,
  45. TransformHandles,
  46. TransformHandleType,
  47. } from "../element/transformHandles";
  48. import {
  49. viewportCoordsToSceneCoords,
  50. supportsEmoji,
  51. throttleRAF,
  52. } from "../utils";
  53. import { UserIdleState } from "../types";
  54. import { THEME_FILTER } from "../constants";
  55. import {
  56. EXTERNAL_LINK_IMG,
  57. getLinkHandleFromCoords,
  58. } from "../element/Hyperlink";
  59. import { isLinearElement } from "../element/typeChecks";
  60. const hasEmojiSupport = supportsEmoji();
  61. export const DEFAULT_SPACING = 4;
  62. const strokeRectWithRotation = (
  63. context: CanvasRenderingContext2D,
  64. x: number,
  65. y: number,
  66. width: number,
  67. height: number,
  68. cx: number,
  69. cy: number,
  70. angle: number,
  71. fill: boolean = false,
  72. ) => {
  73. context.save();
  74. context.translate(cx, cy);
  75. context.rotate(angle);
  76. if (fill) {
  77. context.fillRect(x - cx, y - cy, width, height);
  78. }
  79. context.strokeRect(x - cx, y - cy, width, height);
  80. context.restore();
  81. };
  82. const strokeDiamondWithRotation = (
  83. context: CanvasRenderingContext2D,
  84. width: number,
  85. height: number,
  86. cx: number,
  87. cy: number,
  88. angle: number,
  89. ) => {
  90. context.save();
  91. context.translate(cx, cy);
  92. context.rotate(angle);
  93. context.beginPath();
  94. context.moveTo(0, height / 2);
  95. context.lineTo(width / 2, 0);
  96. context.lineTo(0, -height / 2);
  97. context.lineTo(-width / 2, 0);
  98. context.closePath();
  99. context.stroke();
  100. context.restore();
  101. };
  102. const strokeEllipseWithRotation = (
  103. context: CanvasRenderingContext2D,
  104. width: number,
  105. height: number,
  106. cx: number,
  107. cy: number,
  108. angle: number,
  109. ) => {
  110. context.beginPath();
  111. context.ellipse(cx, cy, width / 2, height / 2, angle, 0, Math.PI * 2);
  112. context.stroke();
  113. };
  114. const fillCircle = (
  115. context: CanvasRenderingContext2D,
  116. cx: number,
  117. cy: number,
  118. radius: number,
  119. stroke = true,
  120. ) => {
  121. context.beginPath();
  122. context.arc(cx, cy, radius, 0, Math.PI * 2);
  123. context.fill();
  124. if (stroke) {
  125. context.stroke();
  126. }
  127. };
  128. const strokeGrid = (
  129. context: CanvasRenderingContext2D,
  130. gridSize: number,
  131. offsetX: number,
  132. offsetY: number,
  133. width: number,
  134. height: number,
  135. ) => {
  136. context.save();
  137. context.strokeStyle = "rgba(0,0,0,0.1)";
  138. context.beginPath();
  139. for (let x = offsetX; x < offsetX + width + gridSize * 2; x += gridSize) {
  140. context.moveTo(x, offsetY - gridSize);
  141. context.lineTo(x, offsetY + height + gridSize * 2);
  142. }
  143. for (let y = offsetY; y < offsetY + height + gridSize * 2; y += gridSize) {
  144. context.moveTo(offsetX - gridSize, y);
  145. context.lineTo(offsetX + width + gridSize * 2, y);
  146. }
  147. context.stroke();
  148. context.restore();
  149. };
  150. const renderSingleLinearPoint = (
  151. context: CanvasRenderingContext2D,
  152. appState: AppState,
  153. renderConfig: RenderConfig,
  154. point: Point,
  155. isSelected: boolean,
  156. isPhantomPoint = false,
  157. ) => {
  158. context.strokeStyle = "#5e5ad8";
  159. context.setLineDash([]);
  160. context.fillStyle = "rgba(255, 255, 255, 0.9)";
  161. if (isSelected) {
  162. context.fillStyle = "rgba(134, 131, 226, 0.9)";
  163. } else if (isPhantomPoint) {
  164. context.fillStyle = "rgba(177, 151, 252, 0.7)";
  165. }
  166. const { POINT_HANDLE_SIZE } = LinearElementEditor;
  167. const radius = appState.editingLinearElement
  168. ? POINT_HANDLE_SIZE
  169. : POINT_HANDLE_SIZE / 2;
  170. fillCircle(
  171. context,
  172. point[0],
  173. point[1],
  174. radius / renderConfig.zoom.value,
  175. !isPhantomPoint,
  176. );
  177. };
  178. const renderLinearPointHandles = (
  179. context: CanvasRenderingContext2D,
  180. appState: AppState,
  181. renderConfig: RenderConfig,
  182. element: NonDeleted<ExcalidrawLinearElement>,
  183. ) => {
  184. if (!appState.selectedLinearElement) {
  185. return;
  186. }
  187. context.save();
  188. context.translate(renderConfig.scrollX, renderConfig.scrollY);
  189. context.lineWidth = 1 / renderConfig.zoom.value;
  190. const points = LinearElementEditor.getPointsGlobalCoordinates(element);
  191. const centerPoint = LinearElementEditor.getMidPoint(
  192. appState.selectedLinearElement,
  193. );
  194. if (!centerPoint) {
  195. return;
  196. }
  197. points.forEach((point, idx) => {
  198. const isSelected =
  199. !!appState.editingLinearElement?.selectedPointsIndices?.includes(idx);
  200. renderSingleLinearPoint(context, appState, renderConfig, point, isSelected);
  201. });
  202. if (!appState.editingLinearElement && points.length < 3) {
  203. if (appState.selectedLinearElement.midPointHovered) {
  204. const centerPoint = LinearElementEditor.getMidPoint(
  205. appState.selectedLinearElement,
  206. )!;
  207. highlightPoint(centerPoint, context, appState, renderConfig);
  208. renderSingleLinearPoint(
  209. context,
  210. appState,
  211. renderConfig,
  212. centerPoint,
  213. false,
  214. );
  215. } else {
  216. renderSingleLinearPoint(
  217. context,
  218. appState,
  219. renderConfig,
  220. centerPoint,
  221. false,
  222. true,
  223. );
  224. }
  225. }
  226. context.restore();
  227. };
  228. const highlightPoint = (
  229. point: Point,
  230. context: CanvasRenderingContext2D,
  231. appState: AppState,
  232. renderConfig: RenderConfig,
  233. ) => {
  234. context.fillStyle = "rgba(105, 101, 219, 0.4)";
  235. fillCircle(
  236. context,
  237. point[0],
  238. point[1],
  239. LinearElementEditor.POINT_HANDLE_SIZE / renderConfig.zoom.value,
  240. false,
  241. );
  242. };
  243. const renderLinearElementPointHighlight = (
  244. context: CanvasRenderingContext2D,
  245. appState: AppState,
  246. renderConfig: RenderConfig,
  247. ) => {
  248. const { elementId, hoverPointIndex } = appState.selectedLinearElement!;
  249. if (
  250. appState.editingLinearElement?.selectedPointsIndices?.includes(
  251. hoverPointIndex,
  252. )
  253. ) {
  254. return;
  255. }
  256. const element = LinearElementEditor.getElement(elementId);
  257. if (!element) {
  258. return;
  259. }
  260. const point = LinearElementEditor.getPointAtIndexGlobalCoordinates(
  261. element,
  262. hoverPointIndex,
  263. );
  264. context.save();
  265. context.translate(renderConfig.scrollX, renderConfig.scrollY);
  266. highlightPoint(point, context, appState, renderConfig);
  267. context.restore();
  268. };
  269. export const _renderScene = ({
  270. elements,
  271. appState,
  272. scale,
  273. rc,
  274. canvas,
  275. renderConfig,
  276. }: {
  277. elements: readonly NonDeletedExcalidrawElement[];
  278. appState: AppState;
  279. scale: number;
  280. rc: RoughCanvas;
  281. canvas: HTMLCanvasElement;
  282. renderConfig: RenderConfig;
  283. }) =>
  284. // extra options passed to the renderer
  285. {
  286. if (canvas === null) {
  287. return { atLeastOneVisibleElement: false };
  288. }
  289. const {
  290. renderScrollbars = true,
  291. renderSelection = true,
  292. renderGrid = true,
  293. isExporting,
  294. } = renderConfig;
  295. const context = canvas.getContext("2d")!;
  296. context.setTransform(1, 0, 0, 1, 0, 0);
  297. context.save();
  298. context.scale(scale, scale);
  299. // When doing calculations based on canvas width we should used normalized one
  300. const normalizedCanvasWidth = canvas.width / scale;
  301. const normalizedCanvasHeight = canvas.height / scale;
  302. if (isExporting && renderConfig.theme === "dark") {
  303. context.filter = THEME_FILTER;
  304. }
  305. // Paint background
  306. if (typeof renderConfig.viewBackgroundColor === "string") {
  307. const hasTransparence =
  308. renderConfig.viewBackgroundColor === "transparent" ||
  309. renderConfig.viewBackgroundColor.length === 5 || // #RGBA
  310. renderConfig.viewBackgroundColor.length === 9 || // #RRGGBBA
  311. /(hsla|rgba)\(/.test(renderConfig.viewBackgroundColor);
  312. if (hasTransparence) {
  313. context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
  314. }
  315. context.save();
  316. context.fillStyle = renderConfig.viewBackgroundColor;
  317. context.fillRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
  318. context.restore();
  319. } else {
  320. context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
  321. }
  322. // Apply zoom
  323. context.save();
  324. context.scale(renderConfig.zoom.value, renderConfig.zoom.value);
  325. // Grid
  326. if (renderGrid && appState.gridSize) {
  327. strokeGrid(
  328. context,
  329. appState.gridSize,
  330. -Math.ceil(renderConfig.zoom.value / appState.gridSize) *
  331. appState.gridSize +
  332. (renderConfig.scrollX % appState.gridSize),
  333. -Math.ceil(renderConfig.zoom.value / appState.gridSize) *
  334. appState.gridSize +
  335. (renderConfig.scrollY % appState.gridSize),
  336. normalizedCanvasWidth / renderConfig.zoom.value,
  337. normalizedCanvasHeight / renderConfig.zoom.value,
  338. );
  339. }
  340. // Paint visible elements
  341. const visibleElements = elements.filter((element) =>
  342. isVisibleElement(element, normalizedCanvasWidth, normalizedCanvasHeight, {
  343. zoom: renderConfig.zoom,
  344. offsetLeft: appState.offsetLeft,
  345. offsetTop: appState.offsetTop,
  346. scrollX: renderConfig.scrollX,
  347. scrollY: renderConfig.scrollY,
  348. }),
  349. );
  350. visibleElements.forEach((element) => {
  351. try {
  352. renderElement(element, rc, context, renderConfig);
  353. if (!isExporting) {
  354. renderLinkIcon(element, context, appState);
  355. }
  356. } catch (error: any) {
  357. console.error(error);
  358. }
  359. });
  360. if (appState.editingLinearElement) {
  361. const element = LinearElementEditor.getElement(
  362. appState.editingLinearElement.elementId,
  363. );
  364. if (element) {
  365. renderLinearPointHandles(context, appState, renderConfig, element);
  366. }
  367. }
  368. // Paint selection element
  369. if (appState.selectionElement) {
  370. try {
  371. renderElement(appState.selectionElement, rc, context, renderConfig);
  372. } catch (error: any) {
  373. console.error(error);
  374. }
  375. }
  376. if (isBindingEnabled(appState)) {
  377. appState.suggestedBindings
  378. .filter((binding) => binding != null)
  379. .forEach((suggestedBinding) => {
  380. renderBindingHighlight(context, renderConfig, suggestedBinding!);
  381. });
  382. }
  383. if (
  384. appState.selectedLinearElement &&
  385. appState.selectedLinearElement.hoverPointIndex >= 0
  386. ) {
  387. renderLinearElementPointHighlight(context, appState, renderConfig);
  388. }
  389. // Paint selected elements
  390. if (
  391. renderSelection &&
  392. !appState.multiElement &&
  393. !appState.editingLinearElement
  394. ) {
  395. const locallySelectedElements = getSelectedElements(elements, appState);
  396. const showBoundingBox = shouldShowBoundingBox(
  397. locallySelectedElements,
  398. appState,
  399. );
  400. const locallySelectedIds = locallySelectedElements.map(
  401. (element) => element.id,
  402. );
  403. const isSingleLinearElementSelected =
  404. locallySelectedElements.length === 1 &&
  405. isLinearElement(locallySelectedElements[0]);
  406. // render selected linear element points
  407. if (
  408. isSingleLinearElementSelected &&
  409. appState.selectedLinearElement?.elementId ===
  410. locallySelectedElements[0].id &&
  411. !locallySelectedElements[0].locked
  412. ) {
  413. renderLinearPointHandles(
  414. context,
  415. appState,
  416. renderConfig,
  417. locallySelectedElements[0] as ExcalidrawLinearElement,
  418. );
  419. }
  420. if (showBoundingBox) {
  421. const selections = elements.reduce((acc, element) => {
  422. const selectionColors = [];
  423. // local user
  424. if (
  425. locallySelectedIds.includes(element.id) &&
  426. !isSelectedViaGroup(appState, element)
  427. ) {
  428. selectionColors.push(oc.black);
  429. }
  430. // remote users
  431. if (renderConfig.remoteSelectedElementIds[element.id]) {
  432. selectionColors.push(
  433. ...renderConfig.remoteSelectedElementIds[element.id].map(
  434. (socketId) => {
  435. const { background } = getClientColors(socketId, appState);
  436. return background;
  437. },
  438. ),
  439. );
  440. }
  441. if (selectionColors.length) {
  442. const [elementX1, elementY1, elementX2, elementY2] =
  443. getElementAbsoluteCoords(element);
  444. acc.push({
  445. angle: element.angle,
  446. elementX1,
  447. elementY1,
  448. elementX2,
  449. elementY2,
  450. selectionColors,
  451. });
  452. }
  453. return acc;
  454. }, [] as { angle: number; elementX1: number; elementY1: number; elementX2: number; elementY2: number; selectionColors: string[] }[]);
  455. const addSelectionForGroupId = (groupId: GroupId) => {
  456. const groupElements = getElementsInGroup(elements, groupId);
  457. const [elementX1, elementY1, elementX2, elementY2] =
  458. getCommonBounds(groupElements);
  459. selections.push({
  460. angle: 0,
  461. elementX1,
  462. elementX2,
  463. elementY1,
  464. elementY2,
  465. selectionColors: [oc.black],
  466. });
  467. };
  468. for (const groupId of getSelectedGroupIds(appState)) {
  469. // TODO: support multiplayer selected group IDs
  470. addSelectionForGroupId(groupId);
  471. }
  472. if (appState.editingGroupId) {
  473. addSelectionForGroupId(appState.editingGroupId);
  474. }
  475. selections.forEach((selection) =>
  476. renderSelectionBorder(
  477. context,
  478. renderConfig,
  479. selection,
  480. isSingleLinearElementSelected
  481. ? DEFAULT_SPACING * 2
  482. : DEFAULT_SPACING,
  483. ),
  484. );
  485. }
  486. // Paint resize transformHandles
  487. context.save();
  488. context.translate(renderConfig.scrollX, renderConfig.scrollY);
  489. if (locallySelectedElements.length === 1) {
  490. context.fillStyle = oc.white;
  491. const transformHandles = getTransformHandles(
  492. locallySelectedElements[0],
  493. renderConfig.zoom,
  494. "mouse", // when we render we don't know which pointer type so use mouse
  495. );
  496. if (!appState.viewModeEnabled && showBoundingBox) {
  497. renderTransformHandles(
  498. context,
  499. renderConfig,
  500. transformHandles,
  501. locallySelectedElements[0].angle,
  502. );
  503. }
  504. } else if (locallySelectedElements.length > 1 && !appState.isRotating) {
  505. const dashedLinePadding = 4 / renderConfig.zoom.value;
  506. context.fillStyle = oc.white;
  507. const [x1, y1, x2, y2] = getCommonBounds(locallySelectedElements);
  508. const initialLineDash = context.getLineDash();
  509. context.setLineDash([2 / renderConfig.zoom.value]);
  510. const lineWidth = context.lineWidth;
  511. context.lineWidth = 1 / renderConfig.zoom.value;
  512. strokeRectWithRotation(
  513. context,
  514. x1 - dashedLinePadding,
  515. y1 - dashedLinePadding,
  516. x2 - x1 + dashedLinePadding * 2,
  517. y2 - y1 + dashedLinePadding * 2,
  518. (x1 + x2) / 2,
  519. (y1 + y2) / 2,
  520. 0,
  521. );
  522. context.lineWidth = lineWidth;
  523. context.setLineDash(initialLineDash);
  524. const transformHandles = getTransformHandlesFromCoords(
  525. [x1, y1, x2, y2],
  526. 0,
  527. renderConfig.zoom,
  528. "mouse",
  529. OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
  530. );
  531. if (locallySelectedElements.some((element) => !element.locked)) {
  532. renderTransformHandles(context, renderConfig, transformHandles, 0);
  533. }
  534. }
  535. context.restore();
  536. }
  537. // Reset zoom
  538. context.restore();
  539. // Paint remote pointers
  540. for (const clientId in renderConfig.remotePointerViewportCoords) {
  541. let { x, y } = renderConfig.remotePointerViewportCoords[clientId];
  542. x -= appState.offsetLeft;
  543. y -= appState.offsetTop;
  544. const width = 9;
  545. const height = 14;
  546. const isOutOfBounds =
  547. x < 0 ||
  548. x > normalizedCanvasWidth - width ||
  549. y < 0 ||
  550. y > normalizedCanvasHeight - height;
  551. x = Math.max(x, 0);
  552. x = Math.min(x, normalizedCanvasWidth - width);
  553. y = Math.max(y, 0);
  554. y = Math.min(y, normalizedCanvasHeight - height);
  555. const { background, stroke } = getClientColors(clientId, appState);
  556. context.save();
  557. context.strokeStyle = stroke;
  558. context.fillStyle = background;
  559. const userState = renderConfig.remotePointerUserStates[clientId];
  560. if (isOutOfBounds || userState === UserIdleState.AWAY) {
  561. context.globalAlpha = 0.48;
  562. }
  563. if (
  564. renderConfig.remotePointerButton &&
  565. renderConfig.remotePointerButton[clientId] === "down"
  566. ) {
  567. context.beginPath();
  568. context.arc(x, y, 15, 0, 2 * Math.PI, false);
  569. context.lineWidth = 3;
  570. context.strokeStyle = "#ffffff88";
  571. context.stroke();
  572. context.closePath();
  573. context.beginPath();
  574. context.arc(x, y, 15, 0, 2 * Math.PI, false);
  575. context.lineWidth = 1;
  576. context.strokeStyle = stroke;
  577. context.stroke();
  578. context.closePath();
  579. }
  580. context.beginPath();
  581. context.moveTo(x, y);
  582. context.lineTo(x + 1, y + 14);
  583. context.lineTo(x + 4, y + 9);
  584. context.lineTo(x + 9, y + 10);
  585. context.lineTo(x, y);
  586. context.fill();
  587. context.stroke();
  588. const username = renderConfig.remotePointerUsernames[clientId];
  589. let idleState = "";
  590. if (userState === UserIdleState.AWAY) {
  591. idleState = hasEmojiSupport ? "⚫️" : ` (${UserIdleState.AWAY})`;
  592. } else if (userState === UserIdleState.IDLE) {
  593. idleState = hasEmojiSupport ? "💤" : ` (${UserIdleState.IDLE})`;
  594. } else if (userState === UserIdleState.ACTIVE) {
  595. idleState = hasEmojiSupport ? "🟢" : "";
  596. }
  597. const usernameAndIdleState = `${
  598. username ? `${username} ` : ""
  599. }${idleState}`;
  600. if (!isOutOfBounds && usernameAndIdleState) {
  601. const offsetX = x + width;
  602. const offsetY = y + height;
  603. const paddingHorizontal = 4;
  604. const paddingVertical = 4;
  605. const measure = context.measureText(usernameAndIdleState);
  606. const measureHeight =
  607. measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent;
  608. // Border
  609. context.fillStyle = stroke;
  610. context.fillRect(
  611. offsetX - 1,
  612. offsetY - 1,
  613. measure.width + 2 * paddingHorizontal + 2,
  614. measureHeight + 2 * paddingVertical + 2,
  615. );
  616. // Background
  617. context.fillStyle = background;
  618. context.fillRect(
  619. offsetX,
  620. offsetY,
  621. measure.width + 2 * paddingHorizontal,
  622. measureHeight + 2 * paddingVertical,
  623. );
  624. context.fillStyle = oc.white;
  625. context.fillText(
  626. usernameAndIdleState,
  627. offsetX + paddingHorizontal,
  628. offsetY + paddingVertical + measure.actualBoundingBoxAscent,
  629. );
  630. }
  631. context.restore();
  632. context.closePath();
  633. }
  634. // Paint scrollbars
  635. let scrollBars;
  636. if (renderScrollbars) {
  637. scrollBars = getScrollBars(
  638. elements,
  639. normalizedCanvasWidth,
  640. normalizedCanvasHeight,
  641. renderConfig,
  642. );
  643. context.save();
  644. context.fillStyle = SCROLLBAR_COLOR;
  645. context.strokeStyle = "rgba(255,255,255,0.8)";
  646. [scrollBars.horizontal, scrollBars.vertical].forEach((scrollBar) => {
  647. if (scrollBar) {
  648. roundRect(
  649. context,
  650. scrollBar.x,
  651. scrollBar.y,
  652. scrollBar.width,
  653. scrollBar.height,
  654. SCROLLBAR_WIDTH / 2,
  655. );
  656. }
  657. });
  658. context.restore();
  659. }
  660. context.restore();
  661. return { atLeastOneVisibleElement: visibleElements.length > 0, scrollBars };
  662. };
  663. const renderSceneThrottled = throttleRAF(
  664. (config: {
  665. elements: readonly NonDeletedExcalidrawElement[];
  666. appState: AppState;
  667. scale: number;
  668. rc: RoughCanvas;
  669. canvas: HTMLCanvasElement;
  670. renderConfig: RenderConfig;
  671. callback?: (data: ReturnType<typeof _renderScene>) => void;
  672. }) => {
  673. const ret = _renderScene(config);
  674. config.callback?.(ret);
  675. },
  676. { trailing: true },
  677. );
  678. /** renderScene throttled to animation framerate */
  679. export const renderScene = <T extends boolean = false>(
  680. config: {
  681. elements: readonly NonDeletedExcalidrawElement[];
  682. appState: AppState;
  683. scale: number;
  684. rc: RoughCanvas;
  685. canvas: HTMLCanvasElement;
  686. renderConfig: RenderConfig;
  687. callback?: (data: ReturnType<typeof _renderScene>) => void;
  688. },
  689. /** Whether to throttle rendering. Defaults to false.
  690. * When throttling, no value is returned. Use the callback instead. */
  691. throttle?: T,
  692. ): T extends true ? void : ReturnType<typeof _renderScene> => {
  693. if (throttle) {
  694. renderSceneThrottled(config);
  695. return undefined as T extends true ? void : ReturnType<typeof _renderScene>;
  696. }
  697. const ret = _renderScene(config);
  698. config.callback?.(ret);
  699. return ret as T extends true ? void : ReturnType<typeof _renderScene>;
  700. };
  701. const renderTransformHandles = (
  702. context: CanvasRenderingContext2D,
  703. renderConfig: RenderConfig,
  704. transformHandles: TransformHandles,
  705. angle: number,
  706. ): void => {
  707. Object.keys(transformHandles).forEach((key) => {
  708. const transformHandle = transformHandles[key as TransformHandleType];
  709. if (transformHandle !== undefined) {
  710. const [x, y, width, height] = transformHandle;
  711. context.save();
  712. context.lineWidth = 1 / renderConfig.zoom.value;
  713. if (key === "rotation") {
  714. fillCircle(context, x + width / 2, y + height / 2, width / 2);
  715. } else {
  716. strokeRectWithRotation(
  717. context,
  718. x,
  719. y,
  720. width,
  721. height,
  722. x + width / 2,
  723. y + height / 2,
  724. angle,
  725. true, // fill before stroke
  726. );
  727. }
  728. context.restore();
  729. }
  730. });
  731. };
  732. const renderSelectionBorder = (
  733. context: CanvasRenderingContext2D,
  734. renderConfig: RenderConfig,
  735. elementProperties: {
  736. angle: number;
  737. elementX1: number;
  738. elementY1: number;
  739. elementX2: number;
  740. elementY2: number;
  741. selectionColors: string[];
  742. },
  743. padding = 4,
  744. ) => {
  745. const { angle, elementX1, elementY1, elementX2, elementY2, selectionColors } =
  746. elementProperties;
  747. const elementWidth = elementX2 - elementX1;
  748. const elementHeight = elementY2 - elementY1;
  749. const dashedLinePadding = padding / renderConfig.zoom.value;
  750. const dashWidth = 8 / renderConfig.zoom.value;
  751. const spaceWidth = 4 / renderConfig.zoom.value;
  752. context.save();
  753. context.translate(renderConfig.scrollX, renderConfig.scrollY);
  754. context.lineWidth = 1 / renderConfig.zoom.value;
  755. const count = selectionColors.length;
  756. for (let index = 0; index < count; ++index) {
  757. context.strokeStyle = selectionColors[index];
  758. context.setLineDash([
  759. dashWidth,
  760. spaceWidth + (dashWidth + spaceWidth) * (count - 1),
  761. ]);
  762. context.lineDashOffset = (dashWidth + spaceWidth) * index;
  763. strokeRectWithRotation(
  764. context,
  765. elementX1 - dashedLinePadding,
  766. elementY1 - dashedLinePadding,
  767. elementWidth + dashedLinePadding * 2,
  768. elementHeight + dashedLinePadding * 2,
  769. elementX1 + elementWidth / 2,
  770. elementY1 + elementHeight / 2,
  771. angle,
  772. );
  773. }
  774. context.restore();
  775. };
  776. const renderBindingHighlight = (
  777. context: CanvasRenderingContext2D,
  778. renderConfig: RenderConfig,
  779. suggestedBinding: SuggestedBinding,
  780. ) => {
  781. const renderHighlight = Array.isArray(suggestedBinding)
  782. ? renderBindingHighlightForSuggestedPointBinding
  783. : renderBindingHighlightForBindableElement;
  784. context.save();
  785. context.translate(renderConfig.scrollX, renderConfig.scrollY);
  786. renderHighlight(context, suggestedBinding as any);
  787. context.restore();
  788. };
  789. const renderBindingHighlightForBindableElement = (
  790. context: CanvasRenderingContext2D,
  791. element: ExcalidrawBindableElement,
  792. ) => {
  793. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  794. const width = x2 - x1;
  795. const height = y2 - y1;
  796. const threshold = maxBindingGap(element, width, height);
  797. // So that we don't overlap the element itself
  798. const strokeOffset = 4;
  799. context.strokeStyle = "rgba(0,0,0,.05)";
  800. context.lineWidth = threshold - strokeOffset;
  801. const padding = strokeOffset / 2 + threshold / 2;
  802. switch (element.type) {
  803. case "rectangle":
  804. case "text":
  805. case "image":
  806. strokeRectWithRotation(
  807. context,
  808. x1 - padding,
  809. y1 - padding,
  810. width + padding * 2,
  811. height + padding * 2,
  812. x1 + width / 2,
  813. y1 + height / 2,
  814. element.angle,
  815. );
  816. break;
  817. case "diamond":
  818. const side = Math.hypot(width, height);
  819. const wPadding = (padding * side) / height;
  820. const hPadding = (padding * side) / width;
  821. strokeDiamondWithRotation(
  822. context,
  823. width + wPadding * 2,
  824. height + hPadding * 2,
  825. x1 + width / 2,
  826. y1 + height / 2,
  827. element.angle,
  828. );
  829. break;
  830. case "ellipse":
  831. strokeEllipseWithRotation(
  832. context,
  833. width + padding * 2,
  834. height + padding * 2,
  835. x1 + width / 2,
  836. y1 + height / 2,
  837. element.angle,
  838. );
  839. break;
  840. }
  841. };
  842. const renderBindingHighlightForSuggestedPointBinding = (
  843. context: CanvasRenderingContext2D,
  844. suggestedBinding: SuggestedPointBinding,
  845. ) => {
  846. const [element, startOrEnd, bindableElement] = suggestedBinding;
  847. const threshold = maxBindingGap(
  848. bindableElement,
  849. bindableElement.width,
  850. bindableElement.height,
  851. );
  852. context.strokeStyle = "rgba(0,0,0,0)";
  853. context.fillStyle = "rgba(0,0,0,.05)";
  854. const pointIndices =
  855. startOrEnd === "both" ? [0, -1] : startOrEnd === "start" ? [0] : [-1];
  856. pointIndices.forEach((index) => {
  857. const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
  858. element,
  859. index,
  860. );
  861. fillCircle(context, x, y, threshold);
  862. });
  863. };
  864. let linkCanvasCache: any;
  865. const renderLinkIcon = (
  866. element: NonDeletedExcalidrawElement,
  867. context: CanvasRenderingContext2D,
  868. appState: AppState,
  869. ) => {
  870. if (element.link && !appState.selectedElementIds[element.id]) {
  871. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  872. const [x, y, width, height] = getLinkHandleFromCoords(
  873. [x1, y1, x2, y2],
  874. element.angle,
  875. appState,
  876. );
  877. const centerX = x + width / 2;
  878. const centerY = y + height / 2;
  879. context.save();
  880. context.translate(appState.scrollX + centerX, appState.scrollY + centerY);
  881. context.rotate(element.angle);
  882. if (!linkCanvasCache || linkCanvasCache.zoom !== appState.zoom.value) {
  883. linkCanvasCache = document.createElement("canvas");
  884. linkCanvasCache.zoom = appState.zoom.value;
  885. linkCanvasCache.width =
  886. width * window.devicePixelRatio * appState.zoom.value;
  887. linkCanvasCache.height =
  888. height * window.devicePixelRatio * appState.zoom.value;
  889. const linkCanvasCacheContext = linkCanvasCache.getContext("2d")!;
  890. linkCanvasCacheContext.scale(
  891. window.devicePixelRatio * appState.zoom.value,
  892. window.devicePixelRatio * appState.zoom.value,
  893. );
  894. linkCanvasCacheContext.fillStyle = "#fff";
  895. linkCanvasCacheContext.fillRect(0, 0, width, height);
  896. linkCanvasCacheContext.drawImage(EXTERNAL_LINK_IMG, 0, 0, width, height);
  897. linkCanvasCacheContext.restore();
  898. context.drawImage(
  899. linkCanvasCache,
  900. x - centerX,
  901. y - centerY,
  902. width,
  903. height,
  904. );
  905. } else {
  906. context.drawImage(
  907. linkCanvasCache,
  908. x - centerX,
  909. y - centerY,
  910. width,
  911. height,
  912. );
  913. }
  914. context.restore();
  915. }
  916. };
  917. const isVisibleElement = (
  918. element: ExcalidrawElement,
  919. canvasWidth: number,
  920. canvasHeight: number,
  921. viewTransformations: {
  922. zoom: Zoom;
  923. offsetLeft: number;
  924. offsetTop: number;
  925. scrollX: number;
  926. scrollY: number;
  927. },
  928. ) => {
  929. const [x1, y1, x2, y2] = getElementBounds(element); // scene coordinates
  930. const topLeftSceneCoords = viewportCoordsToSceneCoords(
  931. {
  932. clientX: viewTransformations.offsetLeft,
  933. clientY: viewTransformations.offsetTop,
  934. },
  935. viewTransformations,
  936. );
  937. const bottomRightSceneCoords = viewportCoordsToSceneCoords(
  938. {
  939. clientX: viewTransformations.offsetLeft + canvasWidth,
  940. clientY: viewTransformations.offsetTop + canvasHeight,
  941. },
  942. viewTransformations,
  943. );
  944. return (
  945. topLeftSceneCoords.x <= x2 &&
  946. topLeftSceneCoords.y <= y2 &&
  947. bottomRightSceneCoords.x >= x1 &&
  948. bottomRightSceneCoords.y >= y1
  949. );
  950. };
  951. // This should be only called for exporting purposes
  952. export const renderSceneToSvg = (
  953. elements: readonly NonDeletedExcalidrawElement[],
  954. rsvg: RoughSVG,
  955. svgRoot: SVGElement,
  956. files: BinaryFiles,
  957. {
  958. offsetX = 0,
  959. offsetY = 0,
  960. exportWithDarkMode = false,
  961. }: {
  962. offsetX?: number;
  963. offsetY?: number;
  964. exportWithDarkMode?: boolean;
  965. } = {},
  966. ) => {
  967. if (!svgRoot) {
  968. return;
  969. }
  970. // render elements
  971. elements.forEach((element) => {
  972. if (!element.isDeleted) {
  973. try {
  974. renderElementToSvg(
  975. element,
  976. rsvg,
  977. svgRoot,
  978. files,
  979. element.x + offsetX,
  980. element.y + offsetY,
  981. exportWithDarkMode,
  982. );
  983. } catch (error: any) {
  984. console.error(error);
  985. }
  986. }
  987. });
  988. };