diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index e9a8040ab..fd7396dd0 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -878,6 +878,7 @@ const ExcalidrawWrapper = () => { theme={appTheme} setTheme={(theme) => setAppTheme(theme)} refresh={() => forceRefresh((prev) => !prev)} + excalidrawAPI={excalidrawAPI} /> { { type: string; listener: EventListener } > >(new WeakMap()); - const dataRef = useRef([]); + const replayRef = useRef>( + new Map(), + ); + const dataRef = useRef(null); React.useEffect(() => { - dataRef.current.push({ - time: new Date().getTime(), - type: "start", - localStorage: JSON.parse(JSON.stringify(window.localStorage)), - dimensions: { - outerWidth: window.outerWidth, - outerHeight: window.outerHeight, - }, - chromeVersion: window.navigator.userAgent - .split(" ") - .find((v) => v.startsWith("Chrome/")) - ?.substring(7), - }); + /** + * Run the event listeners for the type + */ + window.runReplay = (type: string, payload: any) => { + replayRef.current + .get(type) + ?.forEach((listener) => + Object.hasOwn(listener, "handleEvent") + ? (listener as EventListenerObject).handleEvent(payload) + : (listener as EventListener)(payload), + ); + }; + + /** + * Access to the recorded data + */ + window.getRecordedDataRef = (): object[] | null => dataRef.current; + window.setRecordedDataRef = (data: object[] | null) => { + dataRef.current = data; + }; Window.prototype._removeEventListener = Window.prototype.removeEventListener; + /** + * removeEventListener + */ window.removeEventListener = function ( type: K, listener: EventListenerOrEventListenerObject, @@ -1182,12 +1196,25 @@ const ExcalidrawRecorderWrapper = ({ children }: React.PropsWithChildren) => { if (existing) { window._removeEventListener(type, existing.listener, options); listenerRef.current.delete(listener); + const eventListeners = replayRef.current.get(type); + if (eventListeners) { + const index = eventListeners.indexOf(existing.listener); + if (index !== -1) { + eventListeners.splice(index, 1); + if (eventListeners.length === 0) { + replayRef.current.delete(type); + } + } + } } else { window._removeEventListener(type, listener, options); } }; Window.prototype._addEventListener = Window.prototype.addEventListener; + /** + * addEventListener + */ window.addEventListener = function ( type: K, listener: EventListenerOrEventListenerObject, @@ -1198,7 +1225,7 @@ const ExcalidrawRecorderWrapper = ({ children }: React.PropsWithChildren) => { if (!existing || existing?.type !== type) { wrappedListener = function (...args) { - dataRef.current.push({ + dataRef.current?.push({ time: new Date().getTime(), type: "event", name: type, @@ -1217,6 +1244,11 @@ const ExcalidrawRecorderWrapper = ({ children }: React.PropsWithChildren) => { } listenerRef.current.set(listener, { type, listener: wrappedListener }); + if (replayRef.current.has(type)) { + replayRef.current.get(type)?.push(listener); + } else { + replayRef.current.set(type, [listener]); + } window._addEventListener(type, wrappedListener, options); }; @@ -1230,14 +1262,6 @@ const ExcalidrawRecorderWrapper = ({ children }: React.PropsWithChildren) => { }; }, []); - React.useEffect(() => { - const handle = setInterval(() => { - console.log(dataRef.current); - }, 10000); - - return () => clearInterval(handle); - }, []); - return <>{children}; }; diff --git a/excalidraw-app/components/AppMainMenu.tsx b/excalidraw-app/components/AppMainMenu.tsx index cd0aca268..8706bc2ce 100644 --- a/excalidraw-app/components/AppMainMenu.tsx +++ b/excalidraw-app/components/AppMainMenu.tsx @@ -2,13 +2,21 @@ import { loginIcon, ExcalLogo, eyeIcon, + pngIcon, } from "@excalidraw/excalidraw/components/icons"; import { MainMenu } from "@excalidraw/excalidraw/index"; import React from "react"; -import { isDevEnv } from "@excalidraw/common"; +import { getVersion, isDevEnv } from "@excalidraw/common"; +import { fileOpen, fileSave } from "@excalidraw/excalidraw/data/filesystem"; +import superjson from "superjson"; +import { getDefaultAppState } from "@excalidraw/excalidraw/appState"; import type { Theme } from "@excalidraw/element/types"; +import type { + AppState, + ExcalidrawImperativeAPI, +} from "@excalidraw/excalidraw/types"; import { LanguageList } from "../app-language/LanguageList"; import { isExcalidrawPlusSignedUser } from "../app_constants"; @@ -22,6 +30,7 @@ export const AppMainMenu: React.FC<{ theme: Theme | "system"; setTheme: (theme: Theme | "system") => void; refresh: () => void; + excalidrawAPI: ExcalidrawImperativeAPI | null; }> = React.memo((props) => { return ( @@ -60,21 +69,134 @@ export const AppMainMenu: React.FC<{ {isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"} {isDevEnv() && ( - { - if (window.visualDebug) { - delete window.visualDebug; - saveDebugState({ enabled: false }); - } else { - window.visualDebug = { data: [] }; - saveDebugState({ enabled: true }); - } - props?.refresh(); - }} - > - Visual Debug - + <> + { + if (window.visualDebug) { + delete window.visualDebug; + saveDebugState({ enabled: false }); + } else { + window.visualDebug = { data: [] }; + saveDebugState({ enabled: true }); + } + props?.refresh(); + }} + > + Visual Debug + + {props.excalidrawAPI && ( + <> + + { + const blob = await fileOpen({ + description: "Excalidraw test case recording", + extensions: ["json"], + }); + const text = await blob.text(); + const recording = superjson.parse(text); + + window.setRecordedDataRef(null); + + const start = recording.shift(); + + window.resizeTo( + start.dimensions.innerWidth, + start.dimensions.innerHeight, + ); + if ( + Math.abs(start.dimensions.innerWidth - window.innerWidth) > + 1 || + Math.abs( + start.dimensions.innerHeight - window.innerHeight, + ) > 1 + ) { + console.error("Window dimensions do not match"); + return; + } + + props.excalidrawAPI!.resetScene(); + props.excalidrawAPI!.updateScene({ + elements: superjson.parse(start.scene), + appState: { + ...getDefaultAppState(), + ...superjson.parse(start.state), + }, + }); + + let lastTime = start.time; + for (const item of recording) { + if (item.type === "event") { + const { time, type, name, ...rest } = item; + const delay = time - lastTime; + lastTime = time; + await new Promise((resolve) => + setTimeout(resolve, delay), + ); + console.log(type, name, rest); + window.runReplay(name, rest); + } + } + }} + > + Run Recording... + + { + window.setRecordedDataRef([ + { + time: new Date().getTime(), + type: "start", + excalidrawVersion: getVersion(), + dimensions: { + innerWidth: window.innerWidth, + innerHeight: window.innerHeight, + }, + chromeVersion: window.navigator.userAgent + .split(" ") + .find((v) => v.startsWith("Chrome/")) + ?.substring(7), + state: superjson.stringify( + props.excalidrawAPI!.getAppState(), + ), + scene: superjson.stringify( + props.excalidrawAPI!.getSceneElementsIncludingDeleted(), + ), + }, + ]); + }} + > + Start Recording + + { + const blob = new Blob( + [superjson.stringify(window.getRecordedDataRef())], + { + type: "text/json", + }, + ); + + try { + await fileSave(blob, { + name: `testcase-${new Date().getTime()}${Math.floor( + Math.random() * 10000, + )}`, + extension: "json", + description: "Excalidraw test case recording", + }); + } catch (error) {} + }} + > + Save Recording... + + + )} + )} object[] | null; + setRecordedDataRef: (data: object[] | null) => void; + runReplay: (type: string, payload: any) => void; } } diff --git a/yarn.lock b/yarn.lock index 21374749a..8337afee3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4189,6 +4189,13 @@ convert-source-map@^2.0.0: resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== +copy-anything@^3.0.2: + version "3.0.5" + resolved "https://registry.yarnpkg.com/copy-anything/-/copy-anything-3.0.5.tgz#2d92dce8c498f790fa7ad16b01a1ae5a45b020a0" + integrity sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w== + dependencies: + is-what "^4.1.8" + core-js-compat@^3.38.0, core-js-compat@^3.40.0: version "3.41.0" resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.41.0.tgz#4cdfce95f39a8f27759b667cf693d96e5dda3d17" @@ -6501,6 +6508,11 @@ is-weakset@^2.0.3: call-bound "^1.0.3" get-intrinsic "^1.2.6" +is-what@^4.1.8: + version "4.1.16" + resolved "https://registry.yarnpkg.com/is-what/-/is-what-4.1.16.tgz#1ad860a19da8b4895ad5495da3182ce2acdd7a6f" + integrity sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A== + isarray@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" @@ -8941,6 +8953,13 @@ stylis@^4.1.3: resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.3.6.tgz#7c7b97191cb4f195f03ecab7d52f7902ed378320" integrity sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ== +superjson@2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/superjson/-/superjson-2.2.2.tgz#9d52bf0bf6b5751a3c3472f1292e714782ba3173" + integrity sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q== + dependencies: + copy-anything "^3.0.2" + supports-color@^5.3.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"