renderScene.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623
  1. import { RoughCanvas } from "roughjs/bin/canvas";
  2. import { RoughSVG } from "roughjs/bin/svg";
  3. import oc from "open-color";
  4. import { FlooredNumber, AppState } from "../types";
  5. import {
  6. ExcalidrawElement,
  7. NonDeletedExcalidrawElement,
  8. ExcalidrawLinearElement,
  9. NonDeleted,
  10. GroupId,
  11. } from "../element/types";
  12. import {
  13. getElementAbsoluteCoords,
  14. OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
  15. handlerRectanglesFromCoords,
  16. handlerRectangles,
  17. getCommonBounds,
  18. canResizeMutlipleElements,
  19. } from "../element";
  20. import { roundRect } from "./roundRect";
  21. import { SceneState } from "../scene/types";
  22. import {
  23. getScrollBars,
  24. SCROLLBAR_COLOR,
  25. SCROLLBAR_WIDTH,
  26. } from "../scene/scrollbars";
  27. import { getSelectedElements } from "../scene/selection";
  28. import { renderElement, renderElementToSvg } from "./renderElement";
  29. import colors from "../colors";
  30. import { isLinearElement } from "../element/typeChecks";
  31. import { LinearElementEditor } from "../element/linearElementEditor";
  32. import {
  33. isSelectedViaGroup,
  34. getSelectedGroupIds,
  35. getElementsInGroup,
  36. } from "../groups";
  37. type HandlerRectanglesRet = keyof ReturnType<typeof handlerRectangles>;
  38. const colorsForClientId = (clientId: string) => {
  39. // Naive way of getting an integer out of the clientId
  40. const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0);
  41. // Skip transparent background.
  42. const backgrounds = colors.elementBackground.slice(1);
  43. const strokes = colors.elementStroke.slice(1);
  44. return {
  45. background: backgrounds[sum % backgrounds.length],
  46. stroke: strokes[sum % strokes.length],
  47. };
  48. };
  49. const strokeRectWithRotation = (
  50. context: CanvasRenderingContext2D,
  51. x: number,
  52. y: number,
  53. width: number,
  54. height: number,
  55. cx: number,
  56. cy: number,
  57. angle: number,
  58. fill?: boolean,
  59. ) => {
  60. context.translate(cx, cy);
  61. context.rotate(angle);
  62. if (fill) {
  63. context.fillRect(x - cx, y - cy, width, height);
  64. }
  65. context.strokeRect(x - cx, y - cy, width, height);
  66. context.rotate(-angle);
  67. context.translate(-cx, -cy);
  68. };
  69. const strokeCircle = (
  70. context: CanvasRenderingContext2D,
  71. x: number,
  72. y: number,
  73. width: number,
  74. height: number,
  75. ) => {
  76. context.beginPath();
  77. context.arc(x + width / 2, y + height / 2, width / 2, 0, Math.PI * 2);
  78. context.fill();
  79. context.stroke();
  80. };
  81. const renderLinearPointHandles = (
  82. context: CanvasRenderingContext2D,
  83. appState: AppState,
  84. sceneState: SceneState,
  85. element: NonDeleted<ExcalidrawLinearElement>,
  86. ) => {
  87. context.translate(sceneState.scrollX, sceneState.scrollY);
  88. const origStrokeStyle = context.strokeStyle;
  89. const lineWidth = context.lineWidth;
  90. context.lineWidth = 1 / sceneState.zoom;
  91. LinearElementEditor.getPointsGlobalCoordinates(element).forEach(
  92. (point, idx) => {
  93. context.strokeStyle = "red";
  94. context.setLineDash([]);
  95. context.fillStyle =
  96. appState.editingLinearElement?.activePointIndex === idx
  97. ? "rgba(255, 127, 127, 0.9)"
  98. : "rgba(255, 255, 255, 0.9)";
  99. const { POINT_HANDLE_SIZE } = LinearElementEditor;
  100. strokeCircle(
  101. context,
  102. point[0] - POINT_HANDLE_SIZE / 2 / sceneState.zoom,
  103. point[1] - POINT_HANDLE_SIZE / 2 / sceneState.zoom,
  104. POINT_HANDLE_SIZE / sceneState.zoom,
  105. POINT_HANDLE_SIZE / sceneState.zoom,
  106. );
  107. },
  108. );
  109. context.setLineDash([]);
  110. context.lineWidth = lineWidth;
  111. context.translate(-sceneState.scrollX, -sceneState.scrollY);
  112. context.strokeStyle = origStrokeStyle;
  113. };
  114. export const renderScene = (
  115. elements: readonly NonDeletedExcalidrawElement[],
  116. appState: AppState,
  117. selectionElement: NonDeletedExcalidrawElement | null,
  118. scale: number,
  119. rc: RoughCanvas,
  120. canvas: HTMLCanvasElement,
  121. sceneState: SceneState,
  122. // extra options, currently passed by export helper
  123. {
  124. renderScrollbars = true,
  125. renderSelection = true,
  126. // Whether to employ render optimizations to improve performance.
  127. // Should not be turned on for export operations and similar, because it
  128. // doesn't guarantee pixel-perfect output.
  129. renderOptimizations = false,
  130. }: {
  131. renderScrollbars?: boolean;
  132. renderSelection?: boolean;
  133. renderOptimizations?: boolean;
  134. } = {},
  135. ) => {
  136. if (!canvas) {
  137. return { atLeastOneVisibleElement: false };
  138. }
  139. const context = canvas.getContext("2d")!;
  140. context.scale(scale, scale);
  141. // When doing calculations based on canvas width we should used normalized one
  142. const normalizedCanvasWidth = canvas.width / scale;
  143. const normalizedCanvasHeight = canvas.height / scale;
  144. // Paint background
  145. if (typeof sceneState.viewBackgroundColor === "string") {
  146. const hasTransparence =
  147. sceneState.viewBackgroundColor === "transparent" ||
  148. sceneState.viewBackgroundColor.length === 5 || // #RGBA
  149. sceneState.viewBackgroundColor.length === 9 || // #RRGGBBA
  150. /(hsla|rgba)\(/.test(sceneState.viewBackgroundColor);
  151. if (hasTransparence) {
  152. context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
  153. }
  154. const fillStyle = context.fillStyle;
  155. context.fillStyle = sceneState.viewBackgroundColor;
  156. context.fillRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
  157. context.fillStyle = fillStyle;
  158. } else {
  159. context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
  160. }
  161. // Apply zoom
  162. const zoomTranslationX = (-normalizedCanvasWidth * (sceneState.zoom - 1)) / 2;
  163. const zoomTranslationY =
  164. (-normalizedCanvasHeight * (sceneState.zoom - 1)) / 2;
  165. context.translate(zoomTranslationX, zoomTranslationY);
  166. context.scale(sceneState.zoom, sceneState.zoom);
  167. // Paint visible elements
  168. const visibleElements = elements.filter((element) =>
  169. isVisibleElement(
  170. element,
  171. normalizedCanvasWidth,
  172. normalizedCanvasHeight,
  173. sceneState,
  174. ),
  175. );
  176. visibleElements.forEach((element) => {
  177. renderElement(element, rc, context, renderOptimizations, sceneState);
  178. if (
  179. isLinearElement(element) &&
  180. appState.editingLinearElement &&
  181. appState.editingLinearElement.elementId === element.id
  182. ) {
  183. renderLinearPointHandles(context, appState, sceneState, element);
  184. }
  185. });
  186. // Paint selection element
  187. if (selectionElement) {
  188. renderElement(
  189. selectionElement,
  190. rc,
  191. context,
  192. renderOptimizations,
  193. sceneState,
  194. );
  195. }
  196. // Paint selected elements
  197. if (
  198. renderSelection &&
  199. !appState.multiElement &&
  200. !appState.editingLinearElement
  201. ) {
  202. context.translate(sceneState.scrollX, sceneState.scrollY);
  203. const selections = elements.reduce((acc, element) => {
  204. const selectionColors = [];
  205. // local user
  206. if (
  207. appState.selectedElementIds[element.id] &&
  208. !isSelectedViaGroup(appState, element)
  209. ) {
  210. selectionColors.push(oc.black);
  211. }
  212. // remote users
  213. if (sceneState.remoteSelectedElementIds[element.id]) {
  214. selectionColors.push(
  215. ...sceneState.remoteSelectedElementIds[element.id].map((socketId) => {
  216. const { background } = colorsForClientId(socketId);
  217. return background;
  218. }),
  219. );
  220. }
  221. if (selectionColors.length) {
  222. const [
  223. elementX1,
  224. elementY1,
  225. elementX2,
  226. elementY2,
  227. ] = getElementAbsoluteCoords(element);
  228. acc.push({
  229. angle: element.angle,
  230. elementX1,
  231. elementY1,
  232. elementX2,
  233. elementY2,
  234. selectionColors,
  235. });
  236. }
  237. return acc;
  238. }, [] as { angle: number; elementX1: number; elementY1: number; elementX2: number; elementY2: number; selectionColors: string[] }[]);
  239. function addSelectionForGroupId(groupId: GroupId) {
  240. const groupElements = getElementsInGroup(elements, groupId);
  241. const [elementX1, elementY1, elementX2, elementY2] = getCommonBounds(
  242. groupElements,
  243. );
  244. selections.push({
  245. angle: 0,
  246. elementX1,
  247. elementX2,
  248. elementY1,
  249. elementY2,
  250. selectionColors: [oc.black],
  251. });
  252. }
  253. for (const groupId of getSelectedGroupIds(appState)) {
  254. // TODO: support multiplayer selected group IDs
  255. addSelectionForGroupId(groupId);
  256. }
  257. if (appState.editingGroupId) {
  258. addSelectionForGroupId(appState.editingGroupId);
  259. }
  260. selections.forEach(
  261. ({
  262. angle,
  263. elementX1,
  264. elementY1,
  265. elementX2,
  266. elementY2,
  267. selectionColors,
  268. }) => {
  269. const elementWidth = elementX2 - elementX1;
  270. const elementHeight = elementY2 - elementY1;
  271. const initialLineDash = context.getLineDash();
  272. const lineWidth = context.lineWidth;
  273. const lineDashOffset = context.lineDashOffset;
  274. const strokeStyle = context.strokeStyle;
  275. const dashedLinePadding = 4 / sceneState.zoom;
  276. const dashWidth = 8 / sceneState.zoom;
  277. const spaceWidth = 4 / sceneState.zoom;
  278. context.lineWidth = 1 / sceneState.zoom;
  279. const count = selectionColors.length;
  280. for (var i = 0; i < count; ++i) {
  281. context.strokeStyle = selectionColors[i];
  282. context.setLineDash([
  283. dashWidth,
  284. spaceWidth + (dashWidth + spaceWidth) * (count - 1),
  285. ]);
  286. context.lineDashOffset = (dashWidth + spaceWidth) * i;
  287. strokeRectWithRotation(
  288. context,
  289. elementX1 - dashedLinePadding,
  290. elementY1 - dashedLinePadding,
  291. elementWidth + dashedLinePadding * 2,
  292. elementHeight + dashedLinePadding * 2,
  293. elementX1 + elementWidth / 2,
  294. elementY1 + elementHeight / 2,
  295. angle,
  296. );
  297. }
  298. context.lineDashOffset = lineDashOffset;
  299. context.strokeStyle = strokeStyle;
  300. context.lineWidth = lineWidth;
  301. context.setLineDash(initialLineDash);
  302. },
  303. );
  304. context.translate(-sceneState.scrollX, -sceneState.scrollY);
  305. const locallySelectedElements = getSelectedElements(elements, appState);
  306. // Paint resize handlers
  307. if (locallySelectedElements.length === 1) {
  308. context.translate(sceneState.scrollX, sceneState.scrollY);
  309. context.fillStyle = oc.white;
  310. const handlers = handlerRectangles(
  311. locallySelectedElements[0],
  312. sceneState.zoom,
  313. );
  314. Object.keys(handlers).forEach((key) => {
  315. const handler = handlers[key as HandlerRectanglesRet];
  316. if (handler !== undefined) {
  317. const lineWidth = context.lineWidth;
  318. context.lineWidth = 1 / sceneState.zoom;
  319. if (key === "rotation") {
  320. strokeCircle(
  321. context,
  322. handler[0],
  323. handler[1],
  324. handler[2],
  325. handler[3],
  326. );
  327. } else {
  328. strokeRectWithRotation(
  329. context,
  330. handler[0],
  331. handler[1],
  332. handler[2],
  333. handler[3],
  334. handler[0] + handler[2] / 2,
  335. handler[1] + handler[3] / 2,
  336. locallySelectedElements[0].angle,
  337. true, // fill before stroke
  338. );
  339. }
  340. context.lineWidth = lineWidth;
  341. }
  342. });
  343. context.translate(-sceneState.scrollX, -sceneState.scrollY);
  344. } else if (locallySelectedElements.length > 1) {
  345. if (canResizeMutlipleElements(locallySelectedElements)) {
  346. const dashedLinePadding = 4 / sceneState.zoom;
  347. context.translate(sceneState.scrollX, sceneState.scrollY);
  348. context.fillStyle = oc.white;
  349. const [x1, y1, x2, y2] = getCommonBounds(locallySelectedElements);
  350. const initialLineDash = context.getLineDash();
  351. context.setLineDash([2 / sceneState.zoom]);
  352. const lineWidth = context.lineWidth;
  353. context.lineWidth = 1 / sceneState.zoom;
  354. strokeRectWithRotation(
  355. context,
  356. x1 - dashedLinePadding,
  357. y1 - dashedLinePadding,
  358. x2 - x1 + dashedLinePadding * 2,
  359. y2 - y1 + dashedLinePadding * 2,
  360. (x1 + x2) / 2,
  361. (y1 + y2) / 2,
  362. 0,
  363. );
  364. context.lineWidth = lineWidth;
  365. context.setLineDash(initialLineDash);
  366. const handlers = handlerRectanglesFromCoords(
  367. [x1, y1, x2, y2],
  368. 0,
  369. sceneState.zoom,
  370. undefined,
  371. OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
  372. );
  373. Object.keys(handlers).forEach((key) => {
  374. const handler = handlers[key as HandlerRectanglesRet];
  375. if (handler !== undefined) {
  376. const lineWidth = context.lineWidth;
  377. context.lineWidth = 1 / sceneState.zoom;
  378. strokeRectWithRotation(
  379. context,
  380. handler[0],
  381. handler[1],
  382. handler[2],
  383. handler[3],
  384. handler[0] + handler[2] / 2,
  385. handler[1] + handler[3] / 2,
  386. 0,
  387. true, // fill before stroke
  388. );
  389. context.lineWidth = lineWidth;
  390. }
  391. });
  392. context.translate(-sceneState.scrollX, -sceneState.scrollY);
  393. }
  394. }
  395. }
  396. // Reset zoom
  397. context.scale(1 / sceneState.zoom, 1 / sceneState.zoom);
  398. context.translate(-zoomTranslationX, -zoomTranslationY);
  399. // Paint remote pointers
  400. for (const clientId in sceneState.remotePointerViewportCoords) {
  401. let { x, y } = sceneState.remotePointerViewportCoords[clientId];
  402. const username = sceneState.remotePointerUsernames[clientId];
  403. const width = 9;
  404. const height = 14;
  405. const isOutOfBounds =
  406. x < 0 ||
  407. x > normalizedCanvasWidth - width ||
  408. y < 0 ||
  409. y > normalizedCanvasHeight - height;
  410. x = Math.max(x, 0);
  411. x = Math.min(x, normalizedCanvasWidth - width);
  412. y = Math.max(y, 0);
  413. y = Math.min(y, normalizedCanvasHeight - height);
  414. const { background, stroke } = colorsForClientId(clientId);
  415. const strokeStyle = context.strokeStyle;
  416. const fillStyle = context.fillStyle;
  417. const globalAlpha = context.globalAlpha;
  418. context.strokeStyle = stroke;
  419. context.fillStyle = background;
  420. if (isOutOfBounds) {
  421. context.globalAlpha = 0.2;
  422. }
  423. if (
  424. sceneState.remotePointerButton &&
  425. sceneState.remotePointerButton[clientId] === "down"
  426. ) {
  427. context.beginPath();
  428. context.arc(x, y, 15, 0, 2 * Math.PI, false);
  429. context.lineWidth = 3;
  430. context.strokeStyle = "#ffffff88";
  431. context.stroke();
  432. context.closePath();
  433. context.beginPath();
  434. context.arc(x, y, 15, 0, 2 * Math.PI, false);
  435. context.lineWidth = 1;
  436. context.strokeStyle = stroke;
  437. context.stroke();
  438. context.closePath();
  439. }
  440. context.beginPath();
  441. context.moveTo(x, y);
  442. context.lineTo(x + 1, y + 14);
  443. context.lineTo(x + 4, y + 9);
  444. context.lineTo(x + 9, y + 10);
  445. context.lineTo(x, y);
  446. context.fill();
  447. context.stroke();
  448. if (!isOutOfBounds && username) {
  449. const offsetX = x + width;
  450. const offsetY = y + height;
  451. const paddingHorizontal = 4;
  452. const paddingVertical = 4;
  453. const measure = context.measureText(username);
  454. const measureHeight =
  455. measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent;
  456. // Border
  457. context.fillStyle = stroke;
  458. context.globalAlpha = globalAlpha;
  459. context.fillRect(
  460. offsetX - 1,
  461. offsetY - 1,
  462. measure.width + 2 * paddingHorizontal + 2,
  463. measureHeight + 2 * paddingVertical + 2,
  464. );
  465. // Background
  466. context.fillStyle = background;
  467. context.fillRect(
  468. offsetX,
  469. offsetY,
  470. measure.width + 2 * paddingHorizontal,
  471. measureHeight + 2 * paddingVertical,
  472. );
  473. context.fillStyle = oc.white;
  474. context.fillText(
  475. username,
  476. offsetX + paddingHorizontal,
  477. offsetY + paddingVertical + measure.actualBoundingBoxAscent,
  478. );
  479. }
  480. context.strokeStyle = strokeStyle;
  481. context.fillStyle = fillStyle;
  482. context.globalAlpha = globalAlpha;
  483. context.closePath();
  484. }
  485. // Paint scrollbars
  486. let scrollBars;
  487. if (renderScrollbars) {
  488. scrollBars = getScrollBars(
  489. elements,
  490. normalizedCanvasWidth,
  491. normalizedCanvasHeight,
  492. sceneState,
  493. );
  494. const fillStyle = context.fillStyle;
  495. const strokeStyle = context.strokeStyle;
  496. context.fillStyle = SCROLLBAR_COLOR;
  497. context.strokeStyle = "rgba(255,255,255,0.8)";
  498. [scrollBars.horizontal, scrollBars.vertical].forEach((scrollBar) => {
  499. if (scrollBar) {
  500. roundRect(
  501. context,
  502. scrollBar.x,
  503. scrollBar.y,
  504. scrollBar.width,
  505. scrollBar.height,
  506. SCROLLBAR_WIDTH / 2,
  507. );
  508. }
  509. });
  510. context.fillStyle = fillStyle;
  511. context.strokeStyle = strokeStyle;
  512. }
  513. context.scale(1 / scale, 1 / scale);
  514. return { atLeastOneVisibleElement: visibleElements.length > 0, scrollBars };
  515. };
  516. const isVisibleElement = (
  517. element: ExcalidrawElement,
  518. viewportWidth: number,
  519. viewportHeight: number,
  520. {
  521. scrollX,
  522. scrollY,
  523. zoom,
  524. }: {
  525. scrollX: FlooredNumber;
  526. scrollY: FlooredNumber;
  527. zoom: number;
  528. },
  529. ) => {
  530. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  531. // Apply zoom
  532. const viewportWidthWithZoom = viewportWidth / zoom;
  533. const viewportHeightWithZoom = viewportHeight / zoom;
  534. const viewportWidthDiff = viewportWidth - viewportWidthWithZoom;
  535. const viewportHeightDiff = viewportHeight - viewportHeightWithZoom;
  536. return (
  537. x2 + scrollX - viewportWidthDiff / 2 >= 0 &&
  538. x1 + scrollX - viewportWidthDiff / 2 <= viewportWidthWithZoom &&
  539. y2 + scrollY - viewportHeightDiff / 2 >= 0 &&
  540. y1 + scrollY - viewportHeightDiff / 2 <= viewportHeightWithZoom
  541. );
  542. };
  543. // This should be only called for exporting purposes
  544. export const renderSceneToSvg = (
  545. elements: readonly NonDeletedExcalidrawElement[],
  546. rsvg: RoughSVG,
  547. svgRoot: SVGElement,
  548. {
  549. offsetX = 0,
  550. offsetY = 0,
  551. }: {
  552. offsetX?: number;
  553. offsetY?: number;
  554. } = {},
  555. ) => {
  556. if (!svgRoot) {
  557. return;
  558. }
  559. // render elements
  560. elements.forEach((element) => {
  561. if (!element.isDeleted) {
  562. renderElementToSvg(
  563. element,
  564. rsvg,
  565. svgRoot,
  566. element.x + offsetX,
  567. element.y + offsetY,
  568. );
  569. }
  570. });
  571. };