renderScene.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. import { RoughCanvas } from "roughjs/bin/canvas";
  2. import { RoughSVG } from "roughjs/bin/svg";
  3. import { FlooredNumber, AppState } from "../types";
  4. import { ExcalidrawElement } from "../element/types";
  5. import { getElementAbsoluteCoords, handlerRectangles } from "../element";
  6. import { roundRect } from "./roundRect";
  7. import { SceneState } from "../scene/types";
  8. import {
  9. getScrollBars,
  10. SCROLLBAR_COLOR,
  11. SCROLLBAR_WIDTH,
  12. } from "../scene/scrollbars";
  13. import { getZoomTranslation } from "../scene/zoom";
  14. import { getSelectedElements } from "../scene/selection";
  15. import { renderElement, renderElementToSvg } from "./renderElement";
  16. import colors from "../colors";
  17. function colorForClientId(clientId: string) {
  18. // Naive way of getting an integer out of the clientId
  19. const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0);
  20. return colors.elementBackground[sum % colors.elementBackground.length];
  21. }
  22. export function renderScene(
  23. elements: readonly ExcalidrawElement[],
  24. appState: AppState,
  25. selectionElement: ExcalidrawElement | null,
  26. rc: RoughCanvas,
  27. canvas: HTMLCanvasElement,
  28. sceneState: SceneState,
  29. // extra options, currently passed by export helper
  30. {
  31. renderScrollbars = true,
  32. renderSelection = true,
  33. // Whether to employ render optimizations to improve performance.
  34. // Should not be turned on for export operations and similar, because it
  35. // doesn't guarantee pixel-perfect output.
  36. renderOptimizations = false,
  37. }: {
  38. renderScrollbars?: boolean;
  39. renderSelection?: boolean;
  40. renderOptimizations?: boolean;
  41. } = {},
  42. ) {
  43. if (!canvas) {
  44. return { atLeastOneVisibleElement: false };
  45. }
  46. const context = canvas.getContext("2d")!;
  47. // Get initial scale transform as reference for later usage
  48. const initialContextTransform = context.getTransform();
  49. // When doing calculations based on canvas width we should used normalized one
  50. const normalizedCanvasWidth =
  51. canvas.width / getContextTransformScaleX(initialContextTransform);
  52. const normalizedCanvasHeight =
  53. canvas.height / getContextTransformScaleY(initialContextTransform);
  54. const zoomTranslation = getZoomTranslation(canvas, sceneState.zoom);
  55. function applyZoom(context: CanvasRenderingContext2D): void {
  56. context.save();
  57. // Handle zoom scaling
  58. context.setTransform(
  59. getContextTransformScaleX(initialContextTransform) * sceneState.zoom,
  60. 0,
  61. 0,
  62. getContextTransformScaleY(initialContextTransform) * sceneState.zoom,
  63. getContextTransformTranslateX(context.getTransform()),
  64. getContextTransformTranslateY(context.getTransform()),
  65. );
  66. // Handle zoom translation
  67. context.setTransform(
  68. getContextTransformScaleX(context.getTransform()),
  69. 0,
  70. 0,
  71. getContextTransformScaleY(context.getTransform()),
  72. getContextTransformTranslateX(initialContextTransform) -
  73. zoomTranslation.x,
  74. getContextTransformTranslateY(initialContextTransform) -
  75. zoomTranslation.y,
  76. );
  77. }
  78. function resetZoom(context: CanvasRenderingContext2D): void {
  79. context.restore();
  80. }
  81. // Paint background
  82. context.save();
  83. if (typeof sceneState.viewBackgroundColor === "string") {
  84. const hasTransparence =
  85. sceneState.viewBackgroundColor === "transparent" ||
  86. sceneState.viewBackgroundColor.length === 5 ||
  87. sceneState.viewBackgroundColor.length === 9;
  88. if (hasTransparence) {
  89. context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
  90. }
  91. context.fillStyle = sceneState.viewBackgroundColor;
  92. context.fillRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
  93. } else {
  94. context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
  95. }
  96. context.restore();
  97. // Paint visible elements
  98. const visibleElements = elements.filter(element =>
  99. isVisibleElement(
  100. element,
  101. normalizedCanvasWidth,
  102. normalizedCanvasHeight,
  103. sceneState,
  104. ),
  105. );
  106. applyZoom(context);
  107. visibleElements.forEach(element => {
  108. renderElement(element, rc, context, renderOptimizations, sceneState);
  109. });
  110. resetZoom(context);
  111. // Pain selection element
  112. if (selectionElement) {
  113. applyZoom(context);
  114. renderElement(
  115. selectionElement,
  116. rc,
  117. context,
  118. renderOptimizations,
  119. sceneState,
  120. );
  121. resetZoom(context);
  122. }
  123. // Pain selected elements
  124. if (renderSelection) {
  125. const selectedElements = getSelectedElements(elements, appState);
  126. const dashledLinePadding = 4 / sceneState.zoom;
  127. applyZoom(context);
  128. context.translate(sceneState.scrollX, sceneState.scrollY);
  129. selectedElements.forEach(element => {
  130. const [
  131. elementX1,
  132. elementY1,
  133. elementX2,
  134. elementY2,
  135. ] = getElementAbsoluteCoords(element);
  136. const elementWidth = elementX2 - elementX1;
  137. const elementHeight = elementY2 - elementY1;
  138. const initialLineDash = context.getLineDash();
  139. context.setLineDash([8 / sceneState.zoom, 4 / sceneState.zoom]);
  140. context.strokeRect(
  141. elementX1 - dashledLinePadding,
  142. elementY1 - dashledLinePadding,
  143. elementWidth + dashledLinePadding * 2,
  144. elementHeight + dashledLinePadding * 2,
  145. );
  146. context.setLineDash(initialLineDash);
  147. });
  148. resetZoom(context);
  149. // Paint resize handlers
  150. if (selectedElements.length === 1 && selectedElements[0].type !== "text") {
  151. applyZoom(context);
  152. context.translate(sceneState.scrollX, sceneState.scrollY);
  153. context.fillStyle = "#fff";
  154. const handlers = handlerRectangles(selectedElements[0], sceneState.zoom);
  155. Object.values(handlers)
  156. .filter(handler => handler !== undefined)
  157. .forEach(handler => {
  158. context.fillRect(handler[0], handler[1], handler[2], handler[3]);
  159. context.strokeRect(handler[0], handler[1], handler[2], handler[3]);
  160. });
  161. resetZoom(context);
  162. }
  163. }
  164. // Paint remote pointers
  165. for (const clientId in sceneState.remotePointerViewportCoords) {
  166. const { x, y } = sceneState.remotePointerViewportCoords[clientId];
  167. const color = colorForClientId(clientId);
  168. const strokeStyle = context.strokeStyle;
  169. const fillStyle = context.fillStyle;
  170. context.strokeStyle = color;
  171. context.fillStyle = color;
  172. context.beginPath();
  173. context.moveTo(x, y);
  174. context.lineTo(x + 1, y + 14);
  175. context.lineTo(x + 4, y + 9);
  176. context.lineTo(x + 9, y + 10);
  177. context.lineTo(x, y);
  178. context.fill();
  179. context.stroke();
  180. context.strokeStyle = strokeStyle;
  181. context.fillStyle = fillStyle;
  182. }
  183. // Paint scrollbars
  184. if (renderScrollbars) {
  185. const scrollBars = getScrollBars(
  186. elements,
  187. normalizedCanvasWidth,
  188. normalizedCanvasHeight,
  189. sceneState,
  190. );
  191. context.save();
  192. context.fillStyle = SCROLLBAR_COLOR;
  193. context.strokeStyle = "rgba(255,255,255,0.8)";
  194. [scrollBars.horizontal, scrollBars.vertical].forEach(scrollBar => {
  195. if (scrollBar) {
  196. roundRect(
  197. context,
  198. scrollBar.x,
  199. scrollBar.y,
  200. scrollBar.width,
  201. scrollBar.height,
  202. SCROLLBAR_WIDTH / 2,
  203. );
  204. }
  205. });
  206. context.restore();
  207. return { atLeastOneVisibleElement: visibleElements.length > 0, scrollBars };
  208. }
  209. return { atLeastOneVisibleElement: visibleElements.length > 0 };
  210. }
  211. function isVisibleElement(
  212. element: ExcalidrawElement,
  213. viewportWidth: number,
  214. viewportHeight: number,
  215. {
  216. scrollX,
  217. scrollY,
  218. zoom,
  219. }: {
  220. scrollX: FlooredNumber;
  221. scrollY: FlooredNumber;
  222. zoom: number;
  223. },
  224. ) {
  225. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  226. // Apply zoom
  227. const viewportWidthWithZoom = viewportWidth / zoom;
  228. const viewportHeightWithZoom = viewportHeight / zoom;
  229. const viewportWidthDiff = viewportWidth - viewportWidthWithZoom;
  230. const viewportHeightDiff = viewportHeight - viewportHeightWithZoom;
  231. return (
  232. x2 + scrollX - viewportWidthDiff / 2 >= 0 &&
  233. x1 + scrollX - viewportWidthDiff / 2 <= viewportWidthWithZoom &&
  234. y2 + scrollY - viewportHeightDiff / 2 >= 0 &&
  235. y1 + scrollY - viewportHeightDiff / 2 <= viewportHeightWithZoom
  236. );
  237. }
  238. // This should be only called for exporting purposes
  239. export function renderSceneToSvg(
  240. elements: readonly ExcalidrawElement[],
  241. rsvg: RoughSVG,
  242. svgRoot: SVGElement,
  243. {
  244. offsetX = 0,
  245. offsetY = 0,
  246. }: {
  247. offsetX?: number;
  248. offsetY?: number;
  249. } = {},
  250. ) {
  251. if (!svgRoot) {
  252. return;
  253. }
  254. // render elements
  255. elements.forEach(element => {
  256. renderElementToSvg(
  257. element,
  258. rsvg,
  259. svgRoot,
  260. element.x + offsetX,
  261. element.y + offsetY,
  262. );
  263. });
  264. }
  265. function getContextTransformScaleX(transform: DOMMatrix): number {
  266. return transform.a;
  267. }
  268. function getContextTransformScaleY(transform: DOMMatrix): number {
  269. return transform.d;
  270. }
  271. function getContextTransformTranslateX(transform: DOMMatrix): number {
  272. return transform.e;
  273. }
  274. function getContextTransformTranslateY(transform: DOMMatrix): number {
  275. return transform.f;
  276. }