renderElement.ts 20 KB

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