renderScene.ts 19 KB

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