This commit is contained in:
Mark Tolmacs 2025-05-23 13:46:49 +02:00
parent 624500b091
commit c71ccaf17a
No known key found for this signature in database
5 changed files with 208 additions and 39 deletions

View File

@ -878,6 +878,7 @@ const ExcalidrawWrapper = () => {
theme={appTheme}
setTheme={(theme) => setAppTheme(theme)}
refresh={() => forceRefresh((prev) => !prev)}
excalidrawAPI={excalidrawAPI}
/>
<AppWelcomeScreen
onCollabDialogOpen={onCollabDialogOpen}
@ -1154,25 +1155,38 @@ const ExcalidrawRecorderWrapper = ({ children }: React.PropsWithChildren) => {
{ type: string; listener: EventListener }
>
>(new WeakMap());
const dataRef = useRef<object[]>([]);
const replayRef = useRef<Map<string, EventListenerOrEventListenerObject[]>>(
new Map(),
);
const dataRef = useRef<object[] | null>(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 <K extends keyof WindowEventMap>(
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 <K extends keyof WindowEventMap>(
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}</>;
};

View File

@ -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 (
<MainMenu>
@ -60,21 +69,134 @@ export const AppMainMenu: React.FC<{
{isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
</MainMenu.ItemLink>
{isDevEnv() && (
<MainMenu.Item
icon={eyeIcon}
onClick={() => {
if (window.visualDebug) {
delete window.visualDebug;
saveDebugState({ enabled: false });
} else {
window.visualDebug = { data: [] };
saveDebugState({ enabled: true });
}
props?.refresh();
}}
>
Visual Debug
</MainMenu.Item>
<>
<MainMenu.Item
icon={eyeIcon}
onClick={() => {
if (window.visualDebug) {
delete window.visualDebug;
saveDebugState({ enabled: false });
} else {
window.visualDebug = { data: [] };
saveDebugState({ enabled: true });
}
props?.refresh();
}}
>
Visual Debug
</MainMenu.Item>
{props.excalidrawAPI && (
<>
<MainMenu.Separator />
<MainMenu.Item
icon={pngIcon}
onClick={async () => {
const blob = await fileOpen({
description: "Excalidraw test case recording",
extensions: ["json"],
});
const text = await blob.text();
const recording = superjson.parse<any>(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<AppState>(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...
</MainMenu.Item>
<MainMenu.Item
icon={pngIcon}
onClick={async () => {
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
</MainMenu.Item>
<MainMenu.Item
icon={pngIcon}
onClick={async () => {
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...
</MainMenu.Item>
</>
)}
</>
)}
<MainMenu.Separator />
<MainMenu.DefaultItems.ToggleTheme

View File

@ -36,6 +36,7 @@
"react": "19.0.0",
"react-dom": "19.0.0",
"socket.io-client": "4.7.2",
"superjson": "2.2.2",
"vite-plugin-html": "3.2.2"
},
"prettier": "@excalidraw/prettier-config",

View File

@ -11427,6 +11427,9 @@ declare global {
};
_addEventListener: typeof window.addEventListener;
_removeEventListener: typeof window.removeEventListener;
getRecordedDataRef: () => object[] | null;
setRecordedDataRef: (data: object[] | null) => void;
runReplay: (type: string, payload: any) => void;
}
}

View File

@ -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"