renderElement.ts 32 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010
  1. import {
  2. ExcalidrawElement,
  3. ExcalidrawLinearElement,
  4. ExcalidrawTextElement,
  5. Arrowhead,
  6. NonDeletedExcalidrawElement,
  7. ExcalidrawFreeDrawElement,
  8. ExcalidrawImageElement,
  9. } from "../element/types";
  10. import {
  11. isTextElement,
  12. isLinearElement,
  13. isFreeDrawElement,
  14. isInitializedImageElement,
  15. } from "../element/typeChecks";
  16. import {
  17. getDiamondPoints,
  18. getElementAbsoluteCoords,
  19. getArrowheadPoints,
  20. } from "../element/bounds";
  21. import { RoughCanvas } from "roughjs/bin/canvas";
  22. import { Drawable, Options } from "roughjs/bin/core";
  23. import { RoughSVG } from "roughjs/bin/svg";
  24. import { RoughGenerator } from "roughjs/bin/generator";
  25. import { RenderConfig } from "../scene/types";
  26. import { distance, getFontString, getFontFamilyString, isRTL } from "../utils";
  27. import { isPathALoop } from "../math";
  28. import rough from "roughjs/bin/rough";
  29. import { AppState, BinaryFiles, Zoom } from "../types";
  30. import { getDefaultAppState } from "../appState";
  31. import { MAX_DECIMALS_FOR_SVG_EXPORT, MIME_TYPES, SVG_NS } from "../constants";
  32. import { getStroke, StrokeOptions } from "perfect-freehand";
  33. // using a stronger invert (100% vs our regular 93%) and saturate
  34. // as a temp hack to make images in dark theme look closer to original
  35. // color scheme (it's still not quite there and the colors look slightly
  36. // desatured, alas...)
  37. const IMAGE_INVERT_FILTER = "invert(100%) hue-rotate(180deg) saturate(1.25)";
  38. const defaultAppState = getDefaultAppState();
  39. const isPendingImageElement = (
  40. element: ExcalidrawElement,
  41. renderConfig: RenderConfig,
  42. ) =>
  43. isInitializedImageElement(element) &&
  44. !renderConfig.imageCache.has(element.fileId);
  45. const shouldResetImageFilter = (
  46. element: ExcalidrawElement,
  47. renderConfig: RenderConfig,
  48. ) => {
  49. return (
  50. renderConfig.theme === "dark" &&
  51. isInitializedImageElement(element) &&
  52. !isPendingImageElement(element, renderConfig) &&
  53. renderConfig.imageCache.get(element.fileId)?.mimeType !== MIME_TYPES.svg
  54. );
  55. };
  56. const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
  57. const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
  58. const getCanvasPadding = (element: ExcalidrawElement) =>
  59. element.type === "freedraw" ? element.strokeWidth * 12 : 20;
  60. export interface ExcalidrawElementWithCanvas {
  61. element: ExcalidrawElement | ExcalidrawTextElement;
  62. canvas: HTMLCanvasElement;
  63. theme: RenderConfig["theme"];
  64. canvasZoom: Zoom["value"];
  65. canvasOffsetX: number;
  66. canvasOffsetY: number;
  67. }
  68. const generateElementCanvas = (
  69. element: NonDeletedExcalidrawElement,
  70. zoom: Zoom,
  71. renderConfig: RenderConfig,
  72. ): ExcalidrawElementWithCanvas => {
  73. const canvas = document.createElement("canvas");
  74. const context = canvas.getContext("2d")!;
  75. const padding = getCanvasPadding(element);
  76. let canvasOffsetX = 0;
  77. let canvasOffsetY = 0;
  78. if (isLinearElement(element) || isFreeDrawElement(element)) {
  79. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  80. canvas.width =
  81. distance(x1, x2) * window.devicePixelRatio * zoom.value +
  82. padding * zoom.value * 2;
  83. canvas.height =
  84. distance(y1, y2) * window.devicePixelRatio * zoom.value +
  85. padding * zoom.value * 2;
  86. canvasOffsetX =
  87. element.x > x1
  88. ? distance(element.x, x1) * window.devicePixelRatio * zoom.value
  89. : 0;
  90. canvasOffsetY =
  91. element.y > y1
  92. ? distance(element.y, y1) * window.devicePixelRatio * zoom.value
  93. : 0;
  94. context.translate(canvasOffsetX, canvasOffsetY);
  95. } else {
  96. canvas.width =
  97. element.width * window.devicePixelRatio * zoom.value +
  98. padding * zoom.value * 2;
  99. canvas.height =
  100. element.height * window.devicePixelRatio * zoom.value +
  101. padding * zoom.value * 2;
  102. }
  103. context.save();
  104. context.translate(padding * zoom.value, padding * zoom.value);
  105. context.scale(
  106. window.devicePixelRatio * zoom.value,
  107. window.devicePixelRatio * zoom.value,
  108. );
  109. const rc = rough.canvas(canvas);
  110. // in dark theme, revert the image color filter
  111. if (shouldResetImageFilter(element, renderConfig)) {
  112. context.filter = IMAGE_INVERT_FILTER;
  113. }
  114. drawElementOnCanvas(element, rc, context, renderConfig);
  115. context.restore();
  116. return {
  117. element,
  118. canvas,
  119. theme: renderConfig.theme,
  120. canvasZoom: zoom.value,
  121. canvasOffsetX,
  122. canvasOffsetY,
  123. };
  124. };
  125. const IMAGE_PLACEHOLDER_IMG = document.createElement("img");
  126. IMAGE_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
  127. `<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>`,
  128. )}`;
  129. const IMAGE_ERROR_PLACEHOLDER_IMG = document.createElement("img");
  130. IMAGE_ERROR_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
  131. `<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>`,
  132. )}`;
  133. const drawImagePlaceholder = (
  134. element: ExcalidrawImageElement,
  135. context: CanvasRenderingContext2D,
  136. zoomValue: AppState["zoom"]["value"],
  137. ) => {
  138. context.fillStyle = "#E7E7E7";
  139. context.fillRect(0, 0, element.width, element.height);
  140. const imageMinWidthOrHeight = Math.min(element.width, element.height);
  141. const size = Math.min(
  142. imageMinWidthOrHeight,
  143. Math.min(imageMinWidthOrHeight * 0.4, 100),
  144. );
  145. context.drawImage(
  146. element.status === "error"
  147. ? IMAGE_ERROR_PLACEHOLDER_IMG
  148. : IMAGE_PLACEHOLDER_IMG,
  149. element.width / 2 - size / 2,
  150. element.height / 2 - size / 2,
  151. size,
  152. size,
  153. );
  154. };
  155. const drawElementOnCanvas = (
  156. element: NonDeletedExcalidrawElement,
  157. rc: RoughCanvas,
  158. context: CanvasRenderingContext2D,
  159. renderConfig: RenderConfig,
  160. ) => {
  161. context.globalAlpha = element.opacity / 100;
  162. switch (element.type) {
  163. case "rectangle":
  164. case "diamond":
  165. case "ellipse": {
  166. context.lineJoin = "round";
  167. context.lineCap = "round";
  168. rc.draw(getShapeForElement(element) as Drawable);
  169. break;
  170. }
  171. case "arrow":
  172. case "line": {
  173. context.lineJoin = "round";
  174. context.lineCap = "round";
  175. (getShapeForElement(element) as Drawable[]).forEach((shape) => {
  176. rc.draw(shape);
  177. });
  178. break;
  179. }
  180. case "freedraw": {
  181. // Draw directly to canvas
  182. context.save();
  183. context.fillStyle = element.strokeColor;
  184. const path = getFreeDrawPath2D(element) as Path2D;
  185. context.fillStyle = element.strokeColor;
  186. context.fill(path);
  187. context.restore();
  188. break;
  189. }
  190. case "image": {
  191. const img = isInitializedImageElement(element)
  192. ? renderConfig.imageCache.get(element.fileId)?.image
  193. : undefined;
  194. if (img != null && !(img instanceof Promise)) {
  195. context.drawImage(
  196. img,
  197. 0 /* hardcoded for the selection box*/,
  198. 0,
  199. element.width,
  200. element.height,
  201. );
  202. } else {
  203. drawImagePlaceholder(element, context, renderConfig.zoom.value);
  204. }
  205. break;
  206. }
  207. default: {
  208. if (isTextElement(element)) {
  209. const rtl = isRTL(element.text);
  210. const shouldTemporarilyAttach = rtl && !context.canvas.isConnected;
  211. if (shouldTemporarilyAttach) {
  212. // to correctly render RTL text mixed with LTR, we have to append it
  213. // to the DOM
  214. document.body.appendChild(context.canvas);
  215. }
  216. context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr");
  217. context.save();
  218. context.font = getFontString(element);
  219. context.fillStyle = element.strokeColor;
  220. context.textAlign = element.textAlign as CanvasTextAlign;
  221. // Canvas does not support multiline text by default
  222. const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
  223. const lineHeight = element.height / lines.length;
  224. const verticalOffset = element.height - element.baseline;
  225. const horizontalOffset =
  226. element.textAlign === "center"
  227. ? element.width / 2
  228. : element.textAlign === "right"
  229. ? element.width
  230. : 0;
  231. for (let index = 0; index < lines.length; index++) {
  232. context.fillText(
  233. lines[index],
  234. horizontalOffset,
  235. (index + 1) * lineHeight - verticalOffset,
  236. );
  237. }
  238. context.restore();
  239. if (shouldTemporarilyAttach) {
  240. context.canvas.remove();
  241. }
  242. } else {
  243. throw new Error(`Unimplemented type ${element.type}`);
  244. }
  245. }
  246. }
  247. context.globalAlpha = 1;
  248. };
  249. const elementWithCanvasCache = new WeakMap<
  250. ExcalidrawElement,
  251. ExcalidrawElementWithCanvas
  252. >();
  253. const shapeCache = new WeakMap<
  254. ExcalidrawElement,
  255. Drawable | Drawable[] | null
  256. >();
  257. export const getShapeForElement = (element: ExcalidrawElement) =>
  258. shapeCache.get(element);
  259. export const invalidateShapeForElement = (element: ExcalidrawElement) =>
  260. shapeCache.delete(element);
  261. export const generateRoughOptions = (
  262. element: ExcalidrawElement,
  263. continuousPath = false,
  264. ): Options => {
  265. const options: Options = {
  266. seed: element.seed,
  267. strokeLineDash:
  268. element.strokeStyle === "dashed"
  269. ? getDashArrayDashed(element.strokeWidth)
  270. : element.strokeStyle === "dotted"
  271. ? getDashArrayDotted(element.strokeWidth)
  272. : undefined,
  273. // for non-solid strokes, disable multiStroke because it tends to make
  274. // dashes/dots overlay each other
  275. disableMultiStroke: element.strokeStyle !== "solid",
  276. // for non-solid strokes, increase the width a bit to make it visually
  277. // similar to solid strokes, because we're also disabling multiStroke
  278. strokeWidth:
  279. element.strokeStyle !== "solid"
  280. ? element.strokeWidth + 0.5
  281. : element.strokeWidth,
  282. // when increasing strokeWidth, we must explicitly set fillWeight and
  283. // hachureGap because if not specified, roughjs uses strokeWidth to
  284. // calculate them (and we don't want the fills to be modified)
  285. fillWeight: element.strokeWidth / 2,
  286. hachureGap: element.strokeWidth * 4,
  287. roughness: element.roughness,
  288. stroke: element.strokeColor,
  289. preserveVertices: continuousPath,
  290. };
  291. switch (element.type) {
  292. case "rectangle":
  293. case "diamond":
  294. case "ellipse": {
  295. options.fillStyle = element.fillStyle;
  296. options.fill =
  297. element.backgroundColor === "transparent"
  298. ? undefined
  299. : element.backgroundColor;
  300. if (element.type === "ellipse") {
  301. options.curveFitting = 1;
  302. }
  303. return options;
  304. }
  305. case "line": {
  306. if (isPathALoop(element.points)) {
  307. options.fillStyle = element.fillStyle;
  308. options.fill =
  309. element.backgroundColor === "transparent"
  310. ? undefined
  311. : element.backgroundColor;
  312. }
  313. return options;
  314. }
  315. case "freedraw":
  316. case "arrow":
  317. return options;
  318. default: {
  319. throw new Error(`Unimplemented type ${element.type}`);
  320. }
  321. }
  322. };
  323. /**
  324. * Generates the element's shape and puts it into the cache.
  325. * @param element
  326. * @param generator
  327. */
  328. const generateElementShape = (
  329. element: NonDeletedExcalidrawElement,
  330. generator: RoughGenerator,
  331. ) => {
  332. let shape = shapeCache.get(element) || null;
  333. if (!shape) {
  334. elementWithCanvasCache.delete(element);
  335. switch (element.type) {
  336. case "rectangle":
  337. if (element.strokeSharpness === "round") {
  338. const w = element.width;
  339. const h = element.height;
  340. const r = Math.min(w, h) * 0.25;
  341. shape = generator.path(
  342. `M ${r} 0 L ${w - r} 0 Q ${w} 0, ${w} ${r} L ${w} ${
  343. h - r
  344. } Q ${w} ${h}, ${w - r} ${h} L ${r} ${h} Q 0 ${h}, 0 ${
  345. h - r
  346. } L 0 ${r} Q 0 0, ${r} 0`,
  347. generateRoughOptions(element, true),
  348. );
  349. } else {
  350. shape = generator.rectangle(
  351. 0,
  352. 0,
  353. element.width,
  354. element.height,
  355. generateRoughOptions(element),
  356. );
  357. }
  358. break;
  359. case "diamond": {
  360. const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
  361. getDiamondPoints(element);
  362. shape = generator.polygon(
  363. [
  364. [topX, topY],
  365. [rightX, rightY],
  366. [bottomX, bottomY],
  367. [leftX, leftY],
  368. ],
  369. generateRoughOptions(element),
  370. );
  371. break;
  372. }
  373. case "ellipse":
  374. shape = generator.ellipse(
  375. element.width / 2,
  376. element.height / 2,
  377. element.width,
  378. element.height,
  379. generateRoughOptions(element),
  380. );
  381. break;
  382. case "line":
  383. case "arrow": {
  384. const options = generateRoughOptions(element);
  385. // points array can be empty in the beginning, so it is important to add
  386. // initial position to it
  387. const points = element.points.length ? element.points : [[0, 0]];
  388. // curve is always the first element
  389. // this simplifies finding the curve for an element
  390. if (element.strokeSharpness === "sharp") {
  391. if (options.fill) {
  392. shape = [generator.polygon(points as [number, number][], options)];
  393. } else {
  394. shape = [
  395. generator.linearPath(points as [number, number][], options),
  396. ];
  397. }
  398. } else {
  399. shape = [generator.curve(points as [number, number][], options)];
  400. }
  401. // add lines only in arrow
  402. if (element.type === "arrow") {
  403. const { startArrowhead = null, endArrowhead = "arrow" } = element;
  404. const getArrowheadShapes = (
  405. element: ExcalidrawLinearElement,
  406. shape: Drawable[],
  407. position: "start" | "end",
  408. arrowhead: Arrowhead,
  409. ) => {
  410. const arrowheadPoints = getArrowheadPoints(
  411. element,
  412. shape,
  413. position,
  414. arrowhead,
  415. );
  416. if (arrowheadPoints === null) {
  417. return [];
  418. }
  419. // Other arrowheads here...
  420. if (arrowhead === "dot") {
  421. const [x, y, r] = arrowheadPoints;
  422. return [
  423. generator.circle(x, y, r, {
  424. ...options,
  425. fill: element.strokeColor,
  426. fillStyle: "solid",
  427. stroke: "none",
  428. }),
  429. ];
  430. }
  431. if (arrowhead === "triangle") {
  432. const [x, y, x2, y2, x3, y3] = arrowheadPoints;
  433. // always use solid stroke for triangle arrowhead
  434. delete options.strokeLineDash;
  435. return [
  436. generator.polygon(
  437. [
  438. [x, y],
  439. [x2, y2],
  440. [x3, y3],
  441. [x, y],
  442. ],
  443. {
  444. ...options,
  445. fill: element.strokeColor,
  446. fillStyle: "solid",
  447. },
  448. ),
  449. ];
  450. }
  451. // Arrow arrowheads
  452. const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
  453. if (element.strokeStyle === "dotted") {
  454. // for dotted arrows caps, reduce gap to make it more legible
  455. const dash = getDashArrayDotted(element.strokeWidth - 1);
  456. options.strokeLineDash = [dash[0], dash[1] - 1];
  457. } else {
  458. // for solid/dashed, keep solid arrow cap
  459. delete options.strokeLineDash;
  460. }
  461. return [
  462. generator.line(x3, y3, x2, y2, options),
  463. generator.line(x4, y4, x2, y2, options),
  464. ];
  465. };
  466. if (startArrowhead !== null) {
  467. const shapes = getArrowheadShapes(
  468. element,
  469. shape,
  470. "start",
  471. startArrowhead,
  472. );
  473. shape.push(...shapes);
  474. }
  475. if (endArrowhead !== null) {
  476. if (endArrowhead === undefined) {
  477. // Hey, we have an old arrow here!
  478. }
  479. const shapes = getArrowheadShapes(
  480. element,
  481. shape,
  482. "end",
  483. endArrowhead,
  484. );
  485. shape.push(...shapes);
  486. }
  487. }
  488. break;
  489. }
  490. case "freedraw": {
  491. generateFreeDrawShape(element);
  492. shape = [];
  493. break;
  494. }
  495. case "text":
  496. case "image": {
  497. // just to ensure we don't regenerate element.canvas on rerenders
  498. shape = [];
  499. break;
  500. }
  501. }
  502. shapeCache.set(element, shape);
  503. }
  504. };
  505. const generateElementWithCanvas = (
  506. element: NonDeletedExcalidrawElement,
  507. renderConfig: RenderConfig,
  508. ) => {
  509. const zoom: Zoom = renderConfig ? renderConfig.zoom : defaultAppState.zoom;
  510. const prevElementWithCanvas = elementWithCanvasCache.get(element);
  511. const shouldRegenerateBecauseZoom =
  512. prevElementWithCanvas &&
  513. prevElementWithCanvas.canvasZoom !== zoom.value &&
  514. !renderConfig?.shouldCacheIgnoreZoom;
  515. if (
  516. !prevElementWithCanvas ||
  517. shouldRegenerateBecauseZoom ||
  518. prevElementWithCanvas.theme !== renderConfig.theme
  519. ) {
  520. const elementWithCanvas = generateElementCanvas(
  521. element,
  522. zoom,
  523. renderConfig,
  524. );
  525. elementWithCanvasCache.set(element, elementWithCanvas);
  526. return elementWithCanvas;
  527. }
  528. return prevElementWithCanvas;
  529. };
  530. const drawElementFromCanvas = (
  531. elementWithCanvas: ExcalidrawElementWithCanvas,
  532. rc: RoughCanvas,
  533. context: CanvasRenderingContext2D,
  534. renderConfig: RenderConfig,
  535. ) => {
  536. const element = elementWithCanvas.element;
  537. const padding = getCanvasPadding(element);
  538. let [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  539. // Free draw elements will otherwise "shuffle" as the min x and y change
  540. if (isFreeDrawElement(element)) {
  541. x1 = Math.floor(x1);
  542. x2 = Math.ceil(x2);
  543. y1 = Math.floor(y1);
  544. y2 = Math.ceil(y2);
  545. }
  546. const cx = ((x1 + x2) / 2 + renderConfig.scrollX) * window.devicePixelRatio;
  547. const cy = ((y1 + y2) / 2 + renderConfig.scrollY) * window.devicePixelRatio;
  548. const _isPendingImageElement = isPendingImageElement(element, renderConfig);
  549. const scaleXFactor =
  550. "scale" in elementWithCanvas.element && !_isPendingImageElement
  551. ? elementWithCanvas.element.scale[0]
  552. : 1;
  553. const scaleYFactor =
  554. "scale" in elementWithCanvas.element && !_isPendingImageElement
  555. ? elementWithCanvas.element.scale[1]
  556. : 1;
  557. context.save();
  558. context.scale(
  559. (1 / window.devicePixelRatio) * scaleXFactor,
  560. (1 / window.devicePixelRatio) * scaleYFactor,
  561. );
  562. context.translate(cx * scaleXFactor, cy * scaleYFactor);
  563. context.rotate(element.angle * scaleXFactor * scaleYFactor);
  564. context.drawImage(
  565. elementWithCanvas.canvas!,
  566. (-(x2 - x1) / 2) * window.devicePixelRatio -
  567. (padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom,
  568. (-(y2 - y1) / 2) * window.devicePixelRatio -
  569. (padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom,
  570. elementWithCanvas.canvas!.width / elementWithCanvas.canvasZoom,
  571. elementWithCanvas.canvas!.height / elementWithCanvas.canvasZoom,
  572. );
  573. context.restore();
  574. // Clear the nested element we appended to the DOM
  575. };
  576. export const renderElement = (
  577. element: NonDeletedExcalidrawElement,
  578. rc: RoughCanvas,
  579. context: CanvasRenderingContext2D,
  580. renderConfig: RenderConfig,
  581. ) => {
  582. const generator = rc.generator;
  583. switch (element.type) {
  584. case "selection": {
  585. context.save();
  586. context.translate(
  587. element.x + renderConfig.scrollX,
  588. element.y + renderConfig.scrollY,
  589. );
  590. context.fillStyle = "rgba(0, 0, 255, 0.10)";
  591. context.fillRect(0, 0, element.width, element.height);
  592. context.restore();
  593. break;
  594. }
  595. case "freedraw": {
  596. generateElementShape(element, generator);
  597. if (renderConfig.isExporting) {
  598. const elementWithCanvas = generateElementWithCanvas(
  599. element,
  600. renderConfig,
  601. );
  602. drawElementFromCanvas(elementWithCanvas, rc, context, renderConfig);
  603. } else {
  604. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  605. const cx = (x1 + x2) / 2 + renderConfig.scrollX;
  606. const cy = (y1 + y2) / 2 + renderConfig.scrollY;
  607. const shiftX = (x2 - x1) / 2 - (element.x - x1);
  608. const shiftY = (y2 - y1) / 2 - (element.y - y1);
  609. context.save();
  610. context.translate(cx, cy);
  611. context.rotate(element.angle);
  612. context.translate(-shiftX, -shiftY);
  613. drawElementOnCanvas(element, rc, context, renderConfig);
  614. context.restore();
  615. }
  616. break;
  617. }
  618. case "rectangle":
  619. case "diamond":
  620. case "ellipse":
  621. case "line":
  622. case "arrow":
  623. case "image":
  624. case "text": {
  625. generateElementShape(element, generator);
  626. if (renderConfig.isExporting) {
  627. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  628. const cx = (x1 + x2) / 2 + renderConfig.scrollX;
  629. const cy = (y1 + y2) / 2 + renderConfig.scrollY;
  630. const shiftX = (x2 - x1) / 2 - (element.x - x1);
  631. const shiftY = (y2 - y1) / 2 - (element.y - y1);
  632. context.save();
  633. context.translate(cx, cy);
  634. context.rotate(element.angle);
  635. context.translate(-shiftX, -shiftY);
  636. if (shouldResetImageFilter(element, renderConfig)) {
  637. context.filter = "none";
  638. }
  639. drawElementOnCanvas(element, rc, context, renderConfig);
  640. context.restore();
  641. // not exporting → optimized rendering (cache & render from element
  642. // canvases)
  643. } else {
  644. const elementWithCanvas = generateElementWithCanvas(
  645. element,
  646. renderConfig,
  647. );
  648. drawElementFromCanvas(elementWithCanvas, rc, context, renderConfig);
  649. }
  650. break;
  651. }
  652. default: {
  653. // @ts-ignore
  654. throw new Error(`Unimplemented type ${element.type}`);
  655. }
  656. }
  657. };
  658. const roughSVGDrawWithPrecision = (
  659. rsvg: RoughSVG,
  660. drawable: Drawable,
  661. precision?: number,
  662. ) => {
  663. if (typeof precision === "undefined") {
  664. return rsvg.draw(drawable);
  665. }
  666. const pshape: Drawable = {
  667. sets: drawable.sets,
  668. shape: drawable.shape,
  669. options: { ...drawable.options, fixedDecimalPlaceDigits: precision },
  670. };
  671. return rsvg.draw(pshape);
  672. };
  673. export const renderElementToSvg = (
  674. element: NonDeletedExcalidrawElement,
  675. rsvg: RoughSVG,
  676. svgRoot: SVGElement,
  677. files: BinaryFiles,
  678. offsetX?: number,
  679. offsetY?: number,
  680. exportWithDarkMode?: boolean,
  681. ) => {
  682. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  683. const cx = (x2 - x1) / 2 - (element.x - x1);
  684. const cy = (y2 - y1) / 2 - (element.y - y1);
  685. const degree = (180 * element.angle) / Math.PI;
  686. const generator = rsvg.generator;
  687. switch (element.type) {
  688. case "selection": {
  689. // Since this is used only during editing experience, which is canvas based,
  690. // this should not happen
  691. throw new Error("Selection rendering is not supported for SVG");
  692. }
  693. case "rectangle":
  694. case "diamond":
  695. case "ellipse": {
  696. generateElementShape(element, generator);
  697. const node = roughSVGDrawWithPrecision(
  698. rsvg,
  699. getShapeForElement(element) as Drawable,
  700. MAX_DECIMALS_FOR_SVG_EXPORT,
  701. );
  702. const opacity = element.opacity / 100;
  703. if (opacity !== 1) {
  704. node.setAttribute("stroke-opacity", `${opacity}`);
  705. node.setAttribute("fill-opacity", `${opacity}`);
  706. }
  707. node.setAttribute("stroke-linecap", "round");
  708. node.setAttribute(
  709. "transform",
  710. `translate(${offsetX || 0} ${
  711. offsetY || 0
  712. }) rotate(${degree} ${cx} ${cy})`,
  713. );
  714. svgRoot.appendChild(node);
  715. break;
  716. }
  717. case "line":
  718. case "arrow": {
  719. generateElementShape(element, generator);
  720. const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
  721. const opacity = element.opacity / 100;
  722. group.setAttribute("stroke-linecap", "round");
  723. (getShapeForElement(element) as Drawable[]).forEach((shape) => {
  724. const node = roughSVGDrawWithPrecision(
  725. rsvg,
  726. shape,
  727. MAX_DECIMALS_FOR_SVG_EXPORT,
  728. );
  729. if (opacity !== 1) {
  730. node.setAttribute("stroke-opacity", `${opacity}`);
  731. node.setAttribute("fill-opacity", `${opacity}`);
  732. }
  733. node.setAttribute(
  734. "transform",
  735. `translate(${offsetX || 0} ${
  736. offsetY || 0
  737. }) rotate(${degree} ${cx} ${cy})`,
  738. );
  739. if (
  740. element.type === "line" &&
  741. isPathALoop(element.points) &&
  742. element.backgroundColor !== "transparent"
  743. ) {
  744. node.setAttribute("fill-rule", "evenodd");
  745. }
  746. group.appendChild(node);
  747. });
  748. svgRoot.appendChild(group);
  749. break;
  750. }
  751. case "freedraw": {
  752. generateFreeDrawShape(element);
  753. const opacity = element.opacity / 100;
  754. const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
  755. if (opacity !== 1) {
  756. node.setAttribute("stroke-opacity", `${opacity}`);
  757. node.setAttribute("fill-opacity", `${opacity}`);
  758. }
  759. node.setAttribute(
  760. "transform",
  761. `translate(${offsetX || 0} ${
  762. offsetY || 0
  763. }) rotate(${degree} ${cx} ${cy})`,
  764. );
  765. const path = svgRoot.ownerDocument!.createElementNS(SVG_NS, "path");
  766. node.setAttribute("stroke", "none");
  767. node.setAttribute("fill", element.strokeColor);
  768. path.setAttribute("d", getFreeDrawSvgPath(element));
  769. node.appendChild(path);
  770. svgRoot.appendChild(node);
  771. break;
  772. }
  773. case "image": {
  774. const fileData =
  775. isInitializedImageElement(element) && files[element.fileId];
  776. if (fileData) {
  777. const symbolId = `image-${fileData.id}`;
  778. let symbol = svgRoot.querySelector(`#${symbolId}`);
  779. if (!symbol) {
  780. symbol = svgRoot.ownerDocument!.createElementNS(SVG_NS, "symbol");
  781. symbol.id = symbolId;
  782. const image = svgRoot.ownerDocument!.createElementNS(SVG_NS, "image");
  783. image.setAttribute("width", "100%");
  784. image.setAttribute("height", "100%");
  785. image.setAttribute("href", fileData.dataURL);
  786. symbol.appendChild(image);
  787. svgRoot.prepend(symbol);
  788. }
  789. const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use");
  790. use.setAttribute("href", `#${symbolId}`);
  791. // in dark theme, revert the image color filter
  792. if (exportWithDarkMode && fileData.mimeType !== MIME_TYPES.svg) {
  793. use.setAttribute("filter", IMAGE_INVERT_FILTER);
  794. }
  795. use.setAttribute("width", `${Math.round(element.width)}`);
  796. use.setAttribute("height", `${Math.round(element.height)}`);
  797. use.setAttribute(
  798. "transform",
  799. `translate(${offsetX || 0} ${
  800. offsetY || 0
  801. }) rotate(${degree} ${cx} ${cy})`,
  802. );
  803. svgRoot.appendChild(use);
  804. }
  805. break;
  806. }
  807. default: {
  808. if (isTextElement(element)) {
  809. const opacity = element.opacity / 100;
  810. const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
  811. if (opacity !== 1) {
  812. node.setAttribute("stroke-opacity", `${opacity}`);
  813. node.setAttribute("fill-opacity", `${opacity}`);
  814. }
  815. node.setAttribute(
  816. "transform",
  817. `translate(${offsetX || 0} ${
  818. offsetY || 0
  819. }) rotate(${degree} ${cx} ${cy})`,
  820. );
  821. const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
  822. const lineHeight = element.height / lines.length;
  823. const verticalOffset = element.height - element.baseline;
  824. const horizontalOffset =
  825. element.textAlign === "center"
  826. ? element.width / 2
  827. : element.textAlign === "right"
  828. ? element.width
  829. : 0;
  830. const direction = isRTL(element.text) ? "rtl" : "ltr";
  831. const textAnchor =
  832. element.textAlign === "center"
  833. ? "middle"
  834. : element.textAlign === "right" || direction === "rtl"
  835. ? "end"
  836. : "start";
  837. for (let i = 0; i < lines.length; i++) {
  838. const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text");
  839. text.textContent = lines[i];
  840. text.setAttribute("x", `${horizontalOffset}`);
  841. text.setAttribute("y", `${(i + 1) * lineHeight - verticalOffset}`);
  842. text.setAttribute("font-family", getFontFamilyString(element));
  843. text.setAttribute("font-size", `${element.fontSize}px`);
  844. text.setAttribute("fill", element.strokeColor);
  845. text.setAttribute("text-anchor", textAnchor);
  846. text.setAttribute("style", "white-space: pre;");
  847. text.setAttribute("direction", direction);
  848. node.appendChild(text);
  849. }
  850. svgRoot.appendChild(node);
  851. } else {
  852. // @ts-ignore
  853. throw new Error(`Unimplemented type ${element.type}`);
  854. }
  855. }
  856. }
  857. };
  858. export const pathsCache = new WeakMap<ExcalidrawFreeDrawElement, Path2D>([]);
  859. export function generateFreeDrawShape(element: ExcalidrawFreeDrawElement) {
  860. const svgPathData = getFreeDrawSvgPath(element);
  861. const path = new Path2D(svgPathData);
  862. pathsCache.set(element, path);
  863. return path;
  864. }
  865. export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
  866. return pathsCache.get(element);
  867. }
  868. export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
  869. // If input points are empty (should they ever be?) return a dot
  870. const inputPoints = element.simulatePressure
  871. ? element.points
  872. : element.points.length
  873. ? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
  874. : [[0, 0, 0.5]];
  875. // Consider changing the options for simulated pressure vs real pressure
  876. const options: StrokeOptions = {
  877. simulatePressure: element.simulatePressure,
  878. size: element.strokeWidth * 4.25,
  879. thinning: 0.6,
  880. smoothing: 0.5,
  881. streamline: 0.5,
  882. easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
  883. last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
  884. };
  885. return getSvgPathFromStroke(getStroke(inputPoints as number[][], options));
  886. }
  887. function med(A: number[], B: number[]) {
  888. return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
  889. }
  890. // Trim SVG path data so number are each two decimal points. This
  891. // improves SVG exports, and prevents rendering errors on points
  892. // with long decimals.
  893. const TO_FIXED_PRECISION = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g;
  894. function getSvgPathFromStroke(points: number[][]): string {
  895. if (!points.length) {
  896. return "";
  897. }
  898. const max = points.length - 1;
  899. return points
  900. .reduce(
  901. (acc, point, i, arr) => {
  902. if (i === max) {
  903. acc.push(point, med(point, arr[0]), "L", arr[0], "Z");
  904. } else {
  905. acc.push(point, med(point, arr[i + 1]));
  906. }
  907. return acc;
  908. },
  909. ["M", points[0], "Q"],
  910. )
  911. .join(" ")
  912. .replace(TO_FIXED_PRECISION, "$1");
  913. }