renderElement.ts 34 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066
  1. import type {
  2. ExcalidrawElement,
  3. ExcalidrawTextElement,
  4. NonDeletedExcalidrawElement,
  5. ExcalidrawFreeDrawElement,
  6. ExcalidrawImageElement,
  7. ExcalidrawTextElementWithContainer,
  8. ExcalidrawFrameLikeElement,
  9. NonDeletedSceneElementsMap,
  10. ElementsMap,
  11. } from "../element/types";
  12. import {
  13. isTextElement,
  14. isLinearElement,
  15. isFreeDrawElement,
  16. isInitializedImageElement,
  17. isArrowElement,
  18. hasBoundTextElement,
  19. isMagicFrameElement,
  20. isImageElement,
  21. } from "../element/typeChecks";
  22. import { getElementAbsoluteCoords } from "../element/bounds";
  23. import type { RoughCanvas } from "roughjs/bin/canvas";
  24. import type {
  25. StaticCanvasRenderConfig,
  26. RenderableElementsMap,
  27. InteractiveCanvasRenderConfig,
  28. } from "../scene/types";
  29. import { distance, getFontString, isRTL } from "../utils";
  30. import rough from "roughjs/bin/rough";
  31. import type {
  32. AppState,
  33. StaticCanvasAppState,
  34. Zoom,
  35. InteractiveCanvasAppState,
  36. ElementsPendingErasure,
  37. PendingExcalidrawElements,
  38. } from "../types";
  39. import { getDefaultAppState } from "../appState";
  40. import {
  41. BOUND_TEXT_PADDING,
  42. ELEMENT_READY_TO_ERASE_OPACITY,
  43. FRAME_STYLE,
  44. MIME_TYPES,
  45. THEME,
  46. } from "../constants";
  47. import type { StrokeOptions } from "perfect-freehand";
  48. import { getStroke } from "perfect-freehand";
  49. import {
  50. getBoundTextElement,
  51. getContainerCoords,
  52. getContainerElement,
  53. getLineHeightInPx,
  54. getBoundTextMaxHeight,
  55. getBoundTextMaxWidth,
  56. } from "../element/textElement";
  57. import { LinearElementEditor } from "../element/linearElementEditor";
  58. import { getContainingFrame } from "../frame";
  59. import { ShapeCache } from "../scene/ShapeCache";
  60. import { getVerticalOffset } from "../fonts";
  61. import { isRightAngleRads } from "../../math";
  62. import { getCornerRadius } from "../shapes";
  63. import { getUncroppedImageElement } from "../element/cropElement";
  64. // using a stronger invert (100% vs our regular 93%) and saturate
  65. // as a temp hack to make images in dark theme look closer to original
  66. // color scheme (it's still not quite there and the colors look slightly
  67. // desatured, alas...)
  68. export const IMAGE_INVERT_FILTER =
  69. "invert(100%) hue-rotate(180deg) saturate(1.25)";
  70. const defaultAppState = getDefaultAppState();
  71. const isPendingImageElement = (
  72. element: ExcalidrawElement,
  73. renderConfig: StaticCanvasRenderConfig,
  74. ) =>
  75. isInitializedImageElement(element) &&
  76. !renderConfig.imageCache.has(element.fileId);
  77. const shouldResetImageFilter = (
  78. element: ExcalidrawElement,
  79. renderConfig: StaticCanvasRenderConfig,
  80. appState: StaticCanvasAppState,
  81. ) => {
  82. return (
  83. appState.theme === THEME.DARK &&
  84. isInitializedImageElement(element) &&
  85. !isPendingImageElement(element, renderConfig) &&
  86. renderConfig.imageCache.get(element.fileId)?.mimeType !== MIME_TYPES.svg
  87. );
  88. };
  89. const getCanvasPadding = (element: ExcalidrawElement) => {
  90. switch (element.type) {
  91. case "freedraw":
  92. return element.strokeWidth * 12;
  93. case "text":
  94. return element.fontSize / 2;
  95. default:
  96. return 20;
  97. }
  98. };
  99. export const getRenderOpacity = (
  100. element: ExcalidrawElement,
  101. containingFrame: ExcalidrawFrameLikeElement | null,
  102. elementsPendingErasure: ElementsPendingErasure,
  103. pendingNodes: Readonly<PendingExcalidrawElements> | null,
  104. ) => {
  105. // multiplying frame opacity with element opacity to combine them
  106. // (e.g. frame 50% and element 50% opacity should result in 25% opacity)
  107. let opacity = ((containingFrame?.opacity ?? 100) * element.opacity) / 10000;
  108. // if pending erasure, multiply again to combine further
  109. // (so that erasing always results in lower opacity than original)
  110. if (
  111. elementsPendingErasure.has(element.id) ||
  112. (pendingNodes && pendingNodes.some((node) => node.id === element.id)) ||
  113. (containingFrame && elementsPendingErasure.has(containingFrame.id))
  114. ) {
  115. opacity *= ELEMENT_READY_TO_ERASE_OPACITY / 100;
  116. }
  117. return opacity;
  118. };
  119. export interface ExcalidrawElementWithCanvas {
  120. element: ExcalidrawElement | ExcalidrawTextElement;
  121. canvas: HTMLCanvasElement;
  122. theme: AppState["theme"];
  123. scale: number;
  124. angle: number;
  125. zoomValue: AppState["zoom"]["value"];
  126. canvasOffsetX: number;
  127. canvasOffsetY: number;
  128. boundTextElementVersion: number | null;
  129. containingFrameOpacity: number;
  130. boundTextCanvas: HTMLCanvasElement;
  131. }
  132. const cappedElementCanvasSize = (
  133. element: NonDeletedExcalidrawElement,
  134. elementsMap: ElementsMap,
  135. zoom: Zoom,
  136. ): {
  137. width: number;
  138. height: number;
  139. scale: number;
  140. } => {
  141. // these limits are ballpark, they depend on specific browsers and device.
  142. // We've chosen lower limits to be safe. We might want to change these limits
  143. // based on browser/device type, if we get reports of low quality rendering
  144. // on zoom.
  145. //
  146. // ~ safari mobile canvas area limit
  147. const AREA_LIMIT = 16777216;
  148. // ~ safari width/height limit based on developer.mozilla.org.
  149. const WIDTH_HEIGHT_LIMIT = 32767;
  150. const padding = getCanvasPadding(element);
  151. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
  152. const elementWidth =
  153. isLinearElement(element) || isFreeDrawElement(element)
  154. ? distance(x1, x2)
  155. : element.width;
  156. const elementHeight =
  157. isLinearElement(element) || isFreeDrawElement(element)
  158. ? distance(y1, y2)
  159. : element.height;
  160. let width = elementWidth * window.devicePixelRatio + padding * 2;
  161. let height = elementHeight * window.devicePixelRatio + padding * 2;
  162. let scale: number = zoom.value;
  163. // rescale to ensure width and height is within limits
  164. if (
  165. width * scale > WIDTH_HEIGHT_LIMIT ||
  166. height * scale > WIDTH_HEIGHT_LIMIT
  167. ) {
  168. scale = Math.min(WIDTH_HEIGHT_LIMIT / width, WIDTH_HEIGHT_LIMIT / height);
  169. }
  170. // rescale to ensure canvas area is within limits
  171. if (width * height * scale * scale > AREA_LIMIT) {
  172. scale = Math.sqrt(AREA_LIMIT / (width * height));
  173. }
  174. width = Math.floor(width * scale);
  175. height = Math.floor(height * scale);
  176. return { width, height, scale };
  177. };
  178. const generateElementCanvas = (
  179. element: NonDeletedExcalidrawElement,
  180. elementsMap: NonDeletedSceneElementsMap,
  181. zoom: Zoom,
  182. renderConfig: StaticCanvasRenderConfig,
  183. appState: StaticCanvasAppState,
  184. ): ExcalidrawElementWithCanvas | null => {
  185. const canvas = document.createElement("canvas");
  186. const context = canvas.getContext("2d")!;
  187. const padding = getCanvasPadding(element);
  188. const { width, height, scale } = cappedElementCanvasSize(
  189. element,
  190. elementsMap,
  191. zoom,
  192. );
  193. if (!width || !height) {
  194. return null;
  195. }
  196. canvas.width = width;
  197. canvas.height = height;
  198. let canvasOffsetX = -100;
  199. let canvasOffsetY = 0;
  200. if (isLinearElement(element) || isFreeDrawElement(element)) {
  201. const [x1, y1] = getElementAbsoluteCoords(element, elementsMap);
  202. canvasOffsetX =
  203. element.x > x1
  204. ? distance(element.x, x1) * window.devicePixelRatio * scale
  205. : 0;
  206. canvasOffsetY =
  207. element.y > y1
  208. ? distance(element.y, y1) * window.devicePixelRatio * scale
  209. : 0;
  210. context.translate(canvasOffsetX, canvasOffsetY);
  211. }
  212. context.save();
  213. context.translate(padding * scale, padding * scale);
  214. context.scale(
  215. window.devicePixelRatio * scale,
  216. window.devicePixelRatio * scale,
  217. );
  218. const rc = rough.canvas(canvas);
  219. // in dark theme, revert the image color filter
  220. if (shouldResetImageFilter(element, renderConfig, appState)) {
  221. context.filter = IMAGE_INVERT_FILTER;
  222. }
  223. drawElementOnCanvas(element, rc, context, renderConfig, appState);
  224. context.restore();
  225. const boundTextElement = getBoundTextElement(element, elementsMap);
  226. const boundTextCanvas = document.createElement("canvas");
  227. const boundTextCanvasContext = boundTextCanvas.getContext("2d")!;
  228. if (isArrowElement(element) && boundTextElement) {
  229. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
  230. // Take max dimensions of arrow canvas so that when canvas is rotated
  231. // the arrow doesn't get clipped
  232. const maxDim = Math.max(distance(x1, x2), distance(y1, y2));
  233. boundTextCanvas.width =
  234. maxDim * window.devicePixelRatio * scale + padding * scale * 10;
  235. boundTextCanvas.height =
  236. maxDim * window.devicePixelRatio * scale + padding * scale * 10;
  237. boundTextCanvasContext.translate(
  238. boundTextCanvas.width / 2,
  239. boundTextCanvas.height / 2,
  240. );
  241. boundTextCanvasContext.rotate(element.angle);
  242. boundTextCanvasContext.drawImage(
  243. canvas!,
  244. -canvas.width / 2,
  245. -canvas.height / 2,
  246. canvas.width,
  247. canvas.height,
  248. );
  249. const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords(
  250. boundTextElement,
  251. elementsMap,
  252. );
  253. boundTextCanvasContext.rotate(-element.angle);
  254. const offsetX = (boundTextCanvas.width - canvas!.width) / 2;
  255. const offsetY = (boundTextCanvas.height - canvas!.height) / 2;
  256. const shiftX =
  257. boundTextCanvas.width / 2 -
  258. (boundTextCx - x1) * window.devicePixelRatio * scale -
  259. offsetX -
  260. padding * scale;
  261. const shiftY =
  262. boundTextCanvas.height / 2 -
  263. (boundTextCy - y1) * window.devicePixelRatio * scale -
  264. offsetY -
  265. padding * scale;
  266. boundTextCanvasContext.translate(-shiftX, -shiftY);
  267. // Clear the bound text area
  268. boundTextCanvasContext.clearRect(
  269. -(boundTextElement.width / 2 + BOUND_TEXT_PADDING) *
  270. window.devicePixelRatio *
  271. scale,
  272. -(boundTextElement.height / 2 + BOUND_TEXT_PADDING) *
  273. window.devicePixelRatio *
  274. scale,
  275. (boundTextElement.width + BOUND_TEXT_PADDING * 2) *
  276. window.devicePixelRatio *
  277. scale,
  278. (boundTextElement.height + BOUND_TEXT_PADDING * 2) *
  279. window.devicePixelRatio *
  280. scale,
  281. );
  282. }
  283. return {
  284. element,
  285. canvas,
  286. theme: appState.theme,
  287. scale,
  288. zoomValue: zoom.value,
  289. canvasOffsetX,
  290. canvasOffsetY,
  291. boundTextElementVersion:
  292. getBoundTextElement(element, elementsMap)?.version || null,
  293. containingFrameOpacity:
  294. getContainingFrame(element, elementsMap)?.opacity || 100,
  295. boundTextCanvas,
  296. angle: element.angle,
  297. };
  298. };
  299. export const DEFAULT_LINK_SIZE = 14;
  300. const IMAGE_PLACEHOLDER_IMG = document.createElement("img");
  301. IMAGE_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
  302. `<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="image" class="svg-inline--fa fa-image fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#888" d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48zM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56zM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48z"></path></svg>`,
  303. )}`;
  304. const IMAGE_ERROR_PLACEHOLDER_IMG = document.createElement("img");
  305. IMAGE_ERROR_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
  306. `<svg viewBox="0 0 668 668" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2"><path d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48ZM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56ZM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.81709 0 0 .81709 124.825 145.825)"/><path d="M256 8C119.034 8 8 119.033 8 256c0 136.967 111.034 248 248 248s248-111.034 248-248S392.967 8 256 8Zm130.108 117.892c65.448 65.448 70 165.481 20.677 235.637L150.47 105.216c70.204-49.356 170.226-44.735 235.638 20.676ZM125.892 386.108c-65.448-65.448-70-165.481-20.677-235.637L361.53 406.784c-70.203 49.356-170.226 44.736-235.638-20.676Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.30366 0 0 .30366 506.822 60.065)"/></svg>`,
  307. )}`;
  308. const drawImagePlaceholder = (
  309. element: ExcalidrawImageElement,
  310. context: CanvasRenderingContext2D,
  311. ) => {
  312. context.fillStyle = "#E7E7E7";
  313. context.fillRect(0, 0, element.width, element.height);
  314. const imageMinWidthOrHeight = Math.min(element.width, element.height);
  315. const size = Math.min(
  316. imageMinWidthOrHeight,
  317. Math.min(imageMinWidthOrHeight * 0.4, 100),
  318. );
  319. context.drawImage(
  320. element.status === "error"
  321. ? IMAGE_ERROR_PLACEHOLDER_IMG
  322. : IMAGE_PLACEHOLDER_IMG,
  323. element.width / 2 - size / 2,
  324. element.height / 2 - size / 2,
  325. size,
  326. size,
  327. );
  328. };
  329. const drawElementOnCanvas = (
  330. element: NonDeletedExcalidrawElement,
  331. rc: RoughCanvas,
  332. context: CanvasRenderingContext2D,
  333. renderConfig: StaticCanvasRenderConfig,
  334. appState: StaticCanvasAppState,
  335. ) => {
  336. switch (element.type) {
  337. case "rectangle":
  338. case "iframe":
  339. case "embeddable":
  340. case "diamond":
  341. case "ellipse": {
  342. context.lineJoin = "round";
  343. context.lineCap = "round";
  344. rc.draw(ShapeCache.get(element)!);
  345. break;
  346. }
  347. case "arrow":
  348. case "line": {
  349. context.lineJoin = "round";
  350. context.lineCap = "round";
  351. ShapeCache.get(element)!.forEach((shape) => {
  352. rc.draw(shape);
  353. });
  354. break;
  355. }
  356. case "freedraw": {
  357. // Draw directly to canvas
  358. context.save();
  359. context.fillStyle = element.strokeColor;
  360. const path = getFreeDrawPath2D(element) as Path2D;
  361. const fillShape = ShapeCache.get(element);
  362. if (fillShape) {
  363. rc.draw(fillShape);
  364. }
  365. context.fillStyle = element.strokeColor;
  366. context.fill(path);
  367. context.restore();
  368. break;
  369. }
  370. case "image": {
  371. const img = isInitializedImageElement(element)
  372. ? renderConfig.imageCache.get(element.fileId)?.image
  373. : undefined;
  374. if (img != null && !(img instanceof Promise)) {
  375. if (element.roundness && context.roundRect) {
  376. context.beginPath();
  377. context.roundRect(
  378. 0,
  379. 0,
  380. element.width,
  381. element.height,
  382. getCornerRadius(Math.min(element.width, element.height), element),
  383. );
  384. context.clip();
  385. }
  386. const { x, y, width, height } = element.crop
  387. ? element.crop
  388. : {
  389. x: 0,
  390. y: 0,
  391. width: img.naturalWidth,
  392. height: img.naturalHeight,
  393. };
  394. context.drawImage(
  395. img,
  396. x,
  397. y,
  398. width,
  399. height,
  400. 0 /* hardcoded for the selection box*/,
  401. 0,
  402. element.width,
  403. element.height,
  404. );
  405. } else {
  406. drawImagePlaceholder(element, context);
  407. }
  408. break;
  409. }
  410. default: {
  411. if (isTextElement(element)) {
  412. const rtl = isRTL(element.text);
  413. const shouldTemporarilyAttach = rtl && !context.canvas.isConnected;
  414. if (shouldTemporarilyAttach) {
  415. // to correctly render RTL text mixed with LTR, we have to append it
  416. // to the DOM
  417. document.body.appendChild(context.canvas);
  418. }
  419. context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr");
  420. context.save();
  421. context.font = getFontString(element);
  422. context.fillStyle = element.strokeColor;
  423. context.textAlign = element.textAlign as CanvasTextAlign;
  424. // Canvas does not support multiline text by default
  425. const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
  426. const horizontalOffset =
  427. element.textAlign === "center"
  428. ? element.width / 2
  429. : element.textAlign === "right"
  430. ? element.width
  431. : 0;
  432. const lineHeightPx = getLineHeightInPx(
  433. element.fontSize,
  434. element.lineHeight,
  435. );
  436. const verticalOffset = getVerticalOffset(
  437. element.fontFamily,
  438. element.fontSize,
  439. lineHeightPx,
  440. );
  441. for (let index = 0; index < lines.length; index++) {
  442. context.fillText(
  443. lines[index],
  444. horizontalOffset,
  445. index * lineHeightPx + verticalOffset,
  446. );
  447. }
  448. context.restore();
  449. if (shouldTemporarilyAttach) {
  450. context.canvas.remove();
  451. }
  452. } else {
  453. throw new Error(`Unimplemented type ${element.type}`);
  454. }
  455. }
  456. }
  457. };
  458. export const elementWithCanvasCache = new WeakMap<
  459. ExcalidrawElement,
  460. ExcalidrawElementWithCanvas
  461. >();
  462. const generateElementWithCanvas = (
  463. element: NonDeletedExcalidrawElement,
  464. elementsMap: NonDeletedSceneElementsMap,
  465. renderConfig: StaticCanvasRenderConfig,
  466. appState: StaticCanvasAppState,
  467. ) => {
  468. const zoom: Zoom = renderConfig ? appState.zoom : defaultAppState.zoom;
  469. const prevElementWithCanvas = elementWithCanvasCache.get(element);
  470. const shouldRegenerateBecauseZoom =
  471. prevElementWithCanvas &&
  472. prevElementWithCanvas.zoomValue !== zoom.value &&
  473. !appState?.shouldCacheIgnoreZoom;
  474. const boundTextElement = getBoundTextElement(element, elementsMap);
  475. const boundTextElementVersion = boundTextElement?.version || null;
  476. const containingFrameOpacity =
  477. getContainingFrame(element, elementsMap)?.opacity || 100;
  478. if (
  479. !prevElementWithCanvas ||
  480. shouldRegenerateBecauseZoom ||
  481. prevElementWithCanvas.theme !== appState.theme ||
  482. prevElementWithCanvas.boundTextElementVersion !== boundTextElementVersion ||
  483. prevElementWithCanvas.containingFrameOpacity !== containingFrameOpacity ||
  484. // since we rotate the canvas when copying from cached canvas, we don't
  485. // regenerate the cached canvas. But we need to in case of labels which are
  486. // cached alongside the arrow, and we want the labels to remain unrotated
  487. // with respect to the arrow.
  488. (isArrowElement(element) &&
  489. boundTextElement &&
  490. element.angle !== prevElementWithCanvas.angle)
  491. ) {
  492. const elementWithCanvas = generateElementCanvas(
  493. element,
  494. elementsMap,
  495. zoom,
  496. renderConfig,
  497. appState,
  498. );
  499. if (!elementWithCanvas) {
  500. return null;
  501. }
  502. elementWithCanvasCache.set(element, elementWithCanvas);
  503. return elementWithCanvas;
  504. }
  505. return prevElementWithCanvas;
  506. };
  507. const drawElementFromCanvas = (
  508. elementWithCanvas: ExcalidrawElementWithCanvas,
  509. context: CanvasRenderingContext2D,
  510. renderConfig: StaticCanvasRenderConfig,
  511. appState: StaticCanvasAppState,
  512. allElementsMap: NonDeletedSceneElementsMap,
  513. ) => {
  514. const element = elementWithCanvas.element;
  515. const padding = getCanvasPadding(element);
  516. const zoom = elementWithCanvas.scale;
  517. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, allElementsMap);
  518. const cx = ((x1 + x2) / 2 + appState.scrollX) * window.devicePixelRatio;
  519. const cy = ((y1 + y2) / 2 + appState.scrollY) * window.devicePixelRatio;
  520. context.save();
  521. context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio);
  522. const boundTextElement = getBoundTextElement(element, allElementsMap);
  523. if (isArrowElement(element) && boundTextElement) {
  524. const offsetX =
  525. (elementWithCanvas.boundTextCanvas.width -
  526. elementWithCanvas.canvas!.width) /
  527. 2;
  528. const offsetY =
  529. (elementWithCanvas.boundTextCanvas.height -
  530. elementWithCanvas.canvas!.height) /
  531. 2;
  532. context.translate(cx, cy);
  533. context.drawImage(
  534. elementWithCanvas.boundTextCanvas,
  535. (-(x2 - x1) / 2) * window.devicePixelRatio - offsetX / zoom - padding,
  536. (-(y2 - y1) / 2) * window.devicePixelRatio - offsetY / zoom - padding,
  537. elementWithCanvas.boundTextCanvas.width / zoom,
  538. elementWithCanvas.boundTextCanvas.height / zoom,
  539. );
  540. } else {
  541. // we translate context to element center so that rotation and scale
  542. // originates from the element center
  543. context.translate(cx, cy);
  544. context.rotate(element.angle);
  545. if (
  546. "scale" in elementWithCanvas.element &&
  547. !isPendingImageElement(element, renderConfig)
  548. ) {
  549. context.scale(
  550. elementWithCanvas.element.scale[0],
  551. elementWithCanvas.element.scale[1],
  552. );
  553. }
  554. // revert afterwards we don't have account for it during drawing
  555. context.translate(-cx, -cy);
  556. context.drawImage(
  557. elementWithCanvas.canvas!,
  558. (x1 + appState.scrollX) * window.devicePixelRatio -
  559. (padding * elementWithCanvas.scale) / elementWithCanvas.scale,
  560. (y1 + appState.scrollY) * window.devicePixelRatio -
  561. (padding * elementWithCanvas.scale) / elementWithCanvas.scale,
  562. elementWithCanvas.canvas!.width / elementWithCanvas.scale,
  563. elementWithCanvas.canvas!.height / elementWithCanvas.scale,
  564. );
  565. if (
  566. import.meta.env.VITE_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX ===
  567. "true" &&
  568. hasBoundTextElement(element)
  569. ) {
  570. const textElement = getBoundTextElement(
  571. element,
  572. allElementsMap,
  573. ) as ExcalidrawTextElementWithContainer;
  574. const coords = getContainerCoords(element);
  575. context.strokeStyle = "#c92a2a";
  576. context.lineWidth = 3;
  577. context.strokeRect(
  578. (coords.x + appState.scrollX) * window.devicePixelRatio,
  579. (coords.y + appState.scrollY) * window.devicePixelRatio,
  580. getBoundTextMaxWidth(element, textElement) * window.devicePixelRatio,
  581. getBoundTextMaxHeight(element, textElement) * window.devicePixelRatio,
  582. );
  583. }
  584. }
  585. context.restore();
  586. // Clear the nested element we appended to the DOM
  587. };
  588. export const renderSelectionElement = (
  589. element: NonDeletedExcalidrawElement,
  590. context: CanvasRenderingContext2D,
  591. appState: InteractiveCanvasAppState,
  592. selectionColor: InteractiveCanvasRenderConfig["selectionColor"],
  593. ) => {
  594. context.save();
  595. context.translate(element.x + appState.scrollX, element.y + appState.scrollY);
  596. context.fillStyle = "rgba(0, 0, 200, 0.04)";
  597. // render from 0.5px offset to get 1px wide line
  598. // https://stackoverflow.com/questions/7530593/html5-canvas-and-line-width/7531540#7531540
  599. // TODO can be be improved by offseting to the negative when user selects
  600. // from right to left
  601. const offset = 0.5 / appState.zoom.value;
  602. context.fillRect(offset, offset, element.width, element.height);
  603. context.lineWidth = 1 / appState.zoom.value;
  604. context.strokeStyle = selectionColor;
  605. context.strokeRect(offset, offset, element.width, element.height);
  606. context.restore();
  607. };
  608. export const renderElement = (
  609. element: NonDeletedExcalidrawElement,
  610. elementsMap: RenderableElementsMap,
  611. allElementsMap: NonDeletedSceneElementsMap,
  612. rc: RoughCanvas,
  613. context: CanvasRenderingContext2D,
  614. renderConfig: StaticCanvasRenderConfig,
  615. appState: StaticCanvasAppState,
  616. ) => {
  617. context.globalAlpha = getRenderOpacity(
  618. element,
  619. getContainingFrame(element, elementsMap),
  620. renderConfig.elementsPendingErasure,
  621. renderConfig.pendingFlowchartNodes,
  622. );
  623. switch (element.type) {
  624. case "magicframe":
  625. case "frame": {
  626. if (appState.frameRendering.enabled && appState.frameRendering.outline) {
  627. context.save();
  628. context.translate(
  629. element.x + appState.scrollX,
  630. element.y + appState.scrollY,
  631. );
  632. context.fillStyle = "rgba(0, 0, 200, 0.04)";
  633. context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
  634. context.strokeStyle = FRAME_STYLE.strokeColor;
  635. // TODO change later to only affect AI frames
  636. if (isMagicFrameElement(element)) {
  637. context.strokeStyle =
  638. appState.theme === THEME.LIGHT ? "#7affd7" : "#1d8264";
  639. }
  640. if (FRAME_STYLE.radius && context.roundRect) {
  641. context.beginPath();
  642. context.roundRect(
  643. 0,
  644. 0,
  645. element.width,
  646. element.height,
  647. FRAME_STYLE.radius / appState.zoom.value,
  648. );
  649. context.stroke();
  650. context.closePath();
  651. } else {
  652. context.strokeRect(0, 0, element.width, element.height);
  653. }
  654. context.restore();
  655. }
  656. break;
  657. }
  658. case "freedraw": {
  659. // TODO investigate if we can do this in situ. Right now we need to call
  660. // beforehand because math helpers (such as getElementAbsoluteCoords)
  661. // rely on existing shapes
  662. ShapeCache.generateElementShape(element, null);
  663. if (renderConfig.isExporting) {
  664. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
  665. const cx = (x1 + x2) / 2 + appState.scrollX;
  666. const cy = (y1 + y2) / 2 + appState.scrollY;
  667. const shiftX = (x2 - x1) / 2 - (element.x - x1);
  668. const shiftY = (y2 - y1) / 2 - (element.y - y1);
  669. context.save();
  670. context.translate(cx, cy);
  671. context.rotate(element.angle);
  672. context.translate(-shiftX, -shiftY);
  673. drawElementOnCanvas(element, rc, context, renderConfig, appState);
  674. context.restore();
  675. } else {
  676. const elementWithCanvas = generateElementWithCanvas(
  677. element,
  678. allElementsMap,
  679. renderConfig,
  680. appState,
  681. );
  682. if (!elementWithCanvas) {
  683. return;
  684. }
  685. drawElementFromCanvas(
  686. elementWithCanvas,
  687. context,
  688. renderConfig,
  689. appState,
  690. allElementsMap,
  691. );
  692. }
  693. break;
  694. }
  695. case "rectangle":
  696. case "diamond":
  697. case "ellipse":
  698. case "line":
  699. case "arrow":
  700. case "image":
  701. case "text":
  702. case "iframe":
  703. case "embeddable": {
  704. // TODO investigate if we can do this in situ. Right now we need to call
  705. // beforehand because math helpers (such as getElementAbsoluteCoords)
  706. // rely on existing shapes
  707. ShapeCache.generateElementShape(element, renderConfig);
  708. if (renderConfig.isExporting) {
  709. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
  710. const cx = (x1 + x2) / 2 + appState.scrollX;
  711. const cy = (y1 + y2) / 2 + appState.scrollY;
  712. let shiftX = (x2 - x1) / 2 - (element.x - x1);
  713. let shiftY = (y2 - y1) / 2 - (element.y - y1);
  714. if (isTextElement(element)) {
  715. const container = getContainerElement(element, elementsMap);
  716. if (isArrowElement(container)) {
  717. const boundTextCoords =
  718. LinearElementEditor.getBoundTextElementPosition(
  719. container,
  720. element as ExcalidrawTextElementWithContainer,
  721. elementsMap,
  722. );
  723. shiftX = (x2 - x1) / 2 - (boundTextCoords.x - x1);
  724. shiftY = (y2 - y1) / 2 - (boundTextCoords.y - y1);
  725. }
  726. }
  727. context.save();
  728. context.translate(cx, cy);
  729. if (shouldResetImageFilter(element, renderConfig, appState)) {
  730. context.filter = "none";
  731. }
  732. const boundTextElement = getBoundTextElement(element, elementsMap);
  733. if (isArrowElement(element) && boundTextElement) {
  734. const tempCanvas = document.createElement("canvas");
  735. const tempCanvasContext = tempCanvas.getContext("2d")!;
  736. // Take max dimensions of arrow canvas so that when canvas is rotated
  737. // the arrow doesn't get clipped
  738. const maxDim = Math.max(distance(x1, x2), distance(y1, y2));
  739. const padding = getCanvasPadding(element);
  740. tempCanvas.width =
  741. maxDim * appState.exportScale + padding * 10 * appState.exportScale;
  742. tempCanvas.height =
  743. maxDim * appState.exportScale + padding * 10 * appState.exportScale;
  744. tempCanvasContext.translate(
  745. tempCanvas.width / 2,
  746. tempCanvas.height / 2,
  747. );
  748. tempCanvasContext.scale(appState.exportScale, appState.exportScale);
  749. // Shift the canvas to left most point of the arrow
  750. shiftX = element.width / 2 - (element.x - x1);
  751. shiftY = element.height / 2 - (element.y - y1);
  752. tempCanvasContext.rotate(element.angle);
  753. const tempRc = rough.canvas(tempCanvas);
  754. tempCanvasContext.translate(-shiftX, -shiftY);
  755. drawElementOnCanvas(
  756. element,
  757. tempRc,
  758. tempCanvasContext,
  759. renderConfig,
  760. appState,
  761. );
  762. tempCanvasContext.translate(shiftX, shiftY);
  763. tempCanvasContext.rotate(-element.angle);
  764. // Shift the canvas to center of bound text
  765. const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords(
  766. boundTextElement,
  767. elementsMap,
  768. );
  769. const boundTextShiftX = (x1 + x2) / 2 - boundTextCx;
  770. const boundTextShiftY = (y1 + y2) / 2 - boundTextCy;
  771. tempCanvasContext.translate(-boundTextShiftX, -boundTextShiftY);
  772. // Clear the bound text area
  773. tempCanvasContext.clearRect(
  774. -boundTextElement.width / 2,
  775. -boundTextElement.height / 2,
  776. boundTextElement.width,
  777. boundTextElement.height,
  778. );
  779. context.scale(1 / appState.exportScale, 1 / appState.exportScale);
  780. context.drawImage(
  781. tempCanvas,
  782. -tempCanvas.width / 2,
  783. -tempCanvas.height / 2,
  784. tempCanvas.width,
  785. tempCanvas.height,
  786. );
  787. } else {
  788. context.rotate(element.angle);
  789. if (element.type === "image") {
  790. // note: scale must be applied *after* rotating
  791. context.scale(element.scale[0], element.scale[1]);
  792. }
  793. context.translate(-shiftX, -shiftY);
  794. drawElementOnCanvas(element, rc, context, renderConfig, appState);
  795. }
  796. context.restore();
  797. // not exporting → optimized rendering (cache & render from element
  798. // canvases)
  799. } else {
  800. const elementWithCanvas = generateElementWithCanvas(
  801. element,
  802. allElementsMap,
  803. renderConfig,
  804. appState,
  805. );
  806. if (!elementWithCanvas) {
  807. return;
  808. }
  809. const currentImageSmoothingStatus = context.imageSmoothingEnabled;
  810. if (
  811. // do not disable smoothing during zoom as blurry shapes look better
  812. // on low resolution (while still zooming in) than sharp ones
  813. !appState?.shouldCacheIgnoreZoom &&
  814. // angle is 0 -> always disable smoothing
  815. (!element.angle ||
  816. // or check if angle is a right angle in which case we can still
  817. // disable smoothing without adversely affecting the result
  818. // We need less-than comparison because of FP artihmetic
  819. isRightAngleRads(element.angle))
  820. ) {
  821. // Disabling smoothing makes output much sharper, especially for
  822. // text. Unless for non-right angles, where the aliasing is really
  823. // terrible on Chromium.
  824. //
  825. // Note that `context.imageSmoothingQuality="high"` has almost
  826. // zero effect.
  827. //
  828. context.imageSmoothingEnabled = false;
  829. }
  830. if (
  831. element.id === appState.croppingElementId &&
  832. isImageElement(elementWithCanvas.element) &&
  833. elementWithCanvas.element.crop !== null
  834. ) {
  835. context.save();
  836. context.globalAlpha = 0.1;
  837. const uncroppedElementCanvas = generateElementCanvas(
  838. getUncroppedImageElement(elementWithCanvas.element, elementsMap),
  839. allElementsMap,
  840. appState.zoom,
  841. renderConfig,
  842. appState,
  843. );
  844. if (uncroppedElementCanvas) {
  845. drawElementFromCanvas(
  846. uncroppedElementCanvas,
  847. context,
  848. renderConfig,
  849. appState,
  850. allElementsMap,
  851. );
  852. }
  853. context.restore();
  854. }
  855. const _elementWithCanvas = generateElementCanvas(
  856. elementWithCanvas.element,
  857. allElementsMap,
  858. appState.zoom,
  859. renderConfig,
  860. appState,
  861. );
  862. if (_elementWithCanvas) {
  863. drawElementFromCanvas(
  864. _elementWithCanvas,
  865. context,
  866. renderConfig,
  867. appState,
  868. allElementsMap,
  869. );
  870. }
  871. // reset
  872. context.imageSmoothingEnabled = currentImageSmoothingStatus;
  873. }
  874. break;
  875. }
  876. default: {
  877. // @ts-ignore
  878. throw new Error(`Unimplemented type ${element.type}`);
  879. }
  880. }
  881. context.globalAlpha = 1;
  882. };
  883. export const pathsCache = new WeakMap<ExcalidrawFreeDrawElement, Path2D>([]);
  884. export function generateFreeDrawShape(element: ExcalidrawFreeDrawElement) {
  885. const svgPathData = getFreeDrawSvgPath(element);
  886. const path = new Path2D(svgPathData);
  887. pathsCache.set(element, path);
  888. return path;
  889. }
  890. export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
  891. return pathsCache.get(element);
  892. }
  893. export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
  894. // If input points are empty (should they ever be?) return a dot
  895. const inputPoints = element.simulatePressure
  896. ? element.points
  897. : element.points.length
  898. ? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
  899. : [[0, 0, 0.5]];
  900. // Consider changing the options for simulated pressure vs real pressure
  901. const options: StrokeOptions = {
  902. simulatePressure: element.simulatePressure,
  903. size: element.strokeWidth * 4.25,
  904. thinning: 0.6,
  905. smoothing: 0.5,
  906. streamline: 0.5,
  907. easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
  908. last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
  909. };
  910. return getSvgPathFromStroke(getStroke(inputPoints as number[][], options));
  911. }
  912. function med(A: number[], B: number[]) {
  913. return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
  914. }
  915. // Trim SVG path data so number are each two decimal points. This
  916. // improves SVG exports, and prevents rendering errors on points
  917. // with long decimals.
  918. const TO_FIXED_PRECISION = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g;
  919. function getSvgPathFromStroke(points: number[][]): string {
  920. if (!points.length) {
  921. return "";
  922. }
  923. const max = points.length - 1;
  924. return points
  925. .reduce(
  926. (acc, point, i, arr) => {
  927. if (i === max) {
  928. acc.push(point, med(point, arr[0]), "L", arr[0], "Z");
  929. } else {
  930. acc.push(point, med(point, arr[i + 1]));
  931. }
  932. return acc;
  933. },
  934. ["M", points[0], "Q"],
  935. )
  936. .join(" ")
  937. .replace(TO_FIXED_PRECISION, "$1");
  938. }