renderElement.ts 17 KB

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