DebugCanvas.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  1. import {
  2. ArrowheadArrowIcon,
  3. CloseIcon,
  4. TrashIcon,
  5. } from "@excalidraw/excalidraw/components/icons";
  6. import {
  7. bootstrapCanvas,
  8. getNormalizedCanvasDimensions,
  9. } from "@excalidraw/excalidraw/renderer/helpers";
  10. import { type AppState } from "@excalidraw/excalidraw/types";
  11. import { arrayToMap, throttleRAF } from "@excalidraw/common";
  12. import { useCallback } from "react";
  13. import {
  14. getGlobalFixedPointForBindableElement,
  15. isArrowElement,
  16. isBindableElement,
  17. } from "@excalidraw/element";
  18. import {
  19. isLineSegment,
  20. type GlobalPoint,
  21. type LineSegment,
  22. } from "@excalidraw/math";
  23. import { isCurve } from "@excalidraw/math/curve";
  24. import React from "react";
  25. import type { Curve } from "@excalidraw/math";
  26. import type {
  27. DebugElement,
  28. DebugPolygon,
  29. } from "@excalidraw/element/visualdebug";
  30. import type {
  31. ElementsMap,
  32. ExcalidrawArrowElement,
  33. ExcalidrawBindableElement,
  34. FixedPointBinding,
  35. OrderedExcalidrawElement,
  36. } from "@excalidraw/element/types";
  37. import { STORAGE_KEYS } from "../app_constants";
  38. const renderLine = (
  39. context: CanvasRenderingContext2D,
  40. zoom: number,
  41. segment: LineSegment<GlobalPoint>,
  42. color: string,
  43. ) => {
  44. context.save();
  45. context.strokeStyle = color;
  46. context.beginPath();
  47. context.moveTo(segment[0][0] * zoom, segment[0][1] * zoom);
  48. context.lineTo(segment[1][0] * zoom, segment[1][1] * zoom);
  49. context.stroke();
  50. context.restore();
  51. };
  52. const renderCubicBezier = (
  53. context: CanvasRenderingContext2D,
  54. zoom: number,
  55. [start, control1, control2, end]: Curve<GlobalPoint>,
  56. color: string,
  57. ) => {
  58. context.save();
  59. context.strokeStyle = color;
  60. context.beginPath();
  61. context.moveTo(start[0] * zoom, start[1] * zoom);
  62. context.bezierCurveTo(
  63. control1[0] * zoom,
  64. control1[1] * zoom,
  65. control2[0] * zoom,
  66. control2[1] * zoom,
  67. end[0] * zoom,
  68. end[1] * zoom,
  69. );
  70. context.stroke();
  71. context.restore();
  72. };
  73. const renderPolygon = (
  74. context: CanvasRenderingContext2D,
  75. zoom: number,
  76. polygon: DebugPolygon,
  77. color: string,
  78. ) => {
  79. const { points, fill, close } = polygon;
  80. if (points.length < 2) {
  81. return;
  82. }
  83. context.save();
  84. context.beginPath();
  85. context.moveTo(points[0][0] * zoom, points[0][1] * zoom);
  86. for (let i = 1; i < points.length; i += 1) {
  87. context.lineTo(points[i][0] * zoom, points[i][1] * zoom);
  88. }
  89. if (close !== false) {
  90. context.closePath();
  91. }
  92. if (fill) {
  93. context.save();
  94. context.globalAlpha = 0.15;
  95. context.fillStyle = color;
  96. context.fill();
  97. context.restore();
  98. }
  99. context.strokeStyle = color;
  100. context.stroke();
  101. context.restore();
  102. };
  103. const isDebugPolygon = (data: DebugElement["data"]): data is DebugPolygon =>
  104. (data as DebugPolygon).type === "polygon";
  105. const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
  106. context.strokeStyle = "#888";
  107. context.save();
  108. context.beginPath();
  109. context.moveTo(-10 * zoom, -10 * zoom);
  110. context.lineTo(10 * zoom, 10 * zoom);
  111. context.moveTo(10 * zoom, -10 * zoom);
  112. context.lineTo(-10 * zoom, 10 * zoom);
  113. context.stroke();
  114. context.save();
  115. };
  116. const _renderBinding = (
  117. context: CanvasRenderingContext2D,
  118. binding: FixedPointBinding,
  119. elementsMap: ElementsMap,
  120. zoom: number,
  121. width: number,
  122. height: number,
  123. color: string,
  124. ) => {
  125. if (!binding.fixedPoint) {
  126. console.warn("Binding must have a fixedPoint");
  127. return;
  128. }
  129. const bindable = elementsMap.get(
  130. binding.elementId,
  131. ) as ExcalidrawBindableElement;
  132. const [x, y] = getGlobalFixedPointForBindableElement(
  133. binding.fixedPoint,
  134. bindable,
  135. elementsMap,
  136. );
  137. context.save();
  138. context.strokeStyle = color;
  139. context.lineWidth = 1;
  140. context.beginPath();
  141. context.moveTo(x * zoom, y * zoom);
  142. context.bezierCurveTo(
  143. x * zoom - width,
  144. y * zoom - height,
  145. x * zoom - width,
  146. y * zoom + height,
  147. x * zoom,
  148. y * zoom,
  149. );
  150. context.stroke();
  151. context.restore();
  152. };
  153. const _renderBindableBinding = (
  154. binding: FixedPointBinding,
  155. context: CanvasRenderingContext2D,
  156. elementsMap: ElementsMap,
  157. zoom: number,
  158. width: number,
  159. height: number,
  160. color: string,
  161. ) => {
  162. const bindable = elementsMap.get(
  163. binding.elementId,
  164. ) as ExcalidrawBindableElement;
  165. if (!binding.fixedPoint) {
  166. console.warn("Binding must have a fixedPoint");
  167. return;
  168. }
  169. const [x, y] = getGlobalFixedPointForBindableElement(
  170. binding.fixedPoint,
  171. bindable,
  172. elementsMap,
  173. );
  174. context.save();
  175. context.strokeStyle = color;
  176. context.lineWidth = 1;
  177. context.beginPath();
  178. context.moveTo(x * zoom, y * zoom);
  179. context.bezierCurveTo(
  180. x * zoom + width,
  181. y * zoom + height,
  182. x * zoom + width,
  183. y * zoom - height,
  184. x * zoom,
  185. y * zoom,
  186. );
  187. context.stroke();
  188. context.restore();
  189. };
  190. const renderBindings = (
  191. context: CanvasRenderingContext2D,
  192. elements: readonly OrderedExcalidrawElement[],
  193. zoom: number,
  194. ) => {
  195. const elementsMap = arrayToMap(elements);
  196. const dim = 16;
  197. elements.forEach((element) => {
  198. if (element.isDeleted) {
  199. return;
  200. }
  201. if (isArrowElement(element)) {
  202. if (element.startBinding) {
  203. if (
  204. !elementsMap
  205. .get(element.startBinding.elementId)
  206. ?.boundElements?.find((e) => e.id === element.id)
  207. ) {
  208. return;
  209. }
  210. _renderBinding(
  211. context,
  212. element.startBinding,
  213. elementsMap,
  214. zoom,
  215. dim,
  216. dim,
  217. element.startBinding?.mode === "orbit" ? "red" : "black",
  218. );
  219. }
  220. if (element.endBinding) {
  221. if (
  222. !elementsMap
  223. .get(element.endBinding.elementId)
  224. ?.boundElements?.find((e) => e.id === element.id)
  225. ) {
  226. return;
  227. }
  228. _renderBinding(
  229. context,
  230. element.endBinding,
  231. elementsMap,
  232. zoom,
  233. dim,
  234. dim,
  235. element.endBinding?.mode === "orbit" ? "red" : "black",
  236. );
  237. }
  238. }
  239. if (isBindableElement(element) && element.boundElements?.length) {
  240. element.boundElements.forEach((boundElement) => {
  241. if (boundElement.type !== "arrow") {
  242. return;
  243. }
  244. const arrow = elementsMap.get(
  245. boundElement.id,
  246. ) as ExcalidrawArrowElement;
  247. if (arrow && arrow.startBinding?.elementId === element.id) {
  248. _renderBindableBinding(
  249. arrow.startBinding,
  250. context,
  251. elementsMap,
  252. zoom,
  253. dim,
  254. dim,
  255. "green",
  256. );
  257. }
  258. if (arrow && arrow.endBinding?.elementId === element.id) {
  259. _renderBindableBinding(
  260. arrow.endBinding,
  261. context,
  262. elementsMap,
  263. zoom,
  264. dim,
  265. dim,
  266. "green",
  267. );
  268. }
  269. });
  270. }
  271. });
  272. };
  273. const render = (
  274. frame: DebugElement[],
  275. context: CanvasRenderingContext2D,
  276. appState: AppState,
  277. ) => {
  278. frame.forEach((el: DebugElement) => {
  279. switch (true) {
  280. case isLineSegment(el.data):
  281. renderLine(
  282. context,
  283. appState.zoom.value,
  284. el.data as LineSegment<GlobalPoint>,
  285. el.color,
  286. );
  287. break;
  288. case isCurve(el.data):
  289. renderCubicBezier(
  290. context,
  291. appState.zoom.value,
  292. el.data as Curve<GlobalPoint>,
  293. el.color,
  294. );
  295. break;
  296. case isDebugPolygon(el.data):
  297. renderPolygon(context, appState.zoom.value, el.data, el.color);
  298. break;
  299. default:
  300. throw new Error(`Unknown element type ${JSON.stringify(el)}`);
  301. }
  302. });
  303. };
  304. const _debugRenderer = (
  305. canvas: HTMLCanvasElement,
  306. appState: AppState,
  307. elements: readonly OrderedExcalidrawElement[],
  308. scale: number,
  309. ) => {
  310. const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
  311. canvas,
  312. scale,
  313. );
  314. const context = bootstrapCanvas({
  315. canvas,
  316. scale,
  317. normalizedWidth,
  318. normalizedHeight,
  319. viewBackgroundColor: "transparent",
  320. });
  321. // Apply zoom
  322. context.save();
  323. context.translate(
  324. appState.scrollX * appState.zoom.value,
  325. appState.scrollY * appState.zoom.value,
  326. );
  327. renderOrigin(context, appState.zoom.value);
  328. renderBindings(context, elements, appState.zoom.value);
  329. if (
  330. window.visualDebug?.currentFrame &&
  331. window.visualDebug?.data &&
  332. window.visualDebug.data.length > 0
  333. ) {
  334. // Render only one frame
  335. const [idx] = debugFrameData();
  336. render(window.visualDebug.data[idx], context, appState);
  337. } else {
  338. // Render all debug frames
  339. window.visualDebug?.data.forEach((frame) => {
  340. render(frame, context, appState);
  341. });
  342. }
  343. if (window.visualDebug) {
  344. window.visualDebug!.data =
  345. window.visualDebug?.data.map((frame) =>
  346. frame.filter((el) => el.permanent),
  347. ) ?? [];
  348. }
  349. };
  350. const debugFrameData = (): [number, number] => {
  351. const currentFrame = window.visualDebug?.currentFrame ?? 0;
  352. const frameCount = window.visualDebug?.data.length ?? 0;
  353. if (frameCount > 0) {
  354. return [currentFrame % frameCount, window.visualDebug?.currentFrame ?? 0];
  355. }
  356. return [0, 0];
  357. };
  358. export const saveDebugState = (debug: { enabled: boolean }) => {
  359. try {
  360. localStorage.setItem(
  361. STORAGE_KEYS.LOCAL_STORAGE_DEBUG,
  362. JSON.stringify(debug),
  363. );
  364. } catch (error: any) {
  365. console.error(error);
  366. }
  367. };
  368. export const debugRenderer = throttleRAF(
  369. (
  370. canvas: HTMLCanvasElement,
  371. appState: AppState,
  372. elements: readonly OrderedExcalidrawElement[],
  373. scale: number,
  374. ) => {
  375. _debugRenderer(canvas, appState, elements, scale);
  376. },
  377. { trailing: true },
  378. );
  379. export const loadSavedDebugState = () => {
  380. let debug;
  381. try {
  382. const savedDebugState = localStorage.getItem(
  383. STORAGE_KEYS.LOCAL_STORAGE_DEBUG,
  384. );
  385. if (savedDebugState) {
  386. debug = JSON.parse(savedDebugState) as { enabled: boolean };
  387. }
  388. } catch (error: any) {
  389. console.error(error);
  390. }
  391. return debug ?? { enabled: false };
  392. };
  393. export const isVisualDebuggerEnabled = () =>
  394. Array.isArray(window.visualDebug?.data);
  395. export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
  396. const moveForward = useCallback(() => {
  397. if (
  398. !window.visualDebug?.currentFrame ||
  399. isNaN(window.visualDebug?.currentFrame ?? -1)
  400. ) {
  401. window.visualDebug!.currentFrame = 0;
  402. }
  403. window.visualDebug!.currentFrame += 1;
  404. onChange();
  405. }, [onChange]);
  406. const moveBackward = useCallback(() => {
  407. if (
  408. !window.visualDebug?.currentFrame ||
  409. isNaN(window.visualDebug?.currentFrame ?? -1) ||
  410. window.visualDebug?.currentFrame < 1
  411. ) {
  412. window.visualDebug!.currentFrame = 1;
  413. }
  414. window.visualDebug!.currentFrame -= 1;
  415. onChange();
  416. }, [onChange]);
  417. const reset = useCallback(() => {
  418. window.visualDebug!.currentFrame = undefined;
  419. onChange();
  420. }, [onChange]);
  421. const trashFrames = useCallback(() => {
  422. if (window.visualDebug) {
  423. window.visualDebug.currentFrame = undefined;
  424. window.visualDebug.data = [];
  425. }
  426. onChange();
  427. }, [onChange]);
  428. return (
  429. <>
  430. <button
  431. className="ToolIcon_type_button"
  432. data-testid="debug-forward"
  433. aria-label="Move forward"
  434. type="button"
  435. onClick={trashFrames}
  436. >
  437. <div
  438. className="ToolIcon__icon"
  439. aria-hidden="true"
  440. aria-disabled="false"
  441. >
  442. {TrashIcon}
  443. </div>
  444. </button>
  445. <button
  446. className="ToolIcon_type_button"
  447. data-testid="debug-forward"
  448. aria-label="Move forward"
  449. type="button"
  450. onClick={moveBackward}
  451. >
  452. <div
  453. className="ToolIcon__icon"
  454. aria-hidden="true"
  455. aria-disabled="false"
  456. >
  457. <ArrowheadArrowIcon flip />
  458. </div>
  459. </button>
  460. <button
  461. className="ToolIcon_type_button"
  462. data-testid="debug-forward"
  463. aria-label="Move forward"
  464. type="button"
  465. onClick={reset}
  466. >
  467. <div
  468. className="ToolIcon__icon"
  469. aria-hidden="true"
  470. aria-disabled="false"
  471. >
  472. {CloseIcon}
  473. </div>
  474. </button>
  475. <button
  476. className="ToolIcon_type_button"
  477. data-testid="debug-backward"
  478. aria-label="Move backward"
  479. type="button"
  480. onClick={moveForward}
  481. >
  482. <div
  483. className="ToolIcon__icon"
  484. aria-hidden="true"
  485. aria-disabled="false"
  486. >
  487. <ArrowheadArrowIcon />
  488. </div>
  489. </button>
  490. </>
  491. );
  492. };
  493. interface DebugCanvasProps {
  494. appState: AppState;
  495. scale: number;
  496. }
  497. const DebugCanvas = React.forwardRef<HTMLCanvasElement, DebugCanvasProps>(
  498. ({ appState, scale }, ref) => {
  499. const { width, height } = appState;
  500. return (
  501. <canvas
  502. style={{
  503. width,
  504. height,
  505. position: "absolute",
  506. zIndex: 2,
  507. pointerEvents: "none",
  508. }}
  509. width={width * scale}
  510. height={height * scale}
  511. ref={ref}
  512. >
  513. Debug Canvas
  514. </canvas>
  515. );
  516. },
  517. );
  518. export default DebugCanvas;