You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

renderElement.ts 25KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820
  1. import {
  2. ExcalidrawElement,
  3. ExcalidrawLinearElement,
  4. ExcalidrawTextElement,
  5. Arrowhead,
  6. NonDeletedExcalidrawElement,
  7. ExcalidrawFreeDrawElement,
  8. } from "../element/types";
  9. import {
  10. isTextElement,
  11. isLinearElement,
  12. isFreeDrawElement,
  13. } from "../element/typeChecks";
  14. import {
  15. getDiamondPoints,
  16. getElementAbsoluteCoords,
  17. getArrowheadPoints,
  18. } from "../element/bounds";
  19. import { RoughCanvas } from "roughjs/bin/canvas";
  20. import { Drawable, Options } from "roughjs/bin/core";
  21. import { RoughSVG } from "roughjs/bin/svg";
  22. import { RoughGenerator } from "roughjs/bin/generator";
  23. import { SceneState } from "../scene/types";
  24. import {
  25. SVG_NS,
  26. distance,
  27. getFontString,
  28. getFontFamilyString,
  29. isRTL,
  30. } from "../utils";
  31. import { isPathALoop } from "../math";
  32. import rough from "roughjs/bin/rough";
  33. import { Zoom } from "../types";
  34. import { getDefaultAppState } from "../appState";
  35. import getFreeDrawShape from "perfect-freehand";
  36. const defaultAppState = getDefaultAppState();
  37. const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
  38. const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
  39. const getCanvasPadding = (element: ExcalidrawElement) =>
  40. element.type === "freedraw" ? element.strokeWidth * 12 : 20;
  41. export interface ExcalidrawElementWithCanvas {
  42. element: ExcalidrawElement | ExcalidrawTextElement;
  43. canvas: HTMLCanvasElement;
  44. canvasZoom: Zoom["value"];
  45. canvasOffsetX: number;
  46. canvasOffsetY: number;
  47. }
  48. const generateElementCanvas = (
  49. element: NonDeletedExcalidrawElement,
  50. zoom: Zoom,
  51. ): ExcalidrawElementWithCanvas => {
  52. const canvas = document.createElement("canvas");
  53. const context = canvas.getContext("2d")!;
  54. const padding = getCanvasPadding(element);
  55. let canvasOffsetX = 0;
  56. let canvasOffsetY = 0;
  57. if (isLinearElement(element) || isFreeDrawElement(element)) {
  58. let [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  59. x1 = Math.floor(x1);
  60. x2 = Math.ceil(x2);
  61. y1 = Math.floor(y1);
  62. y2 = Math.ceil(y2);
  63. canvas.width =
  64. distance(x1, x2) * window.devicePixelRatio * zoom.value +
  65. padding * zoom.value * 2;
  66. canvas.height =
  67. distance(y1, y2) * window.devicePixelRatio * zoom.value +
  68. padding * zoom.value * 2;
  69. canvasOffsetX =
  70. element.x > x1
  71. ? Math.floor(distance(element.x, x1)) *
  72. window.devicePixelRatio *
  73. zoom.value
  74. : 0;
  75. canvasOffsetY =
  76. element.y > y1
  77. ? Math.floor(distance(element.y, y1)) *
  78. window.devicePixelRatio *
  79. zoom.value
  80. : 0;
  81. context.translate(canvasOffsetX, canvasOffsetY);
  82. } else {
  83. canvas.width =
  84. element.width * window.devicePixelRatio * zoom.value +
  85. padding * zoom.value * 2;
  86. canvas.height =
  87. element.height * window.devicePixelRatio * zoom.value +
  88. padding * zoom.value * 2;
  89. }
  90. context.translate(padding * zoom.value, padding * zoom.value);
  91. context.scale(
  92. window.devicePixelRatio * zoom.value,
  93. window.devicePixelRatio * zoom.value,
  94. );
  95. const rc = rough.canvas(canvas);
  96. drawElementOnCanvas(element, rc, context);
  97. context.translate(-(padding * zoom.value), -(padding * zoom.value));
  98. context.scale(
  99. 1 / (window.devicePixelRatio * zoom.value),
  100. 1 / (window.devicePixelRatio * zoom.value),
  101. );
  102. return {
  103. element,
  104. canvas,
  105. canvasZoom: zoom.value,
  106. canvasOffsetX,
  107. canvasOffsetY,
  108. };
  109. };
  110. const drawElementOnCanvas = (
  111. element: NonDeletedExcalidrawElement,
  112. rc: RoughCanvas,
  113. context: CanvasRenderingContext2D,
  114. ) => {
  115. context.globalAlpha = element.opacity / 100;
  116. switch (element.type) {
  117. case "rectangle":
  118. case "diamond":
  119. case "ellipse": {
  120. context.lineJoin = "round";
  121. context.lineCap = "round";
  122. rc.draw(getShapeForElement(element) as Drawable);
  123. break;
  124. }
  125. case "arrow":
  126. case "draw":
  127. case "line": {
  128. context.lineJoin = "round";
  129. context.lineCap = "round";
  130. (getShapeForElement(element) as Drawable[]).forEach((shape) => {
  131. rc.draw(shape);
  132. });
  133. break;
  134. }
  135. case "freedraw": {
  136. // Draw directly to canvas
  137. context.save();
  138. context.fillStyle = element.strokeColor;
  139. const path = getFreeDrawPath2D(element) as Path2D;
  140. context.fillStyle = element.strokeColor;
  141. context.fill(path);
  142. context.restore();
  143. break;
  144. }
  145. default: {
  146. if (isTextElement(element)) {
  147. const rtl = isRTL(element.text);
  148. const shouldTemporarilyAttach = rtl && !context.canvas.isConnected;
  149. if (shouldTemporarilyAttach) {
  150. // to correctly render RTL text mixed with LTR, we have to append it
  151. // to the DOM
  152. document.body.appendChild(context.canvas);
  153. }
  154. context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr");
  155. const font = context.font;
  156. context.font = getFontString(element);
  157. const fillStyle = context.fillStyle;
  158. context.fillStyle = element.strokeColor;
  159. const textAlign = context.textAlign;
  160. context.textAlign = element.textAlign as CanvasTextAlign;
  161. // Canvas does not support multiline text by default
  162. const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
  163. const lineHeight = element.height / lines.length;
  164. const verticalOffset = element.height - element.baseline;
  165. const horizontalOffset =
  166. element.textAlign === "center"
  167. ? element.width / 2
  168. : element.textAlign === "right"
  169. ? element.width
  170. : 0;
  171. for (let index = 0; index < lines.length; index++) {
  172. context.fillText(
  173. lines[index],
  174. horizontalOffset,
  175. (index + 1) * lineHeight - verticalOffset,
  176. );
  177. }
  178. context.fillStyle = fillStyle;
  179. context.font = font;
  180. context.textAlign = textAlign;
  181. if (shouldTemporarilyAttach) {
  182. context.canvas.remove();
  183. }
  184. } else {
  185. throw new Error(`Unimplemented type ${element.type}`);
  186. }
  187. }
  188. }
  189. context.globalAlpha = 1;
  190. };
  191. const elementWithCanvasCache = new WeakMap<
  192. ExcalidrawElement,
  193. ExcalidrawElementWithCanvas
  194. >();
  195. const shapeCache = new WeakMap<
  196. ExcalidrawElement,
  197. Drawable | Drawable[] | null
  198. >();
  199. export const getShapeForElement = (element: ExcalidrawElement) =>
  200. shapeCache.get(element);
  201. export const invalidateShapeForElement = (element: ExcalidrawElement) =>
  202. shapeCache.delete(element);
  203. export const generateRoughOptions = (element: ExcalidrawElement): Options => {
  204. const options: Options = {
  205. seed: element.seed,
  206. strokeLineDash:
  207. element.strokeStyle === "dashed"
  208. ? getDashArrayDashed(element.strokeWidth)
  209. : element.strokeStyle === "dotted"
  210. ? getDashArrayDotted(element.strokeWidth)
  211. : undefined,
  212. // for non-solid strokes, disable multiStroke because it tends to make
  213. // dashes/dots overlay each other
  214. disableMultiStroke: element.strokeStyle !== "solid",
  215. // for non-solid strokes, increase the width a bit to make it visually
  216. // similar to solid strokes, because we're also disabling multiStroke
  217. strokeWidth:
  218. element.strokeStyle !== "solid"
  219. ? element.strokeWidth + 0.5
  220. : element.strokeWidth,
  221. // when increasing strokeWidth, we must explicitly set fillWeight and
  222. // hachureGap because if not specified, roughjs uses strokeWidth to
  223. // calculate them (and we don't want the fills to be modified)
  224. fillWeight: element.strokeWidth / 2,
  225. hachureGap: element.strokeWidth * 4,
  226. roughness: element.roughness,
  227. stroke: element.strokeColor,
  228. };
  229. switch (element.type) {
  230. case "rectangle":
  231. case "diamond":
  232. case "ellipse": {
  233. options.fillStyle = element.fillStyle;
  234. options.fill =
  235. element.backgroundColor === "transparent"
  236. ? undefined
  237. : element.backgroundColor;
  238. if (element.type === "ellipse") {
  239. options.curveFitting = 1;
  240. }
  241. return options;
  242. }
  243. case "draw":
  244. case "line": {
  245. if (isPathALoop(element.points)) {
  246. options.fillStyle = element.fillStyle;
  247. options.fill =
  248. element.backgroundColor === "transparent"
  249. ? undefined
  250. : element.backgroundColor;
  251. }
  252. return options;
  253. }
  254. case "freedraw":
  255. case "arrow":
  256. return options;
  257. default: {
  258. throw new Error(`Unimplemented type ${element.type}`);
  259. }
  260. }
  261. };
  262. /**
  263. * Generates the element's shape and puts it into the cache.
  264. * @param element
  265. * @param generator
  266. */
  267. const generateElementShape = (
  268. element: NonDeletedExcalidrawElement,
  269. generator: RoughGenerator,
  270. ) => {
  271. let shape = shapeCache.get(element) || null;
  272. if (!shape) {
  273. elementWithCanvasCache.delete(element);
  274. switch (element.type) {
  275. case "rectangle":
  276. if (element.strokeSharpness === "round") {
  277. const w = element.width;
  278. const h = element.height;
  279. const r = Math.min(w, h) * 0.25;
  280. shape = generator.path(
  281. `M ${r} 0 L ${w - r} 0 Q ${w} 0, ${w} ${r} L ${w} ${
  282. h - r
  283. } Q ${w} ${h}, ${w - r} ${h} L ${r} ${h} Q 0 ${h}, 0 ${
  284. h - r
  285. } L 0 ${r} Q 0 0, ${r} 0`,
  286. generateRoughOptions(element),
  287. );
  288. } else {
  289. shape = generator.rectangle(
  290. 0,
  291. 0,
  292. element.width,
  293. element.height,
  294. generateRoughOptions(element),
  295. );
  296. }
  297. break;
  298. case "diamond": {
  299. const [
  300. topX,
  301. topY,
  302. rightX,
  303. rightY,
  304. bottomX,
  305. bottomY,
  306. leftX,
  307. leftY,
  308. ] = getDiamondPoints(element);
  309. shape = generator.polygon(
  310. [
  311. [topX, topY],
  312. [rightX, rightY],
  313. [bottomX, bottomY],
  314. [leftX, leftY],
  315. ],
  316. generateRoughOptions(element),
  317. );
  318. break;
  319. }
  320. case "ellipse":
  321. shape = generator.ellipse(
  322. element.width / 2,
  323. element.height / 2,
  324. element.width,
  325. element.height,
  326. generateRoughOptions(element),
  327. );
  328. break;
  329. case "draw":
  330. case "line":
  331. case "arrow": {
  332. const options = generateRoughOptions(element);
  333. // points array can be empty in the beginning, so it is important to add
  334. // initial position to it
  335. const points = element.points.length ? element.points : [[0, 0]];
  336. // curve is always the first element
  337. // this simplifies finding the curve for an element
  338. if (element.strokeSharpness === "sharp") {
  339. if (options.fill) {
  340. shape = [generator.polygon(points as [number, number][], options)];
  341. } else {
  342. shape = [
  343. generator.linearPath(points as [number, number][], options),
  344. ];
  345. }
  346. } else {
  347. shape = [generator.curve(points as [number, number][], options)];
  348. }
  349. // add lines only in arrow
  350. if (element.type === "arrow") {
  351. const { startArrowhead = null, endArrowhead = "arrow" } = element;
  352. const getArrowheadShapes = (
  353. element: ExcalidrawLinearElement,
  354. shape: Drawable[],
  355. position: "start" | "end",
  356. arrowhead: Arrowhead,
  357. ) => {
  358. const arrowheadPoints = getArrowheadPoints(
  359. element,
  360. shape,
  361. position,
  362. arrowhead,
  363. );
  364. if (arrowheadPoints === null) {
  365. return [];
  366. }
  367. // Other arrowheads here...
  368. if (arrowhead === "dot") {
  369. const [x, y, r] = arrowheadPoints;
  370. return [
  371. generator.circle(x, y, r, {
  372. ...options,
  373. fill: element.strokeColor,
  374. fillStyle: "solid",
  375. stroke: "none",
  376. }),
  377. ];
  378. }
  379. // Arrow arrowheads
  380. const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
  381. if (element.strokeStyle === "dotted") {
  382. // for dotted arrows caps, reduce gap to make it more legible
  383. const dash = getDashArrayDotted(element.strokeWidth - 1);
  384. options.strokeLineDash = [dash[0], dash[1] - 1];
  385. } else {
  386. // for solid/dashed, keep solid arrow cap
  387. delete options.strokeLineDash;
  388. }
  389. return [
  390. generator.line(x3, y3, x2, y2, options),
  391. generator.line(x4, y4, x2, y2, options),
  392. ];
  393. };
  394. if (startArrowhead !== null) {
  395. const shapes = getArrowheadShapes(
  396. element,
  397. shape,
  398. "start",
  399. startArrowhead,
  400. );
  401. shape.push(...shapes);
  402. }
  403. if (endArrowhead !== null) {
  404. if (endArrowhead === undefined) {
  405. // Hey, we have an old arrow here!
  406. }
  407. const shapes = getArrowheadShapes(
  408. element,
  409. shape,
  410. "end",
  411. endArrowhead,
  412. );
  413. shape.push(...shapes);
  414. }
  415. }
  416. break;
  417. }
  418. case "freedraw": {
  419. generateFreeDrawShape(element);
  420. shape = [];
  421. break;
  422. }
  423. case "text": {
  424. // just to ensure we don't regenerate element.canvas on rerenders
  425. shape = [];
  426. break;
  427. }
  428. }
  429. shapeCache.set(element, shape);
  430. }
  431. };
  432. const generateElementWithCanvas = (
  433. element: NonDeletedExcalidrawElement,
  434. sceneState?: SceneState,
  435. ) => {
  436. const zoom: Zoom = sceneState ? sceneState.zoom : defaultAppState.zoom;
  437. const prevElementWithCanvas = elementWithCanvasCache.get(element);
  438. const shouldRegenerateBecauseZoom =
  439. prevElementWithCanvas &&
  440. prevElementWithCanvas.canvasZoom !== zoom.value &&
  441. !sceneState?.shouldCacheIgnoreZoom;
  442. if (!prevElementWithCanvas || shouldRegenerateBecauseZoom) {
  443. const elementWithCanvas = generateElementCanvas(element, zoom);
  444. elementWithCanvasCache.set(element, elementWithCanvas);
  445. return elementWithCanvas;
  446. }
  447. return prevElementWithCanvas;
  448. };
  449. const drawElementFromCanvas = (
  450. elementWithCanvas: ExcalidrawElementWithCanvas,
  451. rc: RoughCanvas,
  452. context: CanvasRenderingContext2D,
  453. sceneState: SceneState,
  454. ) => {
  455. const element = elementWithCanvas.element;
  456. const padding = getCanvasPadding(element);
  457. let [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  458. // Free draw elements will otherwise "shuffle" as the min x and y change
  459. if (isFreeDrawElement(element)) {
  460. x1 = Math.floor(x1);
  461. x2 = Math.ceil(x2);
  462. y1 = Math.floor(y1);
  463. y2 = Math.ceil(y2);
  464. }
  465. const cx = ((x1 + x2) / 2 + sceneState.scrollX) * window.devicePixelRatio;
  466. const cy = ((y1 + y2) / 2 + sceneState.scrollY) * window.devicePixelRatio;
  467. context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio);
  468. context.translate(cx, cy);
  469. context.rotate(element.angle);
  470. context.drawImage(
  471. elementWithCanvas.canvas!,
  472. (-(x2 - x1) / 2) * window.devicePixelRatio -
  473. (padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom,
  474. (-(y2 - y1) / 2) * window.devicePixelRatio -
  475. (padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom,
  476. elementWithCanvas.canvas!.width / elementWithCanvas.canvasZoom,
  477. elementWithCanvas.canvas!.height / elementWithCanvas.canvasZoom,
  478. );
  479. context.rotate(-element.angle);
  480. context.translate(-cx, -cy);
  481. context.scale(window.devicePixelRatio, window.devicePixelRatio);
  482. // Clear the nested element we appended to the DOM
  483. };
  484. export const renderElement = (
  485. element: NonDeletedExcalidrawElement,
  486. rc: RoughCanvas,
  487. context: CanvasRenderingContext2D,
  488. renderOptimizations: boolean,
  489. sceneState: SceneState,
  490. ) => {
  491. const generator = rc.generator;
  492. switch (element.type) {
  493. case "selection": {
  494. context.translate(
  495. element.x + sceneState.scrollX,
  496. element.y + sceneState.scrollY,
  497. );
  498. const fillStyle = context.fillStyle;
  499. context.fillStyle = "rgba(0, 0, 255, 0.10)";
  500. context.fillRect(0, 0, element.width, element.height);
  501. context.fillStyle = fillStyle;
  502. context.translate(
  503. -element.x - sceneState.scrollX,
  504. -element.y - sceneState.scrollY,
  505. );
  506. break;
  507. }
  508. case "freedraw": {
  509. generateElementShape(element, generator);
  510. if (renderOptimizations) {
  511. const elementWithCanvas = generateElementWithCanvas(
  512. element,
  513. sceneState,
  514. );
  515. drawElementFromCanvas(elementWithCanvas, rc, context, sceneState);
  516. } else {
  517. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  518. const cx = (x1 + x2) / 2 + sceneState.scrollX;
  519. const cy = (y1 + y2) / 2 + sceneState.scrollY;
  520. const shiftX = (x2 - x1) / 2 - (element.x - x1);
  521. const shiftY = (y2 - y1) / 2 - (element.y - y1);
  522. context.translate(cx, cy);
  523. context.rotate(element.angle);
  524. context.translate(-shiftX, -shiftY);
  525. drawElementOnCanvas(element, rc, context);
  526. context.translate(shiftX, shiftY);
  527. context.rotate(-element.angle);
  528. context.translate(-cx, -cy);
  529. }
  530. break;
  531. }
  532. case "rectangle":
  533. case "diamond":
  534. case "ellipse":
  535. case "draw":
  536. case "line":
  537. case "arrow":
  538. case "text": {
  539. generateElementShape(element, generator);
  540. if (renderOptimizations) {
  541. const elementWithCanvas = generateElementWithCanvas(
  542. element,
  543. sceneState,
  544. );
  545. drawElementFromCanvas(elementWithCanvas, rc, context, sceneState);
  546. } else {
  547. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  548. const cx = (x1 + x2) / 2 + sceneState.scrollX;
  549. const cy = (y1 + y2) / 2 + sceneState.scrollY;
  550. const shiftX = (x2 - x1) / 2 - (element.x - x1);
  551. const shiftY = (y2 - y1) / 2 - (element.y - y1);
  552. context.translate(cx, cy);
  553. context.rotate(element.angle);
  554. context.translate(-shiftX, -shiftY);
  555. drawElementOnCanvas(element, rc, context);
  556. context.translate(shiftX, shiftY);
  557. context.rotate(-element.angle);
  558. context.translate(-cx, -cy);
  559. }
  560. break;
  561. }
  562. default: {
  563. // @ts-ignore
  564. throw new Error(`Unimplemented type ${element.type}`);
  565. }
  566. }
  567. };
  568. export const renderElementToSvg = (
  569. element: NonDeletedExcalidrawElement,
  570. rsvg: RoughSVG,
  571. svgRoot: SVGElement,
  572. offsetX?: number,
  573. offsetY?: number,
  574. ) => {
  575. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  576. const cx = (x2 - x1) / 2 - (element.x - x1);
  577. const cy = (y2 - y1) / 2 - (element.y - y1);
  578. const degree = (180 * element.angle) / Math.PI;
  579. const generator = rsvg.generator;
  580. switch (element.type) {
  581. case "selection": {
  582. // Since this is used only during editing experience, which is canvas based,
  583. // this should not happen
  584. throw new Error("Selection rendering is not supported for SVG");
  585. }
  586. case "rectangle":
  587. case "diamond":
  588. case "ellipse": {
  589. generateElementShape(element, generator);
  590. const node = rsvg.draw(getShapeForElement(element) as Drawable);
  591. const opacity = element.opacity / 100;
  592. if (opacity !== 1) {
  593. node.setAttribute("stroke-opacity", `${opacity}`);
  594. node.setAttribute("fill-opacity", `${opacity}`);
  595. }
  596. node.setAttribute("stroke-linecap", "round");
  597. node.setAttribute(
  598. "transform",
  599. `translate(${offsetX || 0} ${
  600. offsetY || 0
  601. }) rotate(${degree} ${cx} ${cy})`,
  602. );
  603. svgRoot.appendChild(node);
  604. break;
  605. }
  606. case "draw":
  607. case "line":
  608. case "arrow": {
  609. generateElementShape(element, generator);
  610. const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
  611. const opacity = element.opacity / 100;
  612. group.setAttribute("stroke-linecap", "round");
  613. (getShapeForElement(element) as Drawable[]).forEach((shape) => {
  614. const node = rsvg.draw(shape);
  615. if (opacity !== 1) {
  616. node.setAttribute("stroke-opacity", `${opacity}`);
  617. node.setAttribute("fill-opacity", `${opacity}`);
  618. }
  619. node.setAttribute(
  620. "transform",
  621. `translate(${offsetX || 0} ${
  622. offsetY || 0
  623. }) rotate(${degree} ${cx} ${cy})`,
  624. );
  625. if (
  626. element.type === "line" &&
  627. isPathALoop(element.points) &&
  628. element.backgroundColor !== "transparent"
  629. ) {
  630. node.setAttribute("fill-rule", "evenodd");
  631. }
  632. group.appendChild(node);
  633. });
  634. svgRoot.appendChild(group);
  635. break;
  636. }
  637. case "freedraw": {
  638. generateFreeDrawShape(element);
  639. const opacity = element.opacity / 100;
  640. const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
  641. if (opacity !== 1) {
  642. node.setAttribute("stroke-opacity", `${opacity}`);
  643. node.setAttribute("fill-opacity", `${opacity}`);
  644. }
  645. node.setAttribute(
  646. "transform",
  647. `translate(${offsetX || 0} ${
  648. offsetY || 0
  649. }) rotate(${degree} ${cx} ${cy})`,
  650. );
  651. const path = svgRoot.ownerDocument!.createElementNS(SVG_NS, "path");
  652. node.setAttribute("stroke", "none");
  653. node.setAttribute("fill", element.strokeStyle);
  654. path.setAttribute("d", getFreeDrawSvgPath(element));
  655. node.appendChild(path);
  656. svgRoot.appendChild(node);
  657. break;
  658. }
  659. default: {
  660. if (isTextElement(element)) {
  661. const opacity = element.opacity / 100;
  662. const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
  663. if (opacity !== 1) {
  664. node.setAttribute("stroke-opacity", `${opacity}`);
  665. node.setAttribute("fill-opacity", `${opacity}`);
  666. }
  667. node.setAttribute(
  668. "transform",
  669. `translate(${offsetX || 0} ${
  670. offsetY || 0
  671. }) rotate(${degree} ${cx} ${cy})`,
  672. );
  673. const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
  674. const lineHeight = element.height / lines.length;
  675. const verticalOffset = element.height - element.baseline;
  676. const horizontalOffset =
  677. element.textAlign === "center"
  678. ? element.width / 2
  679. : element.textAlign === "right"
  680. ? element.width
  681. : 0;
  682. const direction = isRTL(element.text) ? "rtl" : "ltr";
  683. const textAnchor =
  684. element.textAlign === "center"
  685. ? "middle"
  686. : element.textAlign === "right" || direction === "rtl"
  687. ? "end"
  688. : "start";
  689. for (let i = 0; i < lines.length; i++) {
  690. const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text");
  691. text.textContent = lines[i];
  692. text.setAttribute("x", `${horizontalOffset}`);
  693. text.setAttribute("y", `${(i + 1) * lineHeight - verticalOffset}`);
  694. text.setAttribute("font-family", getFontFamilyString(element));
  695. text.setAttribute("font-size", `${element.fontSize}px`);
  696. text.setAttribute("fill", element.strokeColor);
  697. text.setAttribute("text-anchor", textAnchor);
  698. text.setAttribute("style", "white-space: pre;");
  699. text.setAttribute("direction", direction);
  700. node.appendChild(text);
  701. }
  702. svgRoot.appendChild(node);
  703. } else {
  704. // @ts-ignore
  705. throw new Error(`Unimplemented type ${element.type}`);
  706. }
  707. }
  708. }
  709. };
  710. export const pathsCache = new WeakMap<ExcalidrawFreeDrawElement, Path2D>([]);
  711. export function generateFreeDrawShape(element: ExcalidrawFreeDrawElement) {
  712. const svgPathData = getFreeDrawSvgPath(element);
  713. const path = new Path2D(svgPathData);
  714. pathsCache.set(element, path);
  715. return path;
  716. }
  717. export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
  718. return pathsCache.get(element);
  719. }
  720. export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
  721. const inputPoints = element.simulatePressure
  722. ? element.points
  723. : element.points.length
  724. ? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
  725. : [[0, 0, 0]];
  726. // Consider changing the options for simulated pressure vs real pressure
  727. const options = {
  728. simulatePressure: element.simulatePressure,
  729. size: element.strokeWidth * 6,
  730. thinning: 0.5,
  731. smoothing: 0.5,
  732. streamline: 0.5,
  733. easing: (t: number) => t * (2 - t),
  734. last: true,
  735. };
  736. const points = getFreeDrawShape(inputPoints as number[][], options);
  737. const d: (string | number)[] = [];
  738. let [p0, p1] = points;
  739. d.push("M", p0[0], p0[1], "Q");
  740. for (let i = 0; i < points.length; i++) {
  741. d.push(p0[0], p0[1], (p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2);
  742. p0 = p1;
  743. p1 = points[i];
  744. }
  745. p1 = points[0];
  746. d.push(p0[0], p0[1], (p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2);
  747. d.push("Z");
  748. return d.join(" ");
  749. }