renderElement.ts 47 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431
  1. import {
  2. ExcalidrawElement,
  3. ExcalidrawLinearElement,
  4. ExcalidrawTextElement,
  5. Arrowhead,
  6. NonDeletedExcalidrawElement,
  7. ExcalidrawFreeDrawElement,
  8. ExcalidrawImageElement,
  9. ExcalidrawTextElementWithContainer,
  10. } from "../element/types";
  11. import {
  12. isTextElement,
  13. isLinearElement,
  14. isFreeDrawElement,
  15. isInitializedImageElement,
  16. isArrowElement,
  17. hasBoundTextElement,
  18. } from "../element/typeChecks";
  19. import {
  20. getDiamondPoints,
  21. getElementAbsoluteCoords,
  22. getArrowheadPoints,
  23. } from "../element/bounds";
  24. import { RoughCanvas } from "roughjs/bin/canvas";
  25. import { Drawable, Options } from "roughjs/bin/core";
  26. import { RoughSVG } from "roughjs/bin/svg";
  27. import { RoughGenerator } from "roughjs/bin/generator";
  28. import { RenderConfig } from "../scene/types";
  29. import { distance, getFontString, getFontFamilyString, isRTL } from "../utils";
  30. import { getCornerRadius, isPathALoop, isRightAngle } from "../math";
  31. import rough from "roughjs/bin/rough";
  32. import { AppState, BinaryFiles, Zoom } from "../types";
  33. import { getDefaultAppState } from "../appState";
  34. import {
  35. BOUND_TEXT_PADDING,
  36. FONT_FAMILY,
  37. MAX_DECIMALS_FOR_SVG_EXPORT,
  38. MIME_TYPES,
  39. SVG_NS,
  40. } from "../constants";
  41. import { getStroke, StrokeOptions } from "perfect-freehand";
  42. import {
  43. getBoundTextElement,
  44. getContainerCoords,
  45. getContainerElement,
  46. getLineHeightInPx,
  47. getMaxContainerHeight,
  48. getMaxContainerWidth,
  49. } from "../element/textElement";
  50. import { LinearElementEditor } from "../element/linearElementEditor";
  51. // using a stronger invert (100% vs our regular 93%) and saturate
  52. // as a temp hack to make images in dark theme look closer to original
  53. // color scheme (it's still not quite there and the colors look slightly
  54. // desatured, alas...)
  55. const IMAGE_INVERT_FILTER = "invert(100%) hue-rotate(180deg) saturate(1.25)";
  56. const defaultAppState = getDefaultAppState();
  57. const isPendingImageElement = (
  58. element: ExcalidrawElement,
  59. renderConfig: RenderConfig,
  60. ) =>
  61. isInitializedImageElement(element) &&
  62. !renderConfig.imageCache.has(element.fileId);
  63. const shouldResetImageFilter = (
  64. element: ExcalidrawElement,
  65. renderConfig: RenderConfig,
  66. ) => {
  67. return (
  68. renderConfig.theme === "dark" &&
  69. isInitializedImageElement(element) &&
  70. !isPendingImageElement(element, renderConfig) &&
  71. renderConfig.imageCache.get(element.fileId)?.mimeType !== MIME_TYPES.svg
  72. );
  73. };
  74. const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
  75. const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
  76. const getCanvasPadding = (element: ExcalidrawElement) =>
  77. element.type === "freedraw" ? element.strokeWidth * 12 : 20;
  78. export interface ExcalidrawElementWithCanvas {
  79. element: ExcalidrawElement | ExcalidrawTextElement;
  80. canvas: HTMLCanvasElement;
  81. theme: RenderConfig["theme"];
  82. canvasZoom: Zoom["value"];
  83. canvasOffsetX: number;
  84. canvasOffsetY: number;
  85. boundTextElementVersion: number | null;
  86. }
  87. const generateElementCanvas = (
  88. element: NonDeletedExcalidrawElement,
  89. zoom: Zoom,
  90. renderConfig: RenderConfig,
  91. ): ExcalidrawElementWithCanvas => {
  92. const canvas = document.createElement("canvas");
  93. const context = canvas.getContext("2d")!;
  94. const padding = getCanvasPadding(element);
  95. let canvasOffsetX = 0;
  96. let canvasOffsetY = 0;
  97. if (isLinearElement(element) || isFreeDrawElement(element)) {
  98. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  99. canvas.width =
  100. distance(x1, x2) * window.devicePixelRatio * zoom.value +
  101. padding * zoom.value * 2;
  102. canvas.height =
  103. distance(y1, y2) * window.devicePixelRatio * zoom.value +
  104. padding * zoom.value * 2;
  105. canvasOffsetX =
  106. element.x > x1
  107. ? distance(element.x, x1) * window.devicePixelRatio * zoom.value
  108. : 0;
  109. canvasOffsetY =
  110. element.y > y1
  111. ? distance(element.y, y1) * window.devicePixelRatio * zoom.value
  112. : 0;
  113. context.translate(canvasOffsetX, canvasOffsetY);
  114. } else {
  115. canvas.width =
  116. element.width * window.devicePixelRatio * zoom.value +
  117. padding * zoom.value * 2;
  118. canvas.height =
  119. element.height * window.devicePixelRatio * zoom.value +
  120. padding * zoom.value * 2;
  121. }
  122. context.save();
  123. context.translate(padding * zoom.value, padding * zoom.value);
  124. context.scale(
  125. window.devicePixelRatio * zoom.value,
  126. window.devicePixelRatio * zoom.value,
  127. );
  128. const rc = rough.canvas(canvas);
  129. // in dark theme, revert the image color filter
  130. if (shouldResetImageFilter(element, renderConfig)) {
  131. context.filter = IMAGE_INVERT_FILTER;
  132. }
  133. drawElementOnCanvas(element, rc, context, renderConfig);
  134. context.restore();
  135. return {
  136. element,
  137. canvas,
  138. theme: renderConfig.theme,
  139. canvasZoom: zoom.value,
  140. canvasOffsetX,
  141. canvasOffsetY,
  142. boundTextElementVersion: getBoundTextElement(element)?.version || null,
  143. };
  144. };
  145. export const DEFAULT_LINK_SIZE = 14;
  146. const IMAGE_PLACEHOLDER_IMG = document.createElement("img");
  147. IMAGE_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
  148. `<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>`,
  149. )}`;
  150. const IMAGE_ERROR_PLACEHOLDER_IMG = document.createElement("img");
  151. IMAGE_ERROR_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
  152. `<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>`,
  153. )}`;
  154. const drawImagePlaceholder = (
  155. element: ExcalidrawImageElement,
  156. context: CanvasRenderingContext2D,
  157. zoomValue: AppState["zoom"]["value"],
  158. ) => {
  159. context.fillStyle = "#E7E7E7";
  160. context.fillRect(0, 0, element.width, element.height);
  161. const imageMinWidthOrHeight = Math.min(element.width, element.height);
  162. const size = Math.min(
  163. imageMinWidthOrHeight,
  164. Math.min(imageMinWidthOrHeight * 0.4, 100),
  165. );
  166. context.drawImage(
  167. element.status === "error"
  168. ? IMAGE_ERROR_PLACEHOLDER_IMG
  169. : IMAGE_PLACEHOLDER_IMG,
  170. element.width / 2 - size / 2,
  171. element.height / 2 - size / 2,
  172. size,
  173. size,
  174. );
  175. };
  176. const drawElementOnCanvas = (
  177. element: NonDeletedExcalidrawElement,
  178. rc: RoughCanvas,
  179. context: CanvasRenderingContext2D,
  180. renderConfig: RenderConfig,
  181. ) => {
  182. context.globalAlpha = element.opacity / 100;
  183. switch (element.type) {
  184. case "rectangle":
  185. case "diamond":
  186. case "ellipse": {
  187. context.lineJoin = "round";
  188. context.lineCap = "round";
  189. rc.draw(getShapeForElement(element)!);
  190. break;
  191. }
  192. case "arrow":
  193. case "line": {
  194. context.lineJoin = "round";
  195. context.lineCap = "round";
  196. getShapeForElement(element)!.forEach((shape) => {
  197. rc.draw(shape);
  198. });
  199. break;
  200. }
  201. case "freedraw": {
  202. // Draw directly to canvas
  203. context.save();
  204. context.fillStyle = element.strokeColor;
  205. const path = getFreeDrawPath2D(element) as Path2D;
  206. const fillShape = getShapeForElement(element);
  207. if (fillShape) {
  208. rc.draw(fillShape);
  209. }
  210. context.fillStyle = element.strokeColor;
  211. context.fill(path);
  212. context.restore();
  213. break;
  214. }
  215. case "image": {
  216. const img = isInitializedImageElement(element)
  217. ? renderConfig.imageCache.get(element.fileId)?.image
  218. : undefined;
  219. if (img != null && !(img instanceof Promise)) {
  220. context.drawImage(
  221. img,
  222. 0 /* hardcoded for the selection box*/,
  223. 0,
  224. element.width,
  225. element.height,
  226. );
  227. } else {
  228. drawImagePlaceholder(element, context, renderConfig.zoom.value);
  229. }
  230. break;
  231. }
  232. default: {
  233. if (isTextElement(element)) {
  234. const rtl = isRTL(element.text);
  235. const shouldTemporarilyAttach = rtl && !context.canvas.isConnected;
  236. if (shouldTemporarilyAttach) {
  237. // to correctly render RTL text mixed with LTR, we have to append it
  238. // to the DOM
  239. document.body.appendChild(context.canvas);
  240. }
  241. context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr");
  242. context.save();
  243. context.font = getFontString(element);
  244. context.fillStyle = element.strokeColor;
  245. context.textAlign = element.textAlign as CanvasTextAlign;
  246. // Canvas does not support multiline text by default
  247. const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
  248. const horizontalOffset =
  249. element.textAlign === "center"
  250. ? element.width / 2
  251. : element.textAlign === "right"
  252. ? element.width
  253. : 0;
  254. // FIXME temporary hack
  255. context.textBaseline =
  256. element.fontFamily === FONT_FAMILY.Virgil ||
  257. element.fontFamily === FONT_FAMILY.Cascadia
  258. ? "ideographic"
  259. : "bottom";
  260. const lineHeightPx = getLineHeightInPx(
  261. element.fontSize,
  262. element.lineHeight,
  263. );
  264. for (let index = 0; index < lines.length; index++) {
  265. context.fillText(
  266. lines[index],
  267. horizontalOffset,
  268. (index + 1) * lineHeightPx,
  269. );
  270. }
  271. context.restore();
  272. if (shouldTemporarilyAttach) {
  273. context.canvas.remove();
  274. }
  275. } else {
  276. throw new Error(`Unimplemented type ${element.type}`);
  277. }
  278. }
  279. }
  280. context.globalAlpha = 1;
  281. };
  282. const elementWithCanvasCache = new WeakMap<
  283. ExcalidrawElement,
  284. ExcalidrawElementWithCanvas
  285. >();
  286. const shapeCache = new WeakMap<ExcalidrawElement, ElementShape>();
  287. type ElementShape = Drawable | Drawable[] | null;
  288. type ElementShapes = {
  289. freedraw: Drawable | null;
  290. arrow: Drawable[];
  291. line: Drawable[];
  292. text: null;
  293. image: null;
  294. };
  295. export const getShapeForElement = <T extends ExcalidrawElement>(element: T) =>
  296. shapeCache.get(element) as T["type"] extends keyof ElementShapes
  297. ? ElementShapes[T["type"]] | undefined
  298. : Drawable | null | undefined;
  299. export const setShapeForElement = <T extends ExcalidrawElement>(
  300. element: T,
  301. shape: T["type"] extends keyof ElementShapes
  302. ? ElementShapes[T["type"]]
  303. : Drawable,
  304. ) => shapeCache.set(element, shape);
  305. export const invalidateShapeForElement = (element: ExcalidrawElement) =>
  306. shapeCache.delete(element);
  307. export const generateRoughOptions = (
  308. element: ExcalidrawElement,
  309. continuousPath = false,
  310. ): Options => {
  311. const options: Options = {
  312. seed: element.seed,
  313. strokeLineDash:
  314. element.strokeStyle === "dashed"
  315. ? getDashArrayDashed(element.strokeWidth)
  316. : element.strokeStyle === "dotted"
  317. ? getDashArrayDotted(element.strokeWidth)
  318. : undefined,
  319. // for non-solid strokes, disable multiStroke because it tends to make
  320. // dashes/dots overlay each other
  321. disableMultiStroke: element.strokeStyle !== "solid",
  322. // for non-solid strokes, increase the width a bit to make it visually
  323. // similar to solid strokes, because we're also disabling multiStroke
  324. strokeWidth:
  325. element.strokeStyle !== "solid"
  326. ? element.strokeWidth + 0.5
  327. : element.strokeWidth,
  328. // when increasing strokeWidth, we must explicitly set fillWeight and
  329. // hachureGap because if not specified, roughjs uses strokeWidth to
  330. // calculate them (and we don't want the fills to be modified)
  331. fillWeight: element.strokeWidth / 2,
  332. hachureGap: element.strokeWidth * 4,
  333. roughness: element.roughness,
  334. stroke: element.strokeColor,
  335. preserveVertices: continuousPath,
  336. };
  337. switch (element.type) {
  338. case "rectangle":
  339. case "diamond":
  340. case "ellipse": {
  341. options.fillStyle = element.fillStyle;
  342. options.fill =
  343. element.backgroundColor === "transparent"
  344. ? undefined
  345. : element.backgroundColor;
  346. if (element.type === "ellipse") {
  347. options.curveFitting = 1;
  348. }
  349. return options;
  350. }
  351. case "line":
  352. case "freedraw": {
  353. if (isPathALoop(element.points)) {
  354. options.fillStyle = element.fillStyle;
  355. options.fill =
  356. element.backgroundColor === "transparent"
  357. ? undefined
  358. : element.backgroundColor;
  359. }
  360. return options;
  361. }
  362. case "arrow":
  363. return options;
  364. default: {
  365. throw new Error(`Unimplemented type ${element.type}`);
  366. }
  367. }
  368. };
  369. /**
  370. * Generates the element's shape and puts it into the cache.
  371. * @param element
  372. * @param generator
  373. */
  374. const generateElementShape = (
  375. element: NonDeletedExcalidrawElement,
  376. generator: RoughGenerator,
  377. ) => {
  378. let shape = shapeCache.get(element);
  379. // `null` indicates no rc shape applicable for this element type
  380. // (= do not generate anything)
  381. if (shape === undefined) {
  382. elementWithCanvasCache.delete(element);
  383. switch (element.type) {
  384. case "rectangle":
  385. if (element.roundness) {
  386. const w = element.width;
  387. const h = element.height;
  388. const r = getCornerRadius(Math.min(w, h), element);
  389. shape = generator.path(
  390. `M ${r} 0 L ${w - r} 0 Q ${w} 0, ${w} ${r} L ${w} ${
  391. h - r
  392. } Q ${w} ${h}, ${w - r} ${h} L ${r} ${h} Q 0 ${h}, 0 ${
  393. h - r
  394. } L 0 ${r} Q 0 0, ${r} 0`,
  395. generateRoughOptions(element, true),
  396. );
  397. } else {
  398. shape = generator.rectangle(
  399. 0,
  400. 0,
  401. element.width,
  402. element.height,
  403. generateRoughOptions(element),
  404. );
  405. }
  406. setShapeForElement(element, shape);
  407. break;
  408. case "diamond": {
  409. const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
  410. getDiamondPoints(element);
  411. if (element.roundness) {
  412. const verticalRadius = getCornerRadius(
  413. Math.abs(topX - leftX),
  414. element,
  415. );
  416. const horizontalRadius = getCornerRadius(
  417. Math.abs(rightY - topY),
  418. element,
  419. );
  420. shape = generator.path(
  421. `M ${topX + verticalRadius} ${topY + horizontalRadius} L ${
  422. rightX - verticalRadius
  423. } ${rightY - horizontalRadius}
  424. C ${rightX} ${rightY}, ${rightX} ${rightY}, ${
  425. rightX - verticalRadius
  426. } ${rightY + horizontalRadius}
  427. L ${bottomX + verticalRadius} ${bottomY - horizontalRadius}
  428. C ${bottomX} ${bottomY}, ${bottomX} ${bottomY}, ${
  429. bottomX - verticalRadius
  430. } ${bottomY - horizontalRadius}
  431. L ${leftX + verticalRadius} ${leftY + horizontalRadius}
  432. C ${leftX} ${leftY}, ${leftX} ${leftY}, ${leftX + verticalRadius} ${
  433. leftY - horizontalRadius
  434. }
  435. L ${topX - verticalRadius} ${topY + horizontalRadius}
  436. C ${topX} ${topY}, ${topX} ${topY}, ${topX + verticalRadius} ${
  437. topY + horizontalRadius
  438. }`,
  439. generateRoughOptions(element, true),
  440. );
  441. } else {
  442. shape = generator.polygon(
  443. [
  444. [topX, topY],
  445. [rightX, rightY],
  446. [bottomX, bottomY],
  447. [leftX, leftY],
  448. ],
  449. generateRoughOptions(element),
  450. );
  451. }
  452. setShapeForElement(element, shape);
  453. break;
  454. }
  455. case "ellipse":
  456. shape = generator.ellipse(
  457. element.width / 2,
  458. element.height / 2,
  459. element.width,
  460. element.height,
  461. generateRoughOptions(element),
  462. );
  463. setShapeForElement(element, shape);
  464. break;
  465. case "line":
  466. case "arrow": {
  467. const options = generateRoughOptions(element);
  468. // points array can be empty in the beginning, so it is important to add
  469. // initial position to it
  470. const points = element.points.length ? element.points : [[0, 0]];
  471. // curve is always the first element
  472. // this simplifies finding the curve for an element
  473. if (!element.roundness) {
  474. if (options.fill) {
  475. shape = [generator.polygon(points as [number, number][], options)];
  476. } else {
  477. shape = [
  478. generator.linearPath(points as [number, number][], options),
  479. ];
  480. }
  481. } else {
  482. shape = [generator.curve(points as [number, number][], options)];
  483. }
  484. // add lines only in arrow
  485. if (element.type === "arrow") {
  486. const { startArrowhead = null, endArrowhead = "arrow" } = element;
  487. const getArrowheadShapes = (
  488. element: ExcalidrawLinearElement,
  489. shape: Drawable[],
  490. position: "start" | "end",
  491. arrowhead: Arrowhead,
  492. ) => {
  493. const arrowheadPoints = getArrowheadPoints(
  494. element,
  495. shape,
  496. position,
  497. arrowhead,
  498. );
  499. if (arrowheadPoints === null) {
  500. return [];
  501. }
  502. // Other arrowheads here...
  503. if (arrowhead === "dot") {
  504. const [x, y, r] = arrowheadPoints;
  505. return [
  506. generator.circle(x, y, r, {
  507. ...options,
  508. fill: element.strokeColor,
  509. fillStyle: "solid",
  510. stroke: "none",
  511. }),
  512. ];
  513. }
  514. if (arrowhead === "triangle") {
  515. const [x, y, x2, y2, x3, y3] = arrowheadPoints;
  516. // always use solid stroke for triangle arrowhead
  517. delete options.strokeLineDash;
  518. return [
  519. generator.polygon(
  520. [
  521. [x, y],
  522. [x2, y2],
  523. [x3, y3],
  524. [x, y],
  525. ],
  526. {
  527. ...options,
  528. fill: element.strokeColor,
  529. fillStyle: "solid",
  530. },
  531. ),
  532. ];
  533. }
  534. // Arrow arrowheads
  535. const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
  536. if (element.strokeStyle === "dotted") {
  537. // for dotted arrows caps, reduce gap to make it more legible
  538. const dash = getDashArrayDotted(element.strokeWidth - 1);
  539. options.strokeLineDash = [dash[0], dash[1] - 1];
  540. } else {
  541. // for solid/dashed, keep solid arrow cap
  542. delete options.strokeLineDash;
  543. }
  544. return [
  545. generator.line(x3, y3, x2, y2, options),
  546. generator.line(x4, y4, x2, y2, options),
  547. ];
  548. };
  549. if (startArrowhead !== null) {
  550. const shapes = getArrowheadShapes(
  551. element,
  552. shape,
  553. "start",
  554. startArrowhead,
  555. );
  556. shape.push(...shapes);
  557. }
  558. if (endArrowhead !== null) {
  559. if (endArrowhead === undefined) {
  560. // Hey, we have an old arrow here!
  561. }
  562. const shapes = getArrowheadShapes(
  563. element,
  564. shape,
  565. "end",
  566. endArrowhead,
  567. );
  568. shape.push(...shapes);
  569. }
  570. }
  571. setShapeForElement(element, shape);
  572. break;
  573. }
  574. case "freedraw": {
  575. generateFreeDrawShape(element);
  576. if (isPathALoop(element.points)) {
  577. // generate rough polygon to fill freedraw shape
  578. shape = generator.polygon(element.points as [number, number][], {
  579. ...generateRoughOptions(element),
  580. stroke: "none",
  581. });
  582. } else {
  583. shape = null;
  584. }
  585. setShapeForElement(element, shape);
  586. break;
  587. }
  588. case "text":
  589. case "image": {
  590. // just to ensure we don't regenerate element.canvas on rerenders
  591. setShapeForElement(element, null);
  592. break;
  593. }
  594. }
  595. }
  596. };
  597. const generateElementWithCanvas = (
  598. element: NonDeletedExcalidrawElement,
  599. renderConfig: RenderConfig,
  600. ) => {
  601. const zoom: Zoom = renderConfig ? renderConfig.zoom : defaultAppState.zoom;
  602. const prevElementWithCanvas = elementWithCanvasCache.get(element);
  603. const shouldRegenerateBecauseZoom =
  604. prevElementWithCanvas &&
  605. prevElementWithCanvas.canvasZoom !== zoom.value &&
  606. !renderConfig?.shouldCacheIgnoreZoom;
  607. const boundTextElementVersion = getBoundTextElement(element)?.version || null;
  608. if (
  609. !prevElementWithCanvas ||
  610. shouldRegenerateBecauseZoom ||
  611. prevElementWithCanvas.theme !== renderConfig.theme ||
  612. prevElementWithCanvas.boundTextElementVersion !== boundTextElementVersion
  613. ) {
  614. const elementWithCanvas = generateElementCanvas(
  615. element,
  616. zoom,
  617. renderConfig,
  618. );
  619. elementWithCanvasCache.set(element, elementWithCanvas);
  620. return elementWithCanvas;
  621. }
  622. return prevElementWithCanvas;
  623. };
  624. const drawElementFromCanvas = (
  625. elementWithCanvas: ExcalidrawElementWithCanvas,
  626. rc: RoughCanvas,
  627. context: CanvasRenderingContext2D,
  628. renderConfig: RenderConfig,
  629. ) => {
  630. const element = elementWithCanvas.element;
  631. const padding = getCanvasPadding(element);
  632. const zoom = elementWithCanvas.canvasZoom;
  633. let [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  634. // Free draw elements will otherwise "shuffle" as the min x and y change
  635. if (isFreeDrawElement(element)) {
  636. x1 = Math.floor(x1);
  637. x2 = Math.ceil(x2);
  638. y1 = Math.floor(y1);
  639. y2 = Math.ceil(y2);
  640. }
  641. const cx = ((x1 + x2) / 2 + renderConfig.scrollX) * window.devicePixelRatio;
  642. const cy = ((y1 + y2) / 2 + renderConfig.scrollY) * window.devicePixelRatio;
  643. context.save();
  644. context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio);
  645. const boundTextElement = getBoundTextElement(element);
  646. if (isArrowElement(element) && boundTextElement) {
  647. const tempCanvas = document.createElement("canvas");
  648. const tempCanvasContext = tempCanvas.getContext("2d")!;
  649. // Take max dimensions of arrow canvas so that when canvas is rotated
  650. // the arrow doesn't get clipped
  651. const maxDim = Math.max(distance(x1, x2), distance(y1, y2));
  652. tempCanvas.width =
  653. maxDim * window.devicePixelRatio * zoom +
  654. padding * elementWithCanvas.canvasZoom * 10;
  655. tempCanvas.height =
  656. maxDim * window.devicePixelRatio * zoom +
  657. padding * elementWithCanvas.canvasZoom * 10;
  658. const offsetX = (tempCanvas.width - elementWithCanvas.canvas!.width) / 2;
  659. const offsetY = (tempCanvas.height - elementWithCanvas.canvas!.height) / 2;
  660. tempCanvasContext.translate(tempCanvas.width / 2, tempCanvas.height / 2);
  661. tempCanvasContext.rotate(element.angle);
  662. tempCanvasContext.drawImage(
  663. elementWithCanvas.canvas!,
  664. -elementWithCanvas.canvas.width / 2,
  665. -elementWithCanvas.canvas.height / 2,
  666. elementWithCanvas.canvas.width,
  667. elementWithCanvas.canvas.height,
  668. );
  669. const [, , , , boundTextCx, boundTextCy] =
  670. getElementAbsoluteCoords(boundTextElement);
  671. tempCanvasContext.rotate(-element.angle);
  672. // Shift the canvas to the center of the bound text element
  673. const shiftX =
  674. tempCanvas.width / 2 -
  675. (boundTextCx - x1) * window.devicePixelRatio * zoom -
  676. offsetX -
  677. padding * zoom;
  678. const shiftY =
  679. tempCanvas.height / 2 -
  680. (boundTextCy - y1) * window.devicePixelRatio * zoom -
  681. offsetY -
  682. padding * zoom;
  683. tempCanvasContext.translate(-shiftX, -shiftY);
  684. // Clear the bound text area
  685. tempCanvasContext.clearRect(
  686. -(boundTextElement.width / 2 + BOUND_TEXT_PADDING) *
  687. window.devicePixelRatio *
  688. zoom,
  689. -(boundTextElement.height / 2 + BOUND_TEXT_PADDING) *
  690. window.devicePixelRatio *
  691. zoom,
  692. (boundTextElement.width + BOUND_TEXT_PADDING * 2) *
  693. window.devicePixelRatio *
  694. zoom,
  695. (boundTextElement.height + BOUND_TEXT_PADDING * 2) *
  696. window.devicePixelRatio *
  697. zoom,
  698. );
  699. context.translate(cx, cy);
  700. context.drawImage(
  701. tempCanvas,
  702. (-(x2 - x1) / 2) * window.devicePixelRatio - offsetX / zoom - padding,
  703. (-(y2 - y1) / 2) * window.devicePixelRatio - offsetY / zoom - padding,
  704. tempCanvas.width / zoom,
  705. tempCanvas.height / zoom,
  706. );
  707. } else {
  708. // we translate context to element center so that rotation and scale
  709. // originates from the element center
  710. context.translate(cx, cy);
  711. context.rotate(element.angle);
  712. if (
  713. "scale" in elementWithCanvas.element &&
  714. !isPendingImageElement(element, renderConfig)
  715. ) {
  716. context.scale(
  717. elementWithCanvas.element.scale[0],
  718. elementWithCanvas.element.scale[1],
  719. );
  720. }
  721. // revert afterwards we don't have account for it during drawing
  722. context.translate(-cx, -cy);
  723. context.drawImage(
  724. elementWithCanvas.canvas!,
  725. (x1 + renderConfig.scrollX) * window.devicePixelRatio -
  726. (padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom,
  727. (y1 + renderConfig.scrollY) * window.devicePixelRatio -
  728. (padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom,
  729. elementWithCanvas.canvas!.width / elementWithCanvas.canvasZoom,
  730. elementWithCanvas.canvas!.height / elementWithCanvas.canvasZoom,
  731. );
  732. if (
  733. process.env.REACT_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX &&
  734. hasBoundTextElement(element)
  735. ) {
  736. const coords = getContainerCoords(element);
  737. context.strokeStyle = "#c92a2a";
  738. context.lineWidth = 3;
  739. context.strokeRect(
  740. (coords.x + renderConfig.scrollX) * window.devicePixelRatio,
  741. (coords.y + renderConfig.scrollY) * window.devicePixelRatio,
  742. getMaxContainerWidth(element) * window.devicePixelRatio,
  743. getMaxContainerHeight(element) * window.devicePixelRatio,
  744. );
  745. }
  746. }
  747. context.restore();
  748. // Clear the nested element we appended to the DOM
  749. };
  750. export const renderElement = (
  751. element: NonDeletedExcalidrawElement,
  752. rc: RoughCanvas,
  753. context: CanvasRenderingContext2D,
  754. renderConfig: RenderConfig,
  755. appState: AppState,
  756. ) => {
  757. const generator = rc.generator;
  758. switch (element.type) {
  759. case "selection": {
  760. context.save();
  761. context.translate(
  762. element.x + renderConfig.scrollX,
  763. element.y + renderConfig.scrollY,
  764. );
  765. context.fillStyle = "rgba(0, 0, 200, 0.04)";
  766. // render from 0.5px offset to get 1px wide line
  767. // https://stackoverflow.com/questions/7530593/html5-canvas-and-line-width/7531540#7531540
  768. // TODO can be be improved by offseting to the negative when user selects
  769. // from right to left
  770. const offset = 0.5 / renderConfig.zoom.value;
  771. context.fillRect(offset, offset, element.width, element.height);
  772. context.lineWidth = 1 / renderConfig.zoom.value;
  773. context.strokeStyle = "rgb(105, 101, 219)";
  774. context.strokeRect(offset, offset, element.width, element.height);
  775. context.restore();
  776. break;
  777. }
  778. case "freedraw": {
  779. generateElementShape(element, generator);
  780. if (renderConfig.isExporting) {
  781. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  782. const cx = (x1 + x2) / 2 + renderConfig.scrollX;
  783. const cy = (y1 + y2) / 2 + renderConfig.scrollY;
  784. const shiftX = (x2 - x1) / 2 - (element.x - x1);
  785. const shiftY = (y2 - y1) / 2 - (element.y - y1);
  786. context.save();
  787. context.translate(cx, cy);
  788. context.rotate(element.angle);
  789. context.translate(-shiftX, -shiftY);
  790. drawElementOnCanvas(element, rc, context, renderConfig);
  791. context.restore();
  792. } else {
  793. const elementWithCanvas = generateElementWithCanvas(
  794. element,
  795. renderConfig,
  796. );
  797. drawElementFromCanvas(elementWithCanvas, rc, context, renderConfig);
  798. }
  799. break;
  800. }
  801. case "rectangle":
  802. case "diamond":
  803. case "ellipse":
  804. case "line":
  805. case "arrow":
  806. case "image":
  807. case "text": {
  808. generateElementShape(element, generator);
  809. if (renderConfig.isExporting) {
  810. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  811. const cx = (x1 + x2) / 2 + renderConfig.scrollX;
  812. const cy = (y1 + y2) / 2 + renderConfig.scrollY;
  813. let shiftX = (x2 - x1) / 2 - (element.x - x1);
  814. let shiftY = (y2 - y1) / 2 - (element.y - y1);
  815. if (isTextElement(element)) {
  816. const container = getContainerElement(element);
  817. if (isArrowElement(container)) {
  818. const boundTextCoords =
  819. LinearElementEditor.getBoundTextElementPosition(
  820. container,
  821. element as ExcalidrawTextElementWithContainer,
  822. );
  823. shiftX = (x2 - x1) / 2 - (boundTextCoords.x - x1);
  824. shiftY = (y2 - y1) / 2 - (boundTextCoords.y - y1);
  825. }
  826. }
  827. context.save();
  828. context.translate(cx, cy);
  829. if (shouldResetImageFilter(element, renderConfig)) {
  830. context.filter = "none";
  831. }
  832. const boundTextElement = getBoundTextElement(element);
  833. if (isArrowElement(element) && boundTextElement) {
  834. const tempCanvas = document.createElement("canvas");
  835. const tempCanvasContext = tempCanvas.getContext("2d")!;
  836. // Take max dimensions of arrow canvas so that when canvas is rotated
  837. // the arrow doesn't get clipped
  838. const maxDim = Math.max(distance(x1, x2), distance(y1, y2));
  839. const padding = getCanvasPadding(element);
  840. tempCanvas.width =
  841. maxDim * appState.exportScale + padding * 10 * appState.exportScale;
  842. tempCanvas.height =
  843. maxDim * appState.exportScale + padding * 10 * appState.exportScale;
  844. tempCanvasContext.translate(
  845. tempCanvas.width / 2,
  846. tempCanvas.height / 2,
  847. );
  848. tempCanvasContext.scale(appState.exportScale, appState.exportScale);
  849. // Shift the canvas to left most point of the arrow
  850. shiftX = element.width / 2 - (element.x - x1);
  851. shiftY = element.height / 2 - (element.y - y1);
  852. tempCanvasContext.rotate(element.angle);
  853. const tempRc = rough.canvas(tempCanvas);
  854. tempCanvasContext.translate(-shiftX, -shiftY);
  855. drawElementOnCanvas(element, tempRc, tempCanvasContext, renderConfig);
  856. tempCanvasContext.translate(shiftX, shiftY);
  857. tempCanvasContext.rotate(-element.angle);
  858. // Shift the canvas to center of bound text
  859. const [, , , , boundTextCx, boundTextCy] =
  860. getElementAbsoluteCoords(boundTextElement);
  861. const boundTextShiftX = (x1 + x2) / 2 - boundTextCx;
  862. const boundTextShiftY = (y1 + y2) / 2 - boundTextCy;
  863. tempCanvasContext.translate(-boundTextShiftX, -boundTextShiftY);
  864. // Clear the bound text area
  865. tempCanvasContext.clearRect(
  866. -boundTextElement.width / 2,
  867. -boundTextElement.height / 2,
  868. boundTextElement.width,
  869. boundTextElement.height,
  870. );
  871. context.scale(1 / appState.exportScale, 1 / appState.exportScale);
  872. context.drawImage(
  873. tempCanvas,
  874. -tempCanvas.width / 2,
  875. -tempCanvas.height / 2,
  876. tempCanvas.width,
  877. tempCanvas.height,
  878. );
  879. } else {
  880. context.rotate(element.angle);
  881. if (element.type === "image") {
  882. // note: scale must be applied *after* rotating
  883. context.scale(element.scale[0], element.scale[1]);
  884. }
  885. context.translate(-shiftX, -shiftY);
  886. drawElementOnCanvas(element, rc, context, renderConfig);
  887. }
  888. context.restore();
  889. // not exporting → optimized rendering (cache & render from element
  890. // canvases)
  891. } else {
  892. const elementWithCanvas = generateElementWithCanvas(
  893. element,
  894. renderConfig,
  895. );
  896. const currentImageSmoothingStatus = context.imageSmoothingEnabled;
  897. if (
  898. // do not disable smoothing during zoom as blurry shapes look better
  899. // on low resolution (while still zooming in) than sharp ones
  900. !renderConfig?.shouldCacheIgnoreZoom &&
  901. // angle is 0 -> always disable smoothing
  902. (!element.angle ||
  903. // or check if angle is a right angle in which case we can still
  904. // disable smoothing without adversely affecting the result
  905. isRightAngle(element.angle))
  906. ) {
  907. // Disabling smoothing makes output much sharper, especially for
  908. // text. Unless for non-right angles, where the aliasing is really
  909. // terrible on Chromium.
  910. //
  911. // Note that `context.imageSmoothingQuality="high"` has almost
  912. // zero effect.
  913. //
  914. context.imageSmoothingEnabled = false;
  915. }
  916. drawElementFromCanvas(elementWithCanvas, rc, context, renderConfig);
  917. // reset
  918. context.imageSmoothingEnabled = currentImageSmoothingStatus;
  919. }
  920. break;
  921. }
  922. default: {
  923. // @ts-ignore
  924. throw new Error(`Unimplemented type ${element.type}`);
  925. }
  926. }
  927. };
  928. const roughSVGDrawWithPrecision = (
  929. rsvg: RoughSVG,
  930. drawable: Drawable,
  931. precision?: number,
  932. ) => {
  933. if (typeof precision === "undefined") {
  934. return rsvg.draw(drawable);
  935. }
  936. const pshape: Drawable = {
  937. sets: drawable.sets,
  938. shape: drawable.shape,
  939. options: { ...drawable.options, fixedDecimalPlaceDigits: precision },
  940. };
  941. return rsvg.draw(pshape);
  942. };
  943. export const renderElementToSvg = (
  944. element: NonDeletedExcalidrawElement,
  945. rsvg: RoughSVG,
  946. svgRoot: SVGElement,
  947. files: BinaryFiles,
  948. offsetX: number,
  949. offsetY: number,
  950. exportWithDarkMode?: boolean,
  951. ) => {
  952. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  953. let cx = (x2 - x1) / 2 - (element.x - x1);
  954. let cy = (y2 - y1) / 2 - (element.y - y1);
  955. if (isTextElement(element)) {
  956. const container = getContainerElement(element);
  957. if (isArrowElement(container)) {
  958. const [x1, y1, x2, y2] = getElementAbsoluteCoords(container);
  959. const boundTextCoords = LinearElementEditor.getBoundTextElementPosition(
  960. container,
  961. element as ExcalidrawTextElementWithContainer,
  962. );
  963. cx = (x2 - x1) / 2 - (boundTextCoords.x - x1);
  964. cy = (y2 - y1) / 2 - (boundTextCoords.y - y1);
  965. offsetX = offsetX + boundTextCoords.x - element.x;
  966. offsetY = offsetY + boundTextCoords.y - element.y;
  967. }
  968. }
  969. const degree = (180 * element.angle) / Math.PI;
  970. const generator = rsvg.generator;
  971. // element to append node to, most of the time svgRoot
  972. let root = svgRoot;
  973. // if the element has a link, create an anchor tag and make that the new root
  974. if (element.link) {
  975. const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a");
  976. anchorTag.setAttribute("href", element.link);
  977. root.appendChild(anchorTag);
  978. root = anchorTag;
  979. }
  980. switch (element.type) {
  981. case "selection": {
  982. // Since this is used only during editing experience, which is canvas based,
  983. // this should not happen
  984. throw new Error("Selection rendering is not supported for SVG");
  985. }
  986. case "rectangle":
  987. case "diamond":
  988. case "ellipse": {
  989. generateElementShape(element, generator);
  990. const node = roughSVGDrawWithPrecision(
  991. rsvg,
  992. getShapeForElement(element)!,
  993. MAX_DECIMALS_FOR_SVG_EXPORT,
  994. );
  995. const opacity = element.opacity / 100;
  996. if (opacity !== 1) {
  997. node.setAttribute("stroke-opacity", `${opacity}`);
  998. node.setAttribute("fill-opacity", `${opacity}`);
  999. }
  1000. node.setAttribute("stroke-linecap", "round");
  1001. node.setAttribute(
  1002. "transform",
  1003. `translate(${offsetX || 0} ${
  1004. offsetY || 0
  1005. }) rotate(${degree} ${cx} ${cy})`,
  1006. );
  1007. root.appendChild(node);
  1008. break;
  1009. }
  1010. case "line":
  1011. case "arrow": {
  1012. const boundText = getBoundTextElement(element);
  1013. const maskPath = svgRoot.ownerDocument!.createElementNS(SVG_NS, "mask");
  1014. if (boundText) {
  1015. maskPath.setAttribute("id", `mask-${element.id}`);
  1016. const maskRectVisible = svgRoot.ownerDocument!.createElementNS(
  1017. SVG_NS,
  1018. "rect",
  1019. );
  1020. offsetX = offsetX || 0;
  1021. offsetY = offsetY || 0;
  1022. maskRectVisible.setAttribute("x", "0");
  1023. maskRectVisible.setAttribute("y", "0");
  1024. maskRectVisible.setAttribute("fill", "#fff");
  1025. maskRectVisible.setAttribute(
  1026. "width",
  1027. `${element.width + 100 + offsetX}`,
  1028. );
  1029. maskRectVisible.setAttribute(
  1030. "height",
  1031. `${element.height + 100 + offsetY}`,
  1032. );
  1033. maskPath.appendChild(maskRectVisible);
  1034. const maskRectInvisible = svgRoot.ownerDocument!.createElementNS(
  1035. SVG_NS,
  1036. "rect",
  1037. );
  1038. const boundTextCoords = LinearElementEditor.getBoundTextElementPosition(
  1039. element,
  1040. boundText,
  1041. );
  1042. const maskX = offsetX + boundTextCoords.x - element.x;
  1043. const maskY = offsetY + boundTextCoords.y - element.y;
  1044. maskRectInvisible.setAttribute("x", maskX.toString());
  1045. maskRectInvisible.setAttribute("y", maskY.toString());
  1046. maskRectInvisible.setAttribute("fill", "#000");
  1047. maskRectInvisible.setAttribute("width", `${boundText.width}`);
  1048. maskRectInvisible.setAttribute("height", `${boundText.height}`);
  1049. maskRectInvisible.setAttribute("opacity", "1");
  1050. maskPath.appendChild(maskRectInvisible);
  1051. }
  1052. generateElementShape(element, generator);
  1053. const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
  1054. if (boundText) {
  1055. group.setAttribute("mask", `url(#mask-${element.id})`);
  1056. }
  1057. const opacity = element.opacity / 100;
  1058. group.setAttribute("stroke-linecap", "round");
  1059. getShapeForElement(element)!.forEach((shape) => {
  1060. const node = roughSVGDrawWithPrecision(
  1061. rsvg,
  1062. shape,
  1063. MAX_DECIMALS_FOR_SVG_EXPORT,
  1064. );
  1065. if (opacity !== 1) {
  1066. node.setAttribute("stroke-opacity", `${opacity}`);
  1067. node.setAttribute("fill-opacity", `${opacity}`);
  1068. }
  1069. node.setAttribute(
  1070. "transform",
  1071. `translate(${offsetX || 0} ${
  1072. offsetY || 0
  1073. }) rotate(${degree} ${cx} ${cy})`,
  1074. );
  1075. if (
  1076. element.type === "line" &&
  1077. isPathALoop(element.points) &&
  1078. element.backgroundColor !== "transparent"
  1079. ) {
  1080. node.setAttribute("fill-rule", "evenodd");
  1081. }
  1082. group.appendChild(node);
  1083. });
  1084. root.appendChild(group);
  1085. root.append(maskPath);
  1086. break;
  1087. }
  1088. case "freedraw": {
  1089. generateElementShape(element, generator);
  1090. generateFreeDrawShape(element);
  1091. const opacity = element.opacity / 100;
  1092. const shape = getShapeForElement(element);
  1093. const node = shape
  1094. ? roughSVGDrawWithPrecision(rsvg, shape, MAX_DECIMALS_FOR_SVG_EXPORT)
  1095. : svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
  1096. if (opacity !== 1) {
  1097. node.setAttribute("stroke-opacity", `${opacity}`);
  1098. node.setAttribute("fill-opacity", `${opacity}`);
  1099. }
  1100. node.setAttribute(
  1101. "transform",
  1102. `translate(${offsetX || 0} ${
  1103. offsetY || 0
  1104. }) rotate(${degree} ${cx} ${cy})`,
  1105. );
  1106. node.setAttribute("stroke", "none");
  1107. const path = svgRoot.ownerDocument!.createElementNS(SVG_NS, "path");
  1108. path.setAttribute("fill", element.strokeColor);
  1109. path.setAttribute("d", getFreeDrawSvgPath(element));
  1110. node.appendChild(path);
  1111. root.appendChild(node);
  1112. break;
  1113. }
  1114. case "image": {
  1115. const width = Math.round(element.width);
  1116. const height = Math.round(element.height);
  1117. const fileData =
  1118. isInitializedImageElement(element) && files[element.fileId];
  1119. if (fileData) {
  1120. const symbolId = `image-${fileData.id}`;
  1121. let symbol = svgRoot.querySelector(`#${symbolId}`);
  1122. if (!symbol) {
  1123. symbol = svgRoot.ownerDocument!.createElementNS(SVG_NS, "symbol");
  1124. symbol.id = symbolId;
  1125. const image = svgRoot.ownerDocument!.createElementNS(SVG_NS, "image");
  1126. image.setAttribute("width", "100%");
  1127. image.setAttribute("height", "100%");
  1128. image.setAttribute("href", fileData.dataURL);
  1129. symbol.appendChild(image);
  1130. root.prepend(symbol);
  1131. }
  1132. const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use");
  1133. use.setAttribute("href", `#${symbolId}`);
  1134. // in dark theme, revert the image color filter
  1135. if (exportWithDarkMode && fileData.mimeType !== MIME_TYPES.svg) {
  1136. use.setAttribute("filter", IMAGE_INVERT_FILTER);
  1137. }
  1138. use.setAttribute("width", `${width}`);
  1139. use.setAttribute("height", `${height}`);
  1140. // We first apply `scale` transforms (horizontal/vertical mirroring)
  1141. // on the <use> element, then apply translation and rotation
  1142. // on the <g> element which wraps the <use>.
  1143. // Doing this separately is a quick hack to to work around compositing
  1144. // the transformations correctly (the transform-origin was not being
  1145. // applied correctly).
  1146. if (element.scale[0] !== 1 || element.scale[1] !== 1) {
  1147. const translateX = element.scale[0] !== 1 ? -width : 0;
  1148. const translateY = element.scale[1] !== 1 ? -height : 0;
  1149. use.setAttribute(
  1150. "transform",
  1151. `scale(${element.scale[0]}, ${element.scale[1]}) translate(${translateX} ${translateY})`,
  1152. );
  1153. }
  1154. const g = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
  1155. g.appendChild(use);
  1156. g.setAttribute(
  1157. "transform",
  1158. `translate(${offsetX || 0} ${
  1159. offsetY || 0
  1160. }) rotate(${degree} ${cx} ${cy})`,
  1161. );
  1162. root.appendChild(g);
  1163. }
  1164. break;
  1165. }
  1166. default: {
  1167. if (isTextElement(element)) {
  1168. const opacity = element.opacity / 100;
  1169. const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
  1170. if (opacity !== 1) {
  1171. node.setAttribute("stroke-opacity", `${opacity}`);
  1172. node.setAttribute("fill-opacity", `${opacity}`);
  1173. }
  1174. node.setAttribute(
  1175. "transform",
  1176. `translate(${offsetX || 0} ${
  1177. offsetY || 0
  1178. }) rotate(${degree} ${cx} ${cy})`,
  1179. );
  1180. const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
  1181. const lineHeightPx = getLineHeightInPx(
  1182. element.fontSize,
  1183. element.lineHeight,
  1184. );
  1185. const horizontalOffset =
  1186. element.textAlign === "center"
  1187. ? element.width / 2
  1188. : element.textAlign === "right"
  1189. ? element.width
  1190. : 0;
  1191. const direction = isRTL(element.text) ? "rtl" : "ltr";
  1192. const textAnchor =
  1193. element.textAlign === "center"
  1194. ? "middle"
  1195. : element.textAlign === "right" || direction === "rtl"
  1196. ? "end"
  1197. : "start";
  1198. for (let i = 0; i < lines.length; i++) {
  1199. const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text");
  1200. text.textContent = lines[i];
  1201. text.setAttribute("x", `${horizontalOffset}`);
  1202. text.setAttribute("y", `${i * lineHeightPx}`);
  1203. text.setAttribute("font-family", getFontFamilyString(element));
  1204. text.setAttribute("font-size", `${element.fontSize}px`);
  1205. text.setAttribute("fill", element.strokeColor);
  1206. text.setAttribute("text-anchor", textAnchor);
  1207. text.setAttribute("style", "white-space: pre;");
  1208. text.setAttribute("direction", direction);
  1209. text.setAttribute("dominant-baseline", "text-before-edge");
  1210. node.appendChild(text);
  1211. }
  1212. root.appendChild(node);
  1213. } else {
  1214. // @ts-ignore
  1215. throw new Error(`Unimplemented type ${element.type}`);
  1216. }
  1217. }
  1218. }
  1219. };
  1220. export const pathsCache = new WeakMap<ExcalidrawFreeDrawElement, Path2D>([]);
  1221. export function generateFreeDrawShape(element: ExcalidrawFreeDrawElement) {
  1222. const svgPathData = getFreeDrawSvgPath(element);
  1223. const path = new Path2D(svgPathData);
  1224. pathsCache.set(element, path);
  1225. return path;
  1226. }
  1227. export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
  1228. return pathsCache.get(element);
  1229. }
  1230. export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
  1231. // If input points are empty (should they ever be?) return a dot
  1232. const inputPoints = element.simulatePressure
  1233. ? element.points
  1234. : element.points.length
  1235. ? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
  1236. : [[0, 0, 0.5]];
  1237. // Consider changing the options for simulated pressure vs real pressure
  1238. const options: StrokeOptions = {
  1239. simulatePressure: element.simulatePressure,
  1240. size: element.strokeWidth * 4.25,
  1241. thinning: 0.6,
  1242. smoothing: 0.5,
  1243. streamline: 0.5,
  1244. easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
  1245. last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
  1246. };
  1247. return getSvgPathFromStroke(getStroke(inputPoints as number[][], options));
  1248. }
  1249. function med(A: number[], B: number[]) {
  1250. return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
  1251. }
  1252. // Trim SVG path data so number are each two decimal points. This
  1253. // improves SVG exports, and prevents rendering errors on points
  1254. // with long decimals.
  1255. const TO_FIXED_PRECISION = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g;
  1256. function getSvgPathFromStroke(points: number[][]): string {
  1257. if (!points.length) {
  1258. return "";
  1259. }
  1260. const max = points.length - 1;
  1261. return points
  1262. .reduce(
  1263. (acc, point, i, arr) => {
  1264. if (i === max) {
  1265. acc.push(point, med(point, arr[0]), "L", arr[0], "Z");
  1266. } else {
  1267. acc.push(point, med(point, arr[i + 1]));
  1268. }
  1269. return acc;
  1270. },
  1271. ["M", points[0], "Q"],
  1272. )
  1273. .join(" ")
  1274. .replace(TO_FIXED_PRECISION, "$1");
  1275. }