renderElement.ts 20 KB

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