renderElement.ts 16 KB

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