renderScene.ts 41 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516
  1. import { RoughSVG } from "roughjs/bin/svg";
  2. import oc from "open-color";
  3. import {
  4. InteractiveCanvasAppState,
  5. StaticCanvasAppState,
  6. BinaryFiles,
  7. Point,
  8. Zoom,
  9. AppState,
  10. } from "../types";
  11. import {
  12. ExcalidrawElement,
  13. NonDeletedExcalidrawElement,
  14. ExcalidrawLinearElement,
  15. NonDeleted,
  16. GroupId,
  17. ExcalidrawBindableElement,
  18. ExcalidrawFrameElement,
  19. } from "../element/types";
  20. import {
  21. getElementAbsoluteCoords,
  22. OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
  23. getTransformHandlesFromCoords,
  24. getTransformHandles,
  25. getCommonBounds,
  26. } from "../element";
  27. import { roundRect } from "./roundRect";
  28. import {
  29. InteractiveCanvasRenderConfig,
  30. InteractiveSceneRenderConfig,
  31. StaticCanvasRenderConfig,
  32. StaticSceneRenderConfig,
  33. } from "../scene/types";
  34. import {
  35. getScrollBars,
  36. SCROLLBAR_COLOR,
  37. SCROLLBAR_WIDTH,
  38. } from "../scene/scrollbars";
  39. import {
  40. renderElement,
  41. renderElementToSvg,
  42. renderSelectionElement,
  43. } from "./renderElement";
  44. import { getClientColor } from "../clients";
  45. import { LinearElementEditor } from "../element/linearElementEditor";
  46. import {
  47. isSelectedViaGroup,
  48. getSelectedGroupIds,
  49. getElementsInGroup,
  50. selectGroupsFromGivenElements,
  51. } from "../groups";
  52. import { maxBindingGap } from "../element/collision";
  53. import { SuggestedBinding, SuggestedPointBinding } from "../element/binding";
  54. import {
  55. OMIT_SIDES_FOR_FRAME,
  56. shouldShowBoundingBox,
  57. TransformHandles,
  58. TransformHandleType,
  59. } from "../element/transformHandles";
  60. import { throttleRAF, isOnlyExportingSingleFrame } from "../utils";
  61. import { UserIdleState } from "../types";
  62. import { FRAME_STYLE, THEME_FILTER } from "../constants";
  63. import {
  64. EXTERNAL_LINK_IMG,
  65. getLinkHandleFromCoords,
  66. } from "../element/Hyperlink";
  67. import {
  68. isEmbeddableElement,
  69. isFrameElement,
  70. isLinearElement,
  71. } from "../element/typeChecks";
  72. import {
  73. isEmbeddableOrFrameLabel,
  74. createPlaceholderEmbeddableLabel,
  75. } from "../element/embeddable";
  76. import {
  77. elementOverlapsWithFrame,
  78. getTargetFrame,
  79. isElementInFrame,
  80. } from "../frame";
  81. import "canvas-roundrect-polyfill";
  82. export const DEFAULT_SPACING = 2;
  83. const strokeRectWithRotation = (
  84. context: CanvasRenderingContext2D,
  85. x: number,
  86. y: number,
  87. width: number,
  88. height: number,
  89. cx: number,
  90. cy: number,
  91. angle: number,
  92. fill: boolean = false,
  93. /** should account for zoom */
  94. radius: number = 0,
  95. ) => {
  96. context.save();
  97. context.translate(cx, cy);
  98. context.rotate(angle);
  99. if (fill) {
  100. context.fillRect(x - cx, y - cy, width, height);
  101. }
  102. if (radius && context.roundRect) {
  103. context.beginPath();
  104. context.roundRect(x - cx, y - cy, width, height, radius);
  105. context.stroke();
  106. context.closePath();
  107. } else {
  108. context.strokeRect(x - cx, y - cy, width, height);
  109. }
  110. context.restore();
  111. };
  112. const strokeDiamondWithRotation = (
  113. context: CanvasRenderingContext2D,
  114. width: number,
  115. height: number,
  116. cx: number,
  117. cy: number,
  118. angle: number,
  119. ) => {
  120. context.save();
  121. context.translate(cx, cy);
  122. context.rotate(angle);
  123. context.beginPath();
  124. context.moveTo(0, height / 2);
  125. context.lineTo(width / 2, 0);
  126. context.lineTo(0, -height / 2);
  127. context.lineTo(-width / 2, 0);
  128. context.closePath();
  129. context.stroke();
  130. context.restore();
  131. };
  132. const strokeEllipseWithRotation = (
  133. context: CanvasRenderingContext2D,
  134. width: number,
  135. height: number,
  136. cx: number,
  137. cy: number,
  138. angle: number,
  139. ) => {
  140. context.beginPath();
  141. context.ellipse(cx, cy, width / 2, height / 2, angle, 0, Math.PI * 2);
  142. context.stroke();
  143. };
  144. const fillCircle = (
  145. context: CanvasRenderingContext2D,
  146. cx: number,
  147. cy: number,
  148. radius: number,
  149. stroke = true,
  150. ) => {
  151. context.beginPath();
  152. context.arc(cx, cy, radius, 0, Math.PI * 2);
  153. context.fill();
  154. if (stroke) {
  155. context.stroke();
  156. }
  157. };
  158. const strokeGrid = (
  159. context: CanvasRenderingContext2D,
  160. gridSize: number,
  161. scrollX: number,
  162. scrollY: number,
  163. zoom: Zoom,
  164. width: number,
  165. height: number,
  166. ) => {
  167. const BOLD_LINE_FREQUENCY = 5;
  168. enum GridLineColor {
  169. Bold = "#cccccc",
  170. Regular = "#e5e5e5",
  171. }
  172. const offsetX =
  173. -Math.round(zoom.value / gridSize) * gridSize + (scrollX % gridSize);
  174. const offsetY =
  175. -Math.round(zoom.value / gridSize) * gridSize + (scrollY % gridSize);
  176. const lineWidth = Math.min(1 / zoom.value, 1);
  177. const spaceWidth = 1 / zoom.value;
  178. const lineDash = [lineWidth * 3, spaceWidth + (lineWidth + spaceWidth)];
  179. context.save();
  180. context.lineWidth = lineWidth;
  181. for (let x = offsetX; x < offsetX + width + gridSize * 2; x += gridSize) {
  182. const isBold =
  183. Math.round(x - scrollX) % (BOLD_LINE_FREQUENCY * gridSize) === 0;
  184. context.beginPath();
  185. context.setLineDash(isBold ? [] : lineDash);
  186. context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular;
  187. context.moveTo(x, offsetY - gridSize);
  188. context.lineTo(x, offsetY + height + gridSize * 2);
  189. context.stroke();
  190. }
  191. for (let y = offsetY; y < offsetY + height + gridSize * 2; y += gridSize) {
  192. const isBold =
  193. Math.round(y - scrollY) % (BOLD_LINE_FREQUENCY * gridSize) === 0;
  194. context.beginPath();
  195. context.setLineDash(isBold ? [] : lineDash);
  196. context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular;
  197. context.moveTo(offsetX - gridSize, y);
  198. context.lineTo(offsetX + width + gridSize * 2, y);
  199. context.stroke();
  200. }
  201. context.restore();
  202. };
  203. const renderSingleLinearPoint = (
  204. context: CanvasRenderingContext2D,
  205. appState: InteractiveCanvasAppState,
  206. point: Point,
  207. radius: number,
  208. isSelected: boolean,
  209. isPhantomPoint = false,
  210. ) => {
  211. context.strokeStyle = "#5e5ad8";
  212. context.setLineDash([]);
  213. context.fillStyle = "rgba(255, 255, 255, 0.9)";
  214. if (isSelected) {
  215. context.fillStyle = "rgba(134, 131, 226, 0.9)";
  216. } else if (isPhantomPoint) {
  217. context.fillStyle = "rgba(177, 151, 252, 0.7)";
  218. }
  219. fillCircle(
  220. context,
  221. point[0],
  222. point[1],
  223. radius / appState.zoom.value,
  224. !isPhantomPoint,
  225. );
  226. };
  227. const renderLinearPointHandles = (
  228. context: CanvasRenderingContext2D,
  229. appState: InteractiveCanvasAppState,
  230. element: NonDeleted<ExcalidrawLinearElement>,
  231. ) => {
  232. if (!appState.selectedLinearElement) {
  233. return;
  234. }
  235. context.save();
  236. context.translate(appState.scrollX, appState.scrollY);
  237. context.lineWidth = 1 / appState.zoom.value;
  238. const points = LinearElementEditor.getPointsGlobalCoordinates(element);
  239. const { POINT_HANDLE_SIZE } = LinearElementEditor;
  240. const radius = appState.editingLinearElement
  241. ? POINT_HANDLE_SIZE
  242. : POINT_HANDLE_SIZE / 2;
  243. points.forEach((point, idx) => {
  244. const isSelected =
  245. !!appState.editingLinearElement?.selectedPointsIndices?.includes(idx);
  246. renderSingleLinearPoint(context, appState, point, radius, isSelected);
  247. });
  248. //Rendering segment mid points
  249. const midPoints = LinearElementEditor.getEditorMidPoints(
  250. element,
  251. appState,
  252. ).filter((midPoint) => midPoint !== null) as Point[];
  253. midPoints.forEach((segmentMidPoint) => {
  254. if (
  255. appState?.selectedLinearElement?.segmentMidPointHoveredCoords &&
  256. LinearElementEditor.arePointsEqual(
  257. segmentMidPoint,
  258. appState.selectedLinearElement.segmentMidPointHoveredCoords,
  259. )
  260. ) {
  261. // The order of renderingSingleLinearPoint and highLight points is different
  262. // inside vs outside editor as hover states are different,
  263. // in editor when hovered the original point is not visible as hover state fully covers it whereas outside the
  264. // editor original point is visible and hover state is just an outer circle.
  265. if (appState.editingLinearElement) {
  266. renderSingleLinearPoint(
  267. context,
  268. appState,
  269. segmentMidPoint,
  270. radius,
  271. false,
  272. );
  273. highlightPoint(segmentMidPoint, context, appState);
  274. } else {
  275. highlightPoint(segmentMidPoint, context, appState);
  276. renderSingleLinearPoint(
  277. context,
  278. appState,
  279. segmentMidPoint,
  280. radius,
  281. false,
  282. );
  283. }
  284. } else if (appState.editingLinearElement || points.length === 2) {
  285. renderSingleLinearPoint(
  286. context,
  287. appState,
  288. segmentMidPoint,
  289. POINT_HANDLE_SIZE / 2,
  290. false,
  291. true,
  292. );
  293. }
  294. });
  295. context.restore();
  296. };
  297. const highlightPoint = (
  298. point: Point,
  299. context: CanvasRenderingContext2D,
  300. appState: InteractiveCanvasAppState,
  301. ) => {
  302. context.fillStyle = "rgba(105, 101, 219, 0.4)";
  303. fillCircle(
  304. context,
  305. point[0],
  306. point[1],
  307. LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value,
  308. false,
  309. );
  310. };
  311. const renderLinearElementPointHighlight = (
  312. context: CanvasRenderingContext2D,
  313. appState: InteractiveCanvasAppState,
  314. ) => {
  315. const { elementId, hoverPointIndex } = appState.selectedLinearElement!;
  316. if (
  317. appState.editingLinearElement?.selectedPointsIndices?.includes(
  318. hoverPointIndex,
  319. )
  320. ) {
  321. return;
  322. }
  323. const element = LinearElementEditor.getElement(elementId);
  324. if (!element) {
  325. return;
  326. }
  327. const point = LinearElementEditor.getPointAtIndexGlobalCoordinates(
  328. element,
  329. hoverPointIndex,
  330. );
  331. context.save();
  332. context.translate(appState.scrollX, appState.scrollY);
  333. highlightPoint(point, context, appState);
  334. context.restore();
  335. };
  336. const frameClip = (
  337. frame: ExcalidrawFrameElement,
  338. context: CanvasRenderingContext2D,
  339. renderConfig: StaticCanvasRenderConfig,
  340. appState: StaticCanvasAppState,
  341. ) => {
  342. context.translate(frame.x + appState.scrollX, frame.y + appState.scrollY);
  343. context.beginPath();
  344. if (context.roundRect && !renderConfig.isExporting) {
  345. context.roundRect(
  346. 0,
  347. 0,
  348. frame.width,
  349. frame.height,
  350. FRAME_STYLE.radius / appState.zoom.value,
  351. );
  352. } else {
  353. context.rect(0, 0, frame.width, frame.height);
  354. }
  355. context.clip();
  356. context.translate(
  357. -(frame.x + appState.scrollX),
  358. -(frame.y + appState.scrollY),
  359. );
  360. };
  361. const getNormalizedCanvasDimensions = (
  362. canvas: HTMLCanvasElement,
  363. scale: number,
  364. ): [number, number] => {
  365. // When doing calculations based on canvas width we should used normalized one
  366. return [canvas.width / scale, canvas.height / scale];
  367. };
  368. const bootstrapCanvas = ({
  369. canvas,
  370. scale,
  371. normalizedWidth,
  372. normalizedHeight,
  373. theme,
  374. isExporting,
  375. viewBackgroundColor,
  376. }: {
  377. canvas: HTMLCanvasElement;
  378. scale: number;
  379. normalizedWidth: number;
  380. normalizedHeight: number;
  381. theme?: AppState["theme"];
  382. isExporting?: StaticCanvasRenderConfig["isExporting"];
  383. viewBackgroundColor?: StaticCanvasAppState["viewBackgroundColor"];
  384. }): CanvasRenderingContext2D => {
  385. const context = canvas.getContext("2d")!;
  386. context.setTransform(1, 0, 0, 1, 0, 0);
  387. context.scale(scale, scale);
  388. if (isExporting && theme === "dark") {
  389. context.filter = THEME_FILTER;
  390. }
  391. // Paint background
  392. if (typeof viewBackgroundColor === "string") {
  393. const hasTransparence =
  394. viewBackgroundColor === "transparent" ||
  395. viewBackgroundColor.length === 5 || // #RGBA
  396. viewBackgroundColor.length === 9 || // #RRGGBBA
  397. /(hsla|rgba)\(/.test(viewBackgroundColor);
  398. if (hasTransparence) {
  399. context.clearRect(0, 0, normalizedWidth, normalizedHeight);
  400. }
  401. context.save();
  402. context.fillStyle = viewBackgroundColor;
  403. context.fillRect(0, 0, normalizedWidth, normalizedHeight);
  404. context.restore();
  405. } else {
  406. context.clearRect(0, 0, normalizedWidth, normalizedHeight);
  407. }
  408. return context;
  409. };
  410. const _renderInteractiveScene = ({
  411. canvas,
  412. elements,
  413. visibleElements,
  414. selectedElements,
  415. scale,
  416. appState,
  417. renderConfig,
  418. }: InteractiveSceneRenderConfig) => {
  419. if (canvas === null) {
  420. return { atLeastOneVisibleElement: false, elements };
  421. }
  422. const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
  423. canvas,
  424. scale,
  425. );
  426. const context = bootstrapCanvas({
  427. canvas,
  428. scale,
  429. normalizedWidth,
  430. normalizedHeight,
  431. });
  432. // Apply zoom
  433. context.save();
  434. context.scale(appState.zoom.value, appState.zoom.value);
  435. let editingLinearElement: NonDeleted<ExcalidrawLinearElement> | undefined =
  436. undefined;
  437. visibleElements.forEach((element) => {
  438. // Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
  439. // ShapeCache returns empty hence making sure that we get the
  440. // correct element from visible elements
  441. if (appState.editingLinearElement?.elementId === element.id) {
  442. if (element) {
  443. editingLinearElement = element as NonDeleted<ExcalidrawLinearElement>;
  444. }
  445. }
  446. });
  447. if (editingLinearElement) {
  448. renderLinearPointHandles(context, appState, editingLinearElement);
  449. }
  450. // Paint selection element
  451. if (appState.selectionElement) {
  452. try {
  453. renderSelectionElement(appState.selectionElement, context, appState);
  454. } catch (error: any) {
  455. console.error(error);
  456. }
  457. }
  458. if (appState.isBindingEnabled) {
  459. appState.suggestedBindings
  460. .filter((binding) => binding != null)
  461. .forEach((suggestedBinding) => {
  462. renderBindingHighlight(context, appState, suggestedBinding!);
  463. });
  464. }
  465. if (appState.frameToHighlight) {
  466. renderFrameHighlight(context, appState, appState.frameToHighlight);
  467. }
  468. if (appState.elementsToHighlight) {
  469. renderElementsBoxHighlight(context, appState, appState.elementsToHighlight);
  470. }
  471. const isFrameSelected = selectedElements.some((element) =>
  472. isFrameElement(element),
  473. );
  474. // Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
  475. // ShapeCache returns empty hence making sure that we get the
  476. // correct element from visible elements
  477. if (
  478. selectedElements.length === 1 &&
  479. appState.editingLinearElement?.elementId === selectedElements[0].id
  480. ) {
  481. renderLinearPointHandles(
  482. context,
  483. appState,
  484. selectedElements[0] as NonDeleted<ExcalidrawLinearElement>,
  485. );
  486. }
  487. if (
  488. appState.selectedLinearElement &&
  489. appState.selectedLinearElement.hoverPointIndex >= 0
  490. ) {
  491. renderLinearElementPointHighlight(context, appState);
  492. }
  493. // Paint selected elements
  494. if (!appState.multiElement && !appState.editingLinearElement) {
  495. const showBoundingBox = shouldShowBoundingBox(selectedElements, appState);
  496. const isSingleLinearElementSelected =
  497. selectedElements.length === 1 && isLinearElement(selectedElements[0]);
  498. // render selected linear element points
  499. if (
  500. isSingleLinearElementSelected &&
  501. appState.selectedLinearElement?.elementId === selectedElements[0].id &&
  502. !selectedElements[0].locked
  503. ) {
  504. renderLinearPointHandles(
  505. context,
  506. appState,
  507. selectedElements[0] as ExcalidrawLinearElement,
  508. );
  509. }
  510. const selectionColor = renderConfig.selectionColor || oc.black;
  511. if (showBoundingBox) {
  512. // Optimisation for finding quickly relevant element ids
  513. const locallySelectedIds = selectedElements.reduce(
  514. (acc: Record<string, boolean>, element) => {
  515. acc[element.id] = true;
  516. return acc;
  517. },
  518. {},
  519. );
  520. const selections = elements.reduce(
  521. (
  522. acc: {
  523. angle: number;
  524. elementX1: number;
  525. elementY1: number;
  526. elementX2: number;
  527. elementY2: number;
  528. selectionColors: string[];
  529. dashed?: boolean;
  530. cx: number;
  531. cy: number;
  532. activeEmbeddable: boolean;
  533. }[],
  534. element,
  535. ) => {
  536. const selectionColors = [];
  537. // local user
  538. if (
  539. locallySelectedIds[element.id] &&
  540. !isSelectedViaGroup(appState, element)
  541. ) {
  542. selectionColors.push(selectionColor);
  543. }
  544. // remote users
  545. if (renderConfig.remoteSelectedElementIds[element.id]) {
  546. selectionColors.push(
  547. ...renderConfig.remoteSelectedElementIds[element.id].map(
  548. (socketId: string) => {
  549. const background = getClientColor(socketId);
  550. return background;
  551. },
  552. ),
  553. );
  554. }
  555. if (selectionColors.length) {
  556. const [elementX1, elementY1, elementX2, elementY2, cx, cy] =
  557. getElementAbsoluteCoords(element, true);
  558. acc.push({
  559. angle: element.angle,
  560. elementX1,
  561. elementY1,
  562. elementX2,
  563. elementY2,
  564. selectionColors,
  565. dashed: !!renderConfig.remoteSelectedElementIds[element.id],
  566. cx,
  567. cy,
  568. activeEmbeddable:
  569. appState.activeEmbeddable?.element === element &&
  570. appState.activeEmbeddable.state === "active",
  571. });
  572. }
  573. return acc;
  574. },
  575. [],
  576. );
  577. const addSelectionForGroupId = (groupId: GroupId) => {
  578. const groupElements = getElementsInGroup(elements, groupId);
  579. const [elementX1, elementY1, elementX2, elementY2] =
  580. getCommonBounds(groupElements);
  581. selections.push({
  582. angle: 0,
  583. elementX1,
  584. elementX2,
  585. elementY1,
  586. elementY2,
  587. selectionColors: [oc.black],
  588. dashed: true,
  589. cx: elementX1 + (elementX2 - elementX1) / 2,
  590. cy: elementY1 + (elementY2 - elementY1) / 2,
  591. activeEmbeddable: false,
  592. });
  593. };
  594. for (const groupId of getSelectedGroupIds(appState)) {
  595. // TODO: support multiplayer selected group IDs
  596. addSelectionForGroupId(groupId);
  597. }
  598. if (appState.editingGroupId) {
  599. addSelectionForGroupId(appState.editingGroupId);
  600. }
  601. selections.forEach((selection) =>
  602. renderSelectionBorder(context, appState, selection),
  603. );
  604. }
  605. // Paint resize transformHandles
  606. context.save();
  607. context.translate(appState.scrollX, appState.scrollY);
  608. if (selectedElements.length === 1) {
  609. context.fillStyle = oc.white;
  610. const transformHandles = getTransformHandles(
  611. selectedElements[0],
  612. appState.zoom,
  613. "mouse", // when we render we don't know which pointer type so use mouse
  614. );
  615. if (!appState.viewModeEnabled && showBoundingBox) {
  616. renderTransformHandles(
  617. context,
  618. renderConfig,
  619. appState,
  620. transformHandles,
  621. selectedElements[0].angle,
  622. );
  623. }
  624. } else if (selectedElements.length > 1 && !appState.isRotating) {
  625. const dashedLinePadding = (DEFAULT_SPACING * 2) / appState.zoom.value;
  626. context.fillStyle = oc.white;
  627. const [x1, y1, x2, y2] = getCommonBounds(selectedElements);
  628. const initialLineDash = context.getLineDash();
  629. context.setLineDash([2 / appState.zoom.value]);
  630. const lineWidth = context.lineWidth;
  631. context.lineWidth = 1 / appState.zoom.value;
  632. context.strokeStyle = selectionColor;
  633. strokeRectWithRotation(
  634. context,
  635. x1 - dashedLinePadding,
  636. y1 - dashedLinePadding,
  637. x2 - x1 + dashedLinePadding * 2,
  638. y2 - y1 + dashedLinePadding * 2,
  639. (x1 + x2) / 2,
  640. (y1 + y2) / 2,
  641. 0,
  642. );
  643. context.lineWidth = lineWidth;
  644. context.setLineDash(initialLineDash);
  645. const transformHandles = getTransformHandlesFromCoords(
  646. [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
  647. 0,
  648. appState.zoom,
  649. "mouse",
  650. isFrameSelected
  651. ? OMIT_SIDES_FOR_FRAME
  652. : OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
  653. );
  654. if (selectedElements.some((element) => !element.locked)) {
  655. renderTransformHandles(
  656. context,
  657. renderConfig,
  658. appState,
  659. transformHandles,
  660. 0,
  661. );
  662. }
  663. }
  664. context.restore();
  665. }
  666. // Reset zoom
  667. context.restore();
  668. // Paint remote pointers
  669. for (const clientId in renderConfig.remotePointerViewportCoords) {
  670. let { x, y } = renderConfig.remotePointerViewportCoords[clientId];
  671. x -= appState.offsetLeft;
  672. y -= appState.offsetTop;
  673. const width = 11;
  674. const height = 14;
  675. const isOutOfBounds =
  676. x < 0 ||
  677. x > normalizedWidth - width ||
  678. y < 0 ||
  679. y > normalizedHeight - height;
  680. x = Math.max(x, 0);
  681. x = Math.min(x, normalizedWidth - width);
  682. y = Math.max(y, 0);
  683. y = Math.min(y, normalizedHeight - height);
  684. const background = getClientColor(clientId);
  685. context.save();
  686. context.strokeStyle = background;
  687. context.fillStyle = background;
  688. const userState = renderConfig.remotePointerUserStates[clientId];
  689. const isInactive =
  690. isOutOfBounds ||
  691. userState === UserIdleState.IDLE ||
  692. userState === UserIdleState.AWAY;
  693. if (isInactive) {
  694. context.globalAlpha = 0.3;
  695. }
  696. if (
  697. renderConfig.remotePointerButton &&
  698. renderConfig.remotePointerButton[clientId] === "down"
  699. ) {
  700. context.beginPath();
  701. context.arc(x, y, 15, 0, 2 * Math.PI, false);
  702. context.lineWidth = 3;
  703. context.strokeStyle = "#ffffff88";
  704. context.stroke();
  705. context.closePath();
  706. context.beginPath();
  707. context.arc(x, y, 15, 0, 2 * Math.PI, false);
  708. context.lineWidth = 1;
  709. context.strokeStyle = background;
  710. context.stroke();
  711. context.closePath();
  712. }
  713. // Background (white outline) for arrow
  714. context.fillStyle = oc.white;
  715. context.strokeStyle = oc.white;
  716. context.lineWidth = 6;
  717. context.lineJoin = "round";
  718. context.beginPath();
  719. context.moveTo(x, y);
  720. context.lineTo(x + 0, y + 14);
  721. context.lineTo(x + 4, y + 9);
  722. context.lineTo(x + 11, y + 8);
  723. context.closePath();
  724. context.stroke();
  725. context.fill();
  726. // Arrow
  727. context.fillStyle = background;
  728. context.strokeStyle = background;
  729. context.lineWidth = 2;
  730. context.lineJoin = "round";
  731. context.beginPath();
  732. if (isInactive) {
  733. context.moveTo(x - 1, y - 1);
  734. context.lineTo(x - 1, y + 15);
  735. context.lineTo(x + 5, y + 10);
  736. context.lineTo(x + 12, y + 9);
  737. context.closePath();
  738. context.fill();
  739. } else {
  740. context.moveTo(x, y);
  741. context.lineTo(x + 0, y + 14);
  742. context.lineTo(x + 4, y + 9);
  743. context.lineTo(x + 11, y + 8);
  744. context.closePath();
  745. context.fill();
  746. context.stroke();
  747. }
  748. const username = renderConfig.remotePointerUsernames[clientId] || "";
  749. if (!isOutOfBounds && username) {
  750. context.font = "600 12px sans-serif"; // font has to be set before context.measureText()
  751. const offsetX = x + width / 2;
  752. const offsetY = y + height + 2;
  753. const paddingHorizontal = 5;
  754. const paddingVertical = 3;
  755. const measure = context.measureText(username);
  756. const measureHeight =
  757. measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent;
  758. const finalHeight = Math.max(measureHeight, 12);
  759. const boxX = offsetX - 1;
  760. const boxY = offsetY - 1;
  761. const boxWidth = measure.width + 2 + paddingHorizontal * 2 + 2;
  762. const boxHeight = finalHeight + 2 + paddingVertical * 2 + 2;
  763. if (context.roundRect) {
  764. context.beginPath();
  765. context.roundRect(boxX, boxY, boxWidth, boxHeight, 8);
  766. context.fillStyle = background;
  767. context.fill();
  768. context.strokeStyle = oc.white;
  769. context.stroke();
  770. } else {
  771. roundRect(context, boxX, boxY, boxWidth, boxHeight, 8, oc.white);
  772. }
  773. context.fillStyle = oc.black;
  774. context.fillText(
  775. username,
  776. offsetX + paddingHorizontal + 1,
  777. offsetY +
  778. paddingVertical +
  779. measure.actualBoundingBoxAscent +
  780. Math.floor((finalHeight - measureHeight) / 2) +
  781. 2,
  782. );
  783. }
  784. context.restore();
  785. context.closePath();
  786. }
  787. // Paint scrollbars
  788. let scrollBars;
  789. if (renderConfig.renderScrollbars) {
  790. scrollBars = getScrollBars(
  791. elements,
  792. normalizedWidth,
  793. normalizedHeight,
  794. appState,
  795. );
  796. context.save();
  797. context.fillStyle = SCROLLBAR_COLOR;
  798. context.strokeStyle = "rgba(255,255,255,0.8)";
  799. [scrollBars.horizontal, scrollBars.vertical].forEach((scrollBar) => {
  800. if (scrollBar) {
  801. roundRect(
  802. context,
  803. scrollBar.x,
  804. scrollBar.y,
  805. scrollBar.width,
  806. scrollBar.height,
  807. SCROLLBAR_WIDTH / 2,
  808. );
  809. }
  810. });
  811. context.restore();
  812. }
  813. return {
  814. scrollBars,
  815. atLeastOneVisibleElement: visibleElements.length > 0,
  816. elements,
  817. };
  818. };
  819. const _renderStaticScene = ({
  820. canvas,
  821. rc,
  822. elements,
  823. visibleElements,
  824. scale,
  825. appState,
  826. renderConfig,
  827. }: StaticSceneRenderConfig) => {
  828. if (canvas === null) {
  829. return;
  830. }
  831. const { renderGrid = true, isExporting } = renderConfig;
  832. const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
  833. canvas,
  834. scale,
  835. );
  836. const context = bootstrapCanvas({
  837. canvas,
  838. scale,
  839. normalizedWidth,
  840. normalizedHeight,
  841. theme: appState.theme,
  842. isExporting,
  843. viewBackgroundColor: appState.viewBackgroundColor,
  844. });
  845. // Apply zoom
  846. context.scale(appState.zoom.value, appState.zoom.value);
  847. // Grid
  848. if (renderGrid && appState.gridSize) {
  849. strokeGrid(
  850. context,
  851. appState.gridSize,
  852. -Math.ceil(appState.zoom.value / appState.gridSize) * appState.gridSize +
  853. (appState.scrollX % appState.gridSize),
  854. -Math.ceil(appState.zoom.value / appState.gridSize) * appState.gridSize +
  855. (appState.scrollY % appState.gridSize),
  856. appState.zoom,
  857. normalizedWidth / appState.zoom.value,
  858. normalizedHeight / appState.zoom.value,
  859. );
  860. }
  861. const groupsToBeAddedToFrame = new Set<string>();
  862. visibleElements.forEach((element) => {
  863. if (
  864. element.groupIds.length > 0 &&
  865. appState.frameToHighlight &&
  866. appState.selectedElementIds[element.id] &&
  867. (elementOverlapsWithFrame(element, appState.frameToHighlight) ||
  868. element.groupIds.find((groupId) => groupsToBeAddedToFrame.has(groupId)))
  869. ) {
  870. element.groupIds.forEach((groupId) =>
  871. groupsToBeAddedToFrame.add(groupId),
  872. );
  873. }
  874. });
  875. // Paint visible elements
  876. visibleElements
  877. .filter((el) => !isEmbeddableOrFrameLabel(el))
  878. .forEach((element) => {
  879. try {
  880. // - when exporting the whole canvas, we DO NOT apply clipping
  881. // - when we are exporting a particular frame, apply clipping
  882. // if the containing frame is not selected, apply clipping
  883. const frameId = element.frameId || appState.frameToHighlight?.id;
  884. if (
  885. frameId &&
  886. ((renderConfig.isExporting && isOnlyExportingSingleFrame(elements)) ||
  887. (!renderConfig.isExporting &&
  888. appState.frameRendering.enabled &&
  889. appState.frameRendering.clip))
  890. ) {
  891. context.save();
  892. const frame = getTargetFrame(element, appState);
  893. // TODO do we need to check isElementInFrame here?
  894. if (frame && isElementInFrame(element, elements, appState)) {
  895. frameClip(frame, context, renderConfig, appState);
  896. }
  897. renderElement(element, rc, context, renderConfig, appState);
  898. context.restore();
  899. } else {
  900. renderElement(element, rc, context, renderConfig, appState);
  901. }
  902. if (!isExporting) {
  903. renderLinkIcon(element, context, appState);
  904. }
  905. } catch (error: any) {
  906. console.error(error);
  907. }
  908. });
  909. // render embeddables on top
  910. visibleElements
  911. .filter((el) => isEmbeddableOrFrameLabel(el))
  912. .forEach((element) => {
  913. try {
  914. const render = () => {
  915. renderElement(element, rc, context, renderConfig, appState);
  916. if (
  917. isEmbeddableElement(element) &&
  918. (isExporting || !element.validated) &&
  919. element.width &&
  920. element.height
  921. ) {
  922. const label = createPlaceholderEmbeddableLabel(element);
  923. renderElement(label, rc, context, renderConfig, appState);
  924. }
  925. if (!isExporting) {
  926. renderLinkIcon(element, context, appState);
  927. }
  928. };
  929. // - when exporting the whole canvas, we DO NOT apply clipping
  930. // - when we are exporting a particular frame, apply clipping
  931. // if the containing frame is not selected, apply clipping
  932. const frameId = element.frameId || appState.frameToHighlight?.id;
  933. if (
  934. frameId &&
  935. ((renderConfig.isExporting && isOnlyExportingSingleFrame(elements)) ||
  936. (!renderConfig.isExporting &&
  937. appState.frameRendering.enabled &&
  938. appState.frameRendering.clip))
  939. ) {
  940. context.save();
  941. const frame = getTargetFrame(element, appState);
  942. if (frame && isElementInFrame(element, elements, appState)) {
  943. frameClip(frame, context, renderConfig, appState);
  944. }
  945. render();
  946. context.restore();
  947. } else {
  948. render();
  949. }
  950. } catch (error: any) {
  951. console.error(error);
  952. }
  953. });
  954. };
  955. /** throttled to animation framerate */
  956. const renderInteractiveSceneThrottled = throttleRAF(
  957. (config: InteractiveSceneRenderConfig) => {
  958. const ret = _renderInteractiveScene(config);
  959. config.callback?.(ret);
  960. },
  961. { trailing: true },
  962. );
  963. /**
  964. * Interactive scene is the ui-canvas where we render boundinb boxes, selections
  965. * and other ui stuff.
  966. */
  967. export const renderInteractiveScene = <
  968. U extends typeof _renderInteractiveScene,
  969. T extends boolean = false,
  970. >(
  971. renderConfig: InteractiveSceneRenderConfig,
  972. throttle?: T,
  973. ): T extends true ? void : ReturnType<U> => {
  974. if (throttle) {
  975. renderInteractiveSceneThrottled(renderConfig);
  976. return undefined as T extends true ? void : ReturnType<U>;
  977. }
  978. const ret = _renderInteractiveScene(renderConfig);
  979. renderConfig.callback(ret);
  980. return ret as T extends true ? void : ReturnType<U>;
  981. };
  982. /** throttled to animation framerate */
  983. const renderStaticSceneThrottled = throttleRAF(
  984. (config: StaticSceneRenderConfig) => {
  985. _renderStaticScene(config);
  986. },
  987. { trailing: true },
  988. );
  989. /**
  990. * Static scene is the non-ui canvas where we render elements.
  991. */
  992. export const renderStaticScene = (
  993. renderConfig: StaticSceneRenderConfig,
  994. throttle?: boolean,
  995. ) => {
  996. if (throttle) {
  997. renderStaticSceneThrottled(renderConfig);
  998. return;
  999. }
  1000. _renderStaticScene(renderConfig);
  1001. };
  1002. export const cancelRender = () => {
  1003. renderInteractiveSceneThrottled.cancel();
  1004. renderStaticSceneThrottled.cancel();
  1005. };
  1006. const renderTransformHandles = (
  1007. context: CanvasRenderingContext2D,
  1008. renderConfig: InteractiveCanvasRenderConfig,
  1009. appState: InteractiveCanvasAppState,
  1010. transformHandles: TransformHandles,
  1011. angle: number,
  1012. ): void => {
  1013. Object.keys(transformHandles).forEach((key) => {
  1014. const transformHandle = transformHandles[key as TransformHandleType];
  1015. if (transformHandle !== undefined) {
  1016. const [x, y, width, height] = transformHandle;
  1017. context.save();
  1018. context.lineWidth = 1 / appState.zoom.value;
  1019. if (renderConfig.selectionColor) {
  1020. context.strokeStyle = renderConfig.selectionColor;
  1021. }
  1022. if (key === "rotation") {
  1023. fillCircle(context, x + width / 2, y + height / 2, width / 2);
  1024. // prefer round corners if roundRect API is available
  1025. } else if (context.roundRect) {
  1026. context.beginPath();
  1027. context.roundRect(x, y, width, height, 2 / appState.zoom.value);
  1028. context.fill();
  1029. context.stroke();
  1030. } else {
  1031. strokeRectWithRotation(
  1032. context,
  1033. x,
  1034. y,
  1035. width,
  1036. height,
  1037. x + width / 2,
  1038. y + height / 2,
  1039. angle,
  1040. true, // fill before stroke
  1041. );
  1042. }
  1043. context.restore();
  1044. }
  1045. });
  1046. };
  1047. const renderSelectionBorder = (
  1048. context: CanvasRenderingContext2D,
  1049. appState: InteractiveCanvasAppState,
  1050. elementProperties: {
  1051. angle: number;
  1052. elementX1: number;
  1053. elementY1: number;
  1054. elementX2: number;
  1055. elementY2: number;
  1056. selectionColors: string[];
  1057. dashed?: boolean;
  1058. cx: number;
  1059. cy: number;
  1060. activeEmbeddable: boolean;
  1061. },
  1062. padding = DEFAULT_SPACING * 2,
  1063. ) => {
  1064. const {
  1065. angle,
  1066. elementX1,
  1067. elementY1,
  1068. elementX2,
  1069. elementY2,
  1070. selectionColors,
  1071. cx,
  1072. cy,
  1073. dashed,
  1074. activeEmbeddable,
  1075. } = elementProperties;
  1076. const elementWidth = elementX2 - elementX1;
  1077. const elementHeight = elementY2 - elementY1;
  1078. const linePadding = padding / appState.zoom.value;
  1079. const lineWidth = 8 / appState.zoom.value;
  1080. const spaceWidth = 4 / appState.zoom.value;
  1081. context.save();
  1082. context.translate(appState.scrollX, appState.scrollY);
  1083. context.lineWidth = (activeEmbeddable ? 4 : 1) / appState.zoom.value;
  1084. const count = selectionColors.length;
  1085. for (let index = 0; index < count; ++index) {
  1086. context.strokeStyle = selectionColors[index];
  1087. if (dashed) {
  1088. context.setLineDash([
  1089. lineWidth,
  1090. spaceWidth + (lineWidth + spaceWidth) * (count - 1),
  1091. ]);
  1092. }
  1093. context.lineDashOffset = (lineWidth + spaceWidth) * index;
  1094. strokeRectWithRotation(
  1095. context,
  1096. elementX1 - linePadding,
  1097. elementY1 - linePadding,
  1098. elementWidth + linePadding * 2,
  1099. elementHeight + linePadding * 2,
  1100. cx,
  1101. cy,
  1102. angle,
  1103. );
  1104. }
  1105. context.restore();
  1106. };
  1107. const renderBindingHighlight = (
  1108. context: CanvasRenderingContext2D,
  1109. appState: InteractiveCanvasAppState,
  1110. suggestedBinding: SuggestedBinding,
  1111. ) => {
  1112. const renderHighlight = Array.isArray(suggestedBinding)
  1113. ? renderBindingHighlightForSuggestedPointBinding
  1114. : renderBindingHighlightForBindableElement;
  1115. context.save();
  1116. context.translate(appState.scrollX, appState.scrollY);
  1117. renderHighlight(context, suggestedBinding as any);
  1118. context.restore();
  1119. };
  1120. const renderBindingHighlightForBindableElement = (
  1121. context: CanvasRenderingContext2D,
  1122. element: ExcalidrawBindableElement,
  1123. ) => {
  1124. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  1125. const width = x2 - x1;
  1126. const height = y2 - y1;
  1127. const threshold = maxBindingGap(element, width, height);
  1128. // So that we don't overlap the element itself
  1129. const strokeOffset = 4;
  1130. context.strokeStyle = "rgba(0,0,0,.05)";
  1131. context.lineWidth = threshold - strokeOffset;
  1132. const padding = strokeOffset / 2 + threshold / 2;
  1133. switch (element.type) {
  1134. case "rectangle":
  1135. case "text":
  1136. case "image":
  1137. case "embeddable":
  1138. case "frame":
  1139. strokeRectWithRotation(
  1140. context,
  1141. x1 - padding,
  1142. y1 - padding,
  1143. width + padding * 2,
  1144. height + padding * 2,
  1145. x1 + width / 2,
  1146. y1 + height / 2,
  1147. element.angle,
  1148. );
  1149. break;
  1150. case "diamond":
  1151. const side = Math.hypot(width, height);
  1152. const wPadding = (padding * side) / height;
  1153. const hPadding = (padding * side) / width;
  1154. strokeDiamondWithRotation(
  1155. context,
  1156. width + wPadding * 2,
  1157. height + hPadding * 2,
  1158. x1 + width / 2,
  1159. y1 + height / 2,
  1160. element.angle,
  1161. );
  1162. break;
  1163. case "ellipse":
  1164. strokeEllipseWithRotation(
  1165. context,
  1166. width + padding * 2,
  1167. height + padding * 2,
  1168. x1 + width / 2,
  1169. y1 + height / 2,
  1170. element.angle,
  1171. );
  1172. break;
  1173. }
  1174. };
  1175. const renderFrameHighlight = (
  1176. context: CanvasRenderingContext2D,
  1177. appState: InteractiveCanvasAppState,
  1178. frame: NonDeleted<ExcalidrawFrameElement>,
  1179. ) => {
  1180. const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame);
  1181. const width = x2 - x1;
  1182. const height = y2 - y1;
  1183. context.strokeStyle = "rgb(0,118,255)";
  1184. context.lineWidth = (FRAME_STYLE.strokeWidth * 2) / appState.zoom.value;
  1185. context.save();
  1186. context.translate(appState.scrollX, appState.scrollY);
  1187. strokeRectWithRotation(
  1188. context,
  1189. x1,
  1190. y1,
  1191. width,
  1192. height,
  1193. x1 + width / 2,
  1194. y1 + height / 2,
  1195. frame.angle,
  1196. false,
  1197. FRAME_STYLE.radius / appState.zoom.value,
  1198. );
  1199. context.restore();
  1200. };
  1201. const renderElementsBoxHighlight = (
  1202. context: CanvasRenderingContext2D,
  1203. appState: InteractiveCanvasAppState,
  1204. elements: NonDeleted<ExcalidrawElement>[],
  1205. ) => {
  1206. const individualElements = elements.filter(
  1207. (element) => element.groupIds.length === 0,
  1208. );
  1209. const elementsInGroups = elements.filter(
  1210. (element) => element.groupIds.length > 0,
  1211. );
  1212. const getSelectionFromElements = (elements: ExcalidrawElement[]) => {
  1213. const [elementX1, elementY1, elementX2, elementY2] =
  1214. getCommonBounds(elements);
  1215. return {
  1216. angle: 0,
  1217. elementX1,
  1218. elementX2,
  1219. elementY1,
  1220. elementY2,
  1221. selectionColors: ["rgb(0,118,255)"],
  1222. dashed: false,
  1223. cx: elementX1 + (elementX2 - elementX1) / 2,
  1224. cy: elementY1 + (elementY2 - elementY1) / 2,
  1225. activeEmbeddable: false,
  1226. };
  1227. };
  1228. const getSelectionForGroupId = (groupId: GroupId) => {
  1229. const groupElements = getElementsInGroup(elements, groupId);
  1230. return getSelectionFromElements(groupElements);
  1231. };
  1232. Object.entries(selectGroupsFromGivenElements(elementsInGroups, appState))
  1233. .filter(([id, isSelected]) => isSelected)
  1234. .map(([id, isSelected]) => id)
  1235. .map((groupId) => getSelectionForGroupId(groupId))
  1236. .concat(
  1237. individualElements.map((element) => getSelectionFromElements([element])),
  1238. )
  1239. .forEach((selection) =>
  1240. renderSelectionBorder(context, appState, selection),
  1241. );
  1242. };
  1243. const renderBindingHighlightForSuggestedPointBinding = (
  1244. context: CanvasRenderingContext2D,
  1245. suggestedBinding: SuggestedPointBinding,
  1246. ) => {
  1247. const [element, startOrEnd, bindableElement] = suggestedBinding;
  1248. const threshold = maxBindingGap(
  1249. bindableElement,
  1250. bindableElement.width,
  1251. bindableElement.height,
  1252. );
  1253. context.strokeStyle = "rgba(0,0,0,0)";
  1254. context.fillStyle = "rgba(0,0,0,.05)";
  1255. const pointIndices =
  1256. startOrEnd === "both" ? [0, -1] : startOrEnd === "start" ? [0] : [-1];
  1257. pointIndices.forEach((index) => {
  1258. const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
  1259. element,
  1260. index,
  1261. );
  1262. fillCircle(context, x, y, threshold);
  1263. });
  1264. };
  1265. let linkCanvasCache: any;
  1266. const renderLinkIcon = (
  1267. element: NonDeletedExcalidrawElement,
  1268. context: CanvasRenderingContext2D,
  1269. appState: StaticCanvasAppState,
  1270. ) => {
  1271. if (element.link && !appState.selectedElementIds[element.id]) {
  1272. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  1273. const [x, y, width, height] = getLinkHandleFromCoords(
  1274. [x1, y1, x2, y2],
  1275. element.angle,
  1276. appState,
  1277. );
  1278. const centerX = x + width / 2;
  1279. const centerY = y + height / 2;
  1280. context.save();
  1281. context.translate(appState.scrollX + centerX, appState.scrollY + centerY);
  1282. context.rotate(element.angle);
  1283. if (!linkCanvasCache || linkCanvasCache.zoom !== appState.zoom.value) {
  1284. linkCanvasCache = document.createElement("canvas");
  1285. linkCanvasCache.zoom = appState.zoom.value;
  1286. linkCanvasCache.width =
  1287. width * window.devicePixelRatio * appState.zoom.value;
  1288. linkCanvasCache.height =
  1289. height * window.devicePixelRatio * appState.zoom.value;
  1290. const linkCanvasCacheContext = linkCanvasCache.getContext("2d")!;
  1291. linkCanvasCacheContext.scale(
  1292. window.devicePixelRatio * appState.zoom.value,
  1293. window.devicePixelRatio * appState.zoom.value,
  1294. );
  1295. linkCanvasCacheContext.fillStyle = "#fff";
  1296. linkCanvasCacheContext.fillRect(0, 0, width, height);
  1297. linkCanvasCacheContext.drawImage(EXTERNAL_LINK_IMG, 0, 0, width, height);
  1298. linkCanvasCacheContext.restore();
  1299. context.drawImage(
  1300. linkCanvasCache,
  1301. x - centerX,
  1302. y - centerY,
  1303. width,
  1304. height,
  1305. );
  1306. } else {
  1307. context.drawImage(
  1308. linkCanvasCache,
  1309. x - centerX,
  1310. y - centerY,
  1311. width,
  1312. height,
  1313. );
  1314. }
  1315. context.restore();
  1316. }
  1317. };
  1318. // This should be only called for exporting purposes
  1319. export const renderSceneToSvg = (
  1320. elements: readonly NonDeletedExcalidrawElement[],
  1321. rsvg: RoughSVG,
  1322. svgRoot: SVGElement,
  1323. files: BinaryFiles,
  1324. {
  1325. offsetX = 0,
  1326. offsetY = 0,
  1327. exportWithDarkMode = false,
  1328. exportingFrameId = null,
  1329. renderEmbeddables,
  1330. }: {
  1331. offsetX?: number;
  1332. offsetY?: number;
  1333. exportWithDarkMode?: boolean;
  1334. exportingFrameId?: string | null;
  1335. renderEmbeddables?: boolean;
  1336. } = {},
  1337. ) => {
  1338. if (!svgRoot) {
  1339. return;
  1340. }
  1341. // render elements
  1342. elements
  1343. .filter((el) => !isEmbeddableOrFrameLabel(el))
  1344. .forEach((element) => {
  1345. if (!element.isDeleted) {
  1346. try {
  1347. renderElementToSvg(
  1348. element,
  1349. rsvg,
  1350. svgRoot,
  1351. files,
  1352. element.x + offsetX,
  1353. element.y + offsetY,
  1354. exportWithDarkMode,
  1355. exportingFrameId,
  1356. renderEmbeddables,
  1357. );
  1358. } catch (error: any) {
  1359. console.error(error);
  1360. }
  1361. }
  1362. });
  1363. // render embeddables on top
  1364. elements
  1365. .filter((el) => isEmbeddableElement(el))
  1366. .forEach((element) => {
  1367. if (!element.isDeleted) {
  1368. try {
  1369. renderElementToSvg(
  1370. element,
  1371. rsvg,
  1372. svgRoot,
  1373. files,
  1374. element.x + offsetX,
  1375. element.y + offsetY,
  1376. exportWithDarkMode,
  1377. exportingFrameId,
  1378. renderEmbeddables,
  1379. );
  1380. } catch (error: any) {
  1381. console.error(error);
  1382. }
  1383. }
  1384. });
  1385. };