DebugCanvas.tsx 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  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 { throttleRAF } from "@excalidraw/common";
  12. import { useCallback, useImperativeHandle, useRef } from "react";
  13. import {
  14. isLineSegment,
  15. type GlobalPoint,
  16. type LineSegment,
  17. } from "@excalidraw/math";
  18. import { isCurve } from "@excalidraw/math/curve";
  19. import type { DebugElement } from "@excalidraw/excalidraw/visualdebug";
  20. import type { Curve } from "@excalidraw/math";
  21. import { STORAGE_KEYS } from "../app_constants";
  22. const renderLine = (
  23. context: CanvasRenderingContext2D,
  24. zoom: number,
  25. segment: LineSegment<GlobalPoint>,
  26. color: string,
  27. ) => {
  28. context.save();
  29. context.strokeStyle = color;
  30. context.beginPath();
  31. context.moveTo(segment[0][0] * zoom, segment[0][1] * zoom);
  32. context.lineTo(segment[1][0] * zoom, segment[1][1] * zoom);
  33. context.stroke();
  34. context.restore();
  35. };
  36. const renderCubicBezier = (
  37. context: CanvasRenderingContext2D,
  38. zoom: number,
  39. [start, control1, control2, end]: Curve<GlobalPoint>,
  40. color: string,
  41. ) => {
  42. context.save();
  43. context.strokeStyle = color;
  44. context.beginPath();
  45. context.moveTo(start[0] * zoom, start[1] * zoom);
  46. context.bezierCurveTo(
  47. control1[0] * zoom,
  48. control1[1] * zoom,
  49. control2[0] * zoom,
  50. control2[1] * zoom,
  51. end[0] * zoom,
  52. end[1] * zoom,
  53. );
  54. context.stroke();
  55. context.restore();
  56. };
  57. const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
  58. context.strokeStyle = "#888";
  59. context.save();
  60. context.beginPath();
  61. context.moveTo(-10 * zoom, -10 * zoom);
  62. context.lineTo(10 * zoom, 10 * zoom);
  63. context.moveTo(10 * zoom, -10 * zoom);
  64. context.lineTo(-10 * zoom, 10 * zoom);
  65. context.stroke();
  66. context.save();
  67. };
  68. const render = (
  69. frame: DebugElement[],
  70. context: CanvasRenderingContext2D,
  71. appState: AppState,
  72. ) => {
  73. frame.forEach((el: DebugElement) => {
  74. switch (true) {
  75. case isLineSegment(el.data):
  76. renderLine(
  77. context,
  78. appState.zoom.value,
  79. el.data as LineSegment<GlobalPoint>,
  80. el.color,
  81. );
  82. break;
  83. case isCurve(el.data):
  84. renderCubicBezier(
  85. context,
  86. appState.zoom.value,
  87. el.data as Curve<GlobalPoint>,
  88. el.color,
  89. );
  90. break;
  91. default:
  92. throw new Error(`Unknown element type ${JSON.stringify(el)}`);
  93. }
  94. });
  95. };
  96. const _debugRenderer = (
  97. canvas: HTMLCanvasElement,
  98. appState: AppState,
  99. scale: number,
  100. refresh: () => void,
  101. ) => {
  102. const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
  103. canvas,
  104. scale,
  105. );
  106. if (appState.height !== canvas.height || appState.width !== canvas.width) {
  107. refresh();
  108. }
  109. const context = bootstrapCanvas({
  110. canvas,
  111. scale,
  112. normalizedWidth,
  113. normalizedHeight,
  114. viewBackgroundColor: "transparent",
  115. });
  116. // Apply zoom
  117. context.save();
  118. context.translate(
  119. appState.scrollX * appState.zoom.value,
  120. appState.scrollY * appState.zoom.value,
  121. );
  122. renderOrigin(context, appState.zoom.value);
  123. if (
  124. window.visualDebug?.currentFrame &&
  125. window.visualDebug?.data &&
  126. window.visualDebug.data.length > 0
  127. ) {
  128. // Render only one frame
  129. const [idx] = debugFrameData();
  130. render(window.visualDebug.data[idx], context, appState);
  131. } else {
  132. // Render all debug frames
  133. window.visualDebug?.data.forEach((frame) => {
  134. render(frame, context, appState);
  135. });
  136. }
  137. if (window.visualDebug) {
  138. window.visualDebug!.data =
  139. window.visualDebug?.data.map((frame) =>
  140. frame.filter((el) => el.permanent),
  141. ) ?? [];
  142. }
  143. };
  144. const debugFrameData = (): [number, number] => {
  145. const currentFrame = window.visualDebug?.currentFrame ?? 0;
  146. const frameCount = window.visualDebug?.data.length ?? 0;
  147. if (frameCount > 0) {
  148. return [currentFrame % frameCount, window.visualDebug?.currentFrame ?? 0];
  149. }
  150. return [0, 0];
  151. };
  152. export const saveDebugState = (debug: { enabled: boolean }) => {
  153. try {
  154. localStorage.setItem(
  155. STORAGE_KEYS.LOCAL_STORAGE_DEBUG,
  156. JSON.stringify(debug),
  157. );
  158. } catch (error: any) {
  159. console.error(error);
  160. }
  161. };
  162. export const debugRenderer = throttleRAF(
  163. (
  164. canvas: HTMLCanvasElement,
  165. appState: AppState,
  166. scale: number,
  167. refresh: () => void,
  168. ) => {
  169. _debugRenderer(canvas, appState, scale, refresh);
  170. },
  171. { trailing: true },
  172. );
  173. export const loadSavedDebugState = () => {
  174. let debug;
  175. try {
  176. const savedDebugState = localStorage.getItem(
  177. STORAGE_KEYS.LOCAL_STORAGE_DEBUG,
  178. );
  179. if (savedDebugState) {
  180. debug = JSON.parse(savedDebugState) as { enabled: boolean };
  181. }
  182. } catch (error: any) {
  183. console.error(error);
  184. }
  185. return debug ?? { enabled: false };
  186. };
  187. export const isVisualDebuggerEnabled = () =>
  188. Array.isArray(window.visualDebug?.data);
  189. export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
  190. const moveForward = useCallback(() => {
  191. if (
  192. !window.visualDebug?.currentFrame ||
  193. isNaN(window.visualDebug?.currentFrame ?? -1)
  194. ) {
  195. window.visualDebug!.currentFrame = 0;
  196. }
  197. window.visualDebug!.currentFrame += 1;
  198. onChange();
  199. }, [onChange]);
  200. const moveBackward = useCallback(() => {
  201. if (
  202. !window.visualDebug?.currentFrame ||
  203. isNaN(window.visualDebug?.currentFrame ?? -1) ||
  204. window.visualDebug?.currentFrame < 1
  205. ) {
  206. window.visualDebug!.currentFrame = 1;
  207. }
  208. window.visualDebug!.currentFrame -= 1;
  209. onChange();
  210. }, [onChange]);
  211. const reset = useCallback(() => {
  212. window.visualDebug!.currentFrame = undefined;
  213. onChange();
  214. }, [onChange]);
  215. const trashFrames = useCallback(() => {
  216. if (window.visualDebug) {
  217. window.visualDebug.currentFrame = undefined;
  218. window.visualDebug.data = [];
  219. }
  220. onChange();
  221. }, [onChange]);
  222. return (
  223. <>
  224. <button
  225. className="ToolIcon_type_button"
  226. data-testid="debug-forward"
  227. aria-label="Move forward"
  228. type="button"
  229. onClick={trashFrames}
  230. >
  231. <div
  232. className="ToolIcon__icon"
  233. aria-hidden="true"
  234. aria-disabled="false"
  235. >
  236. {TrashIcon}
  237. </div>
  238. </button>
  239. <button
  240. className="ToolIcon_type_button"
  241. data-testid="debug-forward"
  242. aria-label="Move forward"
  243. type="button"
  244. onClick={moveBackward}
  245. >
  246. <div
  247. className="ToolIcon__icon"
  248. aria-hidden="true"
  249. aria-disabled="false"
  250. >
  251. <ArrowheadArrowIcon flip />
  252. </div>
  253. </button>
  254. <button
  255. className="ToolIcon_type_button"
  256. data-testid="debug-forward"
  257. aria-label="Move forward"
  258. type="button"
  259. onClick={reset}
  260. >
  261. <div
  262. className="ToolIcon__icon"
  263. aria-hidden="true"
  264. aria-disabled="false"
  265. >
  266. {CloseIcon}
  267. </div>
  268. </button>
  269. <button
  270. className="ToolIcon_type_button"
  271. data-testid="debug-backward"
  272. aria-label="Move backward"
  273. type="button"
  274. onClick={moveForward}
  275. >
  276. <div
  277. className="ToolIcon__icon"
  278. aria-hidden="true"
  279. aria-disabled="false"
  280. >
  281. <ArrowheadArrowIcon />
  282. </div>
  283. </button>
  284. </>
  285. );
  286. };
  287. interface DebugCanvasProps {
  288. appState: AppState;
  289. scale: number;
  290. ref?: React.Ref<HTMLCanvasElement>;
  291. }
  292. const DebugCanvas = ({ appState, scale, ref }: DebugCanvasProps) => {
  293. const { width, height } = appState;
  294. const canvasRef = useRef<HTMLCanvasElement>(null);
  295. useImperativeHandle<HTMLCanvasElement | null, HTMLCanvasElement | null>(
  296. ref,
  297. () => canvasRef.current,
  298. [canvasRef],
  299. );
  300. return (
  301. <canvas
  302. style={{
  303. width,
  304. height,
  305. position: "absolute",
  306. zIndex: 2,
  307. pointerEvents: "none",
  308. }}
  309. width={width * scale}
  310. height={height * scale}
  311. ref={canvasRef}
  312. >
  313. Debug Canvas
  314. </canvas>
  315. );
  316. };
  317. export default DebugCanvas;