renderElement.ts 13 KB

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