renderElement.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819
  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 "line": {
  127. context.lineJoin = "round";
  128. context.lineCap = "round";
  129. (getShapeForElement(element) as Drawable[]).forEach((shape) => {
  130. rc.draw(shape);
  131. });
  132. break;
  133. }
  134. case "freedraw": {
  135. // Draw directly to canvas
  136. context.save();
  137. context.fillStyle = element.strokeColor;
  138. const path = getFreeDrawPath2D(element) as Path2D;
  139. context.fillStyle = element.strokeColor;
  140. context.fill(path);
  141. context.restore();
  142. break;
  143. }
  144. default: {
  145. if (isTextElement(element)) {
  146. const rtl = isRTL(element.text);
  147. const shouldTemporarilyAttach = rtl && !context.canvas.isConnected;
  148. if (shouldTemporarilyAttach) {
  149. // to correctly render RTL text mixed with LTR, we have to append it
  150. // to the DOM
  151. document.body.appendChild(context.canvas);
  152. }
  153. context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr");
  154. const font = context.font;
  155. context.font = getFontString(element);
  156. const fillStyle = context.fillStyle;
  157. context.fillStyle = element.strokeColor;
  158. const textAlign = context.textAlign;
  159. context.textAlign = element.textAlign as CanvasTextAlign;
  160. // Canvas does not support multiline text by default
  161. const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
  162. const lineHeight = element.height / lines.length;
  163. const verticalOffset = element.height - element.baseline;
  164. const horizontalOffset =
  165. element.textAlign === "center"
  166. ? element.width / 2
  167. : element.textAlign === "right"
  168. ? element.width
  169. : 0;
  170. for (let index = 0; index < lines.length; index++) {
  171. context.fillText(
  172. lines[index],
  173. horizontalOffset,
  174. (index + 1) * lineHeight - verticalOffset,
  175. );
  176. }
  177. context.fillStyle = fillStyle;
  178. context.font = font;
  179. context.textAlign = textAlign;
  180. if (shouldTemporarilyAttach) {
  181. context.canvas.remove();
  182. }
  183. } else {
  184. throw new Error(`Unimplemented type ${element.type}`);
  185. }
  186. }
  187. }
  188. context.globalAlpha = 1;
  189. };
  190. const elementWithCanvasCache = new WeakMap<
  191. ExcalidrawElement,
  192. ExcalidrawElementWithCanvas
  193. >();
  194. const shapeCache = new WeakMap<
  195. ExcalidrawElement,
  196. Drawable | Drawable[] | null
  197. >();
  198. export const getShapeForElement = (element: ExcalidrawElement) =>
  199. shapeCache.get(element);
  200. export const invalidateShapeForElement = (element: ExcalidrawElement) =>
  201. shapeCache.delete(element);
  202. export const generateRoughOptions = (
  203. element: ExcalidrawElement,
  204. continuousPath = false,
  205. ): Options => {
  206. const options: Options = {
  207. seed: element.seed,
  208. strokeLineDash:
  209. element.strokeStyle === "dashed"
  210. ? getDashArrayDashed(element.strokeWidth)
  211. : element.strokeStyle === "dotted"
  212. ? getDashArrayDotted(element.strokeWidth)
  213. : undefined,
  214. // for non-solid strokes, disable multiStroke because it tends to make
  215. // dashes/dots overlay each other
  216. disableMultiStroke: element.strokeStyle !== "solid",
  217. // for non-solid strokes, increase the width a bit to make it visually
  218. // similar to solid strokes, because we're also disabling multiStroke
  219. strokeWidth:
  220. element.strokeStyle !== "solid"
  221. ? element.strokeWidth + 0.5
  222. : element.strokeWidth,
  223. // when increasing strokeWidth, we must explicitly set fillWeight and
  224. // hachureGap because if not specified, roughjs uses strokeWidth to
  225. // calculate them (and we don't want the fills to be modified)
  226. fillWeight: element.strokeWidth / 2,
  227. hachureGap: element.strokeWidth * 4,
  228. roughness: element.roughness,
  229. stroke: element.strokeColor,
  230. preserveVertices: continuousPath,
  231. };
  232. switch (element.type) {
  233. case "rectangle":
  234. case "diamond":
  235. case "ellipse": {
  236. options.fillStyle = element.fillStyle;
  237. options.fill =
  238. element.backgroundColor === "transparent"
  239. ? undefined
  240. : element.backgroundColor;
  241. if (element.type === "ellipse") {
  242. options.curveFitting = 1;
  243. }
  244. return options;
  245. }
  246. case "line": {
  247. if (isPathALoop(element.points)) {
  248. options.fillStyle = element.fillStyle;
  249. options.fill =
  250. element.backgroundColor === "transparent"
  251. ? undefined
  252. : element.backgroundColor;
  253. }
  254. return options;
  255. }
  256. case "freedraw":
  257. case "arrow":
  258. return options;
  259. default: {
  260. throw new Error(`Unimplemented type ${element.type}`);
  261. }
  262. }
  263. };
  264. /**
  265. * Generates the element's shape and puts it into the cache.
  266. * @param element
  267. * @param generator
  268. */
  269. const generateElementShape = (
  270. element: NonDeletedExcalidrawElement,
  271. generator: RoughGenerator,
  272. ) => {
  273. let shape = shapeCache.get(element) || null;
  274. if (!shape) {
  275. elementWithCanvasCache.delete(element);
  276. switch (element.type) {
  277. case "rectangle":
  278. if (element.strokeSharpness === "round") {
  279. const w = element.width;
  280. const h = element.height;
  281. const r = Math.min(w, h) * 0.25;
  282. shape = generator.path(
  283. `M ${r} 0 L ${w - r} 0 Q ${w} 0, ${w} ${r} L ${w} ${
  284. h - r
  285. } Q ${w} ${h}, ${w - r} ${h} L ${r} ${h} Q 0 ${h}, 0 ${
  286. h - r
  287. } L 0 ${r} Q 0 0, ${r} 0`,
  288. generateRoughOptions(element, true),
  289. );
  290. } else {
  291. shape = generator.rectangle(
  292. 0,
  293. 0,
  294. element.width,
  295. element.height,
  296. generateRoughOptions(element),
  297. );
  298. }
  299. break;
  300. case "diamond": {
  301. const [
  302. topX,
  303. topY,
  304. rightX,
  305. rightY,
  306. bottomX,
  307. bottomY,
  308. leftX,
  309. leftY,
  310. ] = getDiamondPoints(element);
  311. shape = generator.polygon(
  312. [
  313. [topX, topY],
  314. [rightX, rightY],
  315. [bottomX, bottomY],
  316. [leftX, leftY],
  317. ],
  318. generateRoughOptions(element),
  319. );
  320. break;
  321. }
  322. case "ellipse":
  323. shape = generator.ellipse(
  324. element.width / 2,
  325. element.height / 2,
  326. element.width,
  327. element.height,
  328. generateRoughOptions(element),
  329. );
  330. break;
  331. case "line":
  332. case "arrow": {
  333. const options = generateRoughOptions(element);
  334. // points array can be empty in the beginning, so it is important to add
  335. // initial position to it
  336. const points = element.points.length ? element.points : [[0, 0]];
  337. // curve is always the first element
  338. // this simplifies finding the curve for an element
  339. if (element.strokeSharpness === "sharp") {
  340. if (options.fill) {
  341. shape = [generator.polygon(points as [number, number][], options)];
  342. } else {
  343. shape = [
  344. generator.linearPath(points as [number, number][], options),
  345. ];
  346. }
  347. } else {
  348. shape = [generator.curve(points as [number, number][], options)];
  349. }
  350. // add lines only in arrow
  351. if (element.type === "arrow") {
  352. const { startArrowhead = null, endArrowhead = "arrow" } = element;
  353. const getArrowheadShapes = (
  354. element: ExcalidrawLinearElement,
  355. shape: Drawable[],
  356. position: "start" | "end",
  357. arrowhead: Arrowhead,
  358. ) => {
  359. const arrowheadPoints = getArrowheadPoints(
  360. element,
  361. shape,
  362. position,
  363. arrowhead,
  364. );
  365. if (arrowheadPoints === null) {
  366. return [];
  367. }
  368. // Other arrowheads here...
  369. if (arrowhead === "dot") {
  370. const [x, y, r] = arrowheadPoints;
  371. return [
  372. generator.circle(x, y, r, {
  373. ...options,
  374. fill: element.strokeColor,
  375. fillStyle: "solid",
  376. stroke: "none",
  377. }),
  378. ];
  379. }
  380. // Arrow arrowheads
  381. const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
  382. if (element.strokeStyle === "dotted") {
  383. // for dotted arrows caps, reduce gap to make it more legible
  384. const dash = getDashArrayDotted(element.strokeWidth - 1);
  385. options.strokeLineDash = [dash[0], dash[1] - 1];
  386. } else {
  387. // for solid/dashed, keep solid arrow cap
  388. delete options.strokeLineDash;
  389. }
  390. return [
  391. generator.line(x3, y3, x2, y2, options),
  392. generator.line(x4, y4, x2, y2, options),
  393. ];
  394. };
  395. if (startArrowhead !== null) {
  396. const shapes = getArrowheadShapes(
  397. element,
  398. shape,
  399. "start",
  400. startArrowhead,
  401. );
  402. shape.push(...shapes);
  403. }
  404. if (endArrowhead !== null) {
  405. if (endArrowhead === undefined) {
  406. // Hey, we have an old arrow here!
  407. }
  408. const shapes = getArrowheadShapes(
  409. element,
  410. shape,
  411. "end",
  412. endArrowhead,
  413. );
  414. shape.push(...shapes);
  415. }
  416. }
  417. break;
  418. }
  419. case "freedraw": {
  420. generateFreeDrawShape(element);
  421. shape = [];
  422. break;
  423. }
  424. case "text": {
  425. // just to ensure we don't regenerate element.canvas on rerenders
  426. shape = [];
  427. break;
  428. }
  429. }
  430. shapeCache.set(element, shape);
  431. }
  432. };
  433. const generateElementWithCanvas = (
  434. element: NonDeletedExcalidrawElement,
  435. sceneState?: SceneState,
  436. ) => {
  437. const zoom: Zoom = sceneState ? sceneState.zoom : defaultAppState.zoom;
  438. const prevElementWithCanvas = elementWithCanvasCache.get(element);
  439. const shouldRegenerateBecauseZoom =
  440. prevElementWithCanvas &&
  441. prevElementWithCanvas.canvasZoom !== zoom.value &&
  442. !sceneState?.shouldCacheIgnoreZoom;
  443. if (!prevElementWithCanvas || shouldRegenerateBecauseZoom) {
  444. const elementWithCanvas = generateElementCanvas(element, zoom);
  445. elementWithCanvasCache.set(element, elementWithCanvas);
  446. return elementWithCanvas;
  447. }
  448. return prevElementWithCanvas;
  449. };
  450. const drawElementFromCanvas = (
  451. elementWithCanvas: ExcalidrawElementWithCanvas,
  452. rc: RoughCanvas,
  453. context: CanvasRenderingContext2D,
  454. sceneState: SceneState,
  455. ) => {
  456. const element = elementWithCanvas.element;
  457. const padding = getCanvasPadding(element);
  458. let [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  459. // Free draw elements will otherwise "shuffle" as the min x and y change
  460. if (isFreeDrawElement(element)) {
  461. x1 = Math.floor(x1);
  462. x2 = Math.ceil(x2);
  463. y1 = Math.floor(y1);
  464. y2 = Math.ceil(y2);
  465. }
  466. const cx = ((x1 + x2) / 2 + sceneState.scrollX) * window.devicePixelRatio;
  467. const cy = ((y1 + y2) / 2 + sceneState.scrollY) * window.devicePixelRatio;
  468. context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio);
  469. context.translate(cx, cy);
  470. context.rotate(element.angle);
  471. context.drawImage(
  472. elementWithCanvas.canvas!,
  473. (-(x2 - x1) / 2) * window.devicePixelRatio -
  474. (padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom,
  475. (-(y2 - y1) / 2) * window.devicePixelRatio -
  476. (padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom,
  477. elementWithCanvas.canvas!.width / elementWithCanvas.canvasZoom,
  478. elementWithCanvas.canvas!.height / elementWithCanvas.canvasZoom,
  479. );
  480. context.rotate(-element.angle);
  481. context.translate(-cx, -cy);
  482. context.scale(window.devicePixelRatio, window.devicePixelRatio);
  483. // Clear the nested element we appended to the DOM
  484. };
  485. export const renderElement = (
  486. element: NonDeletedExcalidrawElement,
  487. rc: RoughCanvas,
  488. context: CanvasRenderingContext2D,
  489. renderOptimizations: boolean,
  490. sceneState: SceneState,
  491. ) => {
  492. const generator = rc.generator;
  493. switch (element.type) {
  494. case "selection": {
  495. context.translate(
  496. element.x + sceneState.scrollX,
  497. element.y + sceneState.scrollY,
  498. );
  499. const fillStyle = context.fillStyle;
  500. context.fillStyle = "rgba(0, 0, 255, 0.10)";
  501. context.fillRect(0, 0, element.width, element.height);
  502. context.fillStyle = fillStyle;
  503. context.translate(
  504. -element.x - sceneState.scrollX,
  505. -element.y - sceneState.scrollY,
  506. );
  507. break;
  508. }
  509. case "freedraw": {
  510. generateElementShape(element, generator);
  511. if (renderOptimizations) {
  512. const elementWithCanvas = generateElementWithCanvas(
  513. element,
  514. sceneState,
  515. );
  516. drawElementFromCanvas(elementWithCanvas, rc, context, sceneState);
  517. } else {
  518. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  519. const cx = (x1 + x2) / 2 + sceneState.scrollX;
  520. const cy = (y1 + y2) / 2 + sceneState.scrollY;
  521. const shiftX = (x2 - x1) / 2 - (element.x - x1);
  522. const shiftY = (y2 - y1) / 2 - (element.y - y1);
  523. context.translate(cx, cy);
  524. context.rotate(element.angle);
  525. context.translate(-shiftX, -shiftY);
  526. drawElementOnCanvas(element, rc, context);
  527. context.translate(shiftX, shiftY);
  528. context.rotate(-element.angle);
  529. context.translate(-cx, -cy);
  530. }
  531. break;
  532. }
  533. case "rectangle":
  534. case "diamond":
  535. case "ellipse":
  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 "line":
  607. case "arrow": {
  608. generateElementShape(element, generator);
  609. const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
  610. const opacity = element.opacity / 100;
  611. group.setAttribute("stroke-linecap", "round");
  612. (getShapeForElement(element) as Drawable[]).forEach((shape) => {
  613. const node = rsvg.draw(shape);
  614. if (opacity !== 1) {
  615. node.setAttribute("stroke-opacity", `${opacity}`);
  616. node.setAttribute("fill-opacity", `${opacity}`);
  617. }
  618. node.setAttribute(
  619. "transform",
  620. `translate(${offsetX || 0} ${
  621. offsetY || 0
  622. }) rotate(${degree} ${cx} ${cy})`,
  623. );
  624. if (
  625. element.type === "line" &&
  626. isPathALoop(element.points) &&
  627. element.backgroundColor !== "transparent"
  628. ) {
  629. node.setAttribute("fill-rule", "evenodd");
  630. }
  631. group.appendChild(node);
  632. });
  633. svgRoot.appendChild(group);
  634. break;
  635. }
  636. case "freedraw": {
  637. generateFreeDrawShape(element);
  638. const opacity = element.opacity / 100;
  639. const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
  640. if (opacity !== 1) {
  641. node.setAttribute("stroke-opacity", `${opacity}`);
  642. node.setAttribute("fill-opacity", `${opacity}`);
  643. }
  644. node.setAttribute(
  645. "transform",
  646. `translate(${offsetX || 0} ${
  647. offsetY || 0
  648. }) rotate(${degree} ${cx} ${cy})`,
  649. );
  650. const path = svgRoot.ownerDocument!.createElementNS(SVG_NS, "path");
  651. node.setAttribute("stroke", "none");
  652. node.setAttribute("fill", element.strokeColor);
  653. path.setAttribute("d", getFreeDrawSvgPath(element));
  654. node.appendChild(path);
  655. svgRoot.appendChild(node);
  656. break;
  657. }
  658. default: {
  659. if (isTextElement(element)) {
  660. const opacity = element.opacity / 100;
  661. const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
  662. if (opacity !== 1) {
  663. node.setAttribute("stroke-opacity", `${opacity}`);
  664. node.setAttribute("fill-opacity", `${opacity}`);
  665. }
  666. node.setAttribute(
  667. "transform",
  668. `translate(${offsetX || 0} ${
  669. offsetY || 0
  670. }) rotate(${degree} ${cx} ${cy})`,
  671. );
  672. const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
  673. const lineHeight = element.height / lines.length;
  674. const verticalOffset = element.height - element.baseline;
  675. const horizontalOffset =
  676. element.textAlign === "center"
  677. ? element.width / 2
  678. : element.textAlign === "right"
  679. ? element.width
  680. : 0;
  681. const direction = isRTL(element.text) ? "rtl" : "ltr";
  682. const textAnchor =
  683. element.textAlign === "center"
  684. ? "middle"
  685. : element.textAlign === "right" || direction === "rtl"
  686. ? "end"
  687. : "start";
  688. for (let i = 0; i < lines.length; i++) {
  689. const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text");
  690. text.textContent = lines[i];
  691. text.setAttribute("x", `${horizontalOffset}`);
  692. text.setAttribute("y", `${(i + 1) * lineHeight - verticalOffset}`);
  693. text.setAttribute("font-family", getFontFamilyString(element));
  694. text.setAttribute("font-size", `${element.fontSize}px`);
  695. text.setAttribute("fill", element.strokeColor);
  696. text.setAttribute("text-anchor", textAnchor);
  697. text.setAttribute("style", "white-space: pre;");
  698. text.setAttribute("direction", direction);
  699. node.appendChild(text);
  700. }
  701. svgRoot.appendChild(node);
  702. } else {
  703. // @ts-ignore
  704. throw new Error(`Unimplemented type ${element.type}`);
  705. }
  706. }
  707. }
  708. };
  709. export const pathsCache = new WeakMap<ExcalidrawFreeDrawElement, Path2D>([]);
  710. export function generateFreeDrawShape(element: ExcalidrawFreeDrawElement) {
  711. const svgPathData = getFreeDrawSvgPath(element);
  712. const path = new Path2D(svgPathData);
  713. pathsCache.set(element, path);
  714. return path;
  715. }
  716. export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
  717. return pathsCache.get(element);
  718. }
  719. export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
  720. const inputPoints = element.simulatePressure
  721. ? element.points
  722. : element.points.length
  723. ? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
  724. : [[0, 0, 0]];
  725. // Consider changing the options for simulated pressure vs real pressure
  726. const options = {
  727. simulatePressure: element.simulatePressure,
  728. size: element.strokeWidth * 6,
  729. thinning: 0.5,
  730. smoothing: 0.5,
  731. streamline: 0.5,
  732. easing: (t: number) => t * (2 - t),
  733. last: true,
  734. };
  735. const points = getFreeDrawShape(inputPoints as number[][], options);
  736. const d: (string | number)[] = [];
  737. let [p0, p1] = points;
  738. d.push("M", p0[0], p0[1], "Q");
  739. for (let i = 0; i < points.length; i++) {
  740. d.push(p0[0], p0[1], (p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2);
  741. p0 = p1;
  742. p1 = points[i];
  743. }
  744. p1 = points[0];
  745. d.push(p0[0], p0[1], (p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2);
  746. d.push("Z");
  747. return d.join(" ");
  748. }