Compare commits

..

1 Commits

Author SHA1 Message Date
Mark Tolmacs b3a58b6c2d feat: Console log overlay preference
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-04-02 10:46:02 +00:00
13 changed files with 264 additions and 519 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24-bullseye
FROM node:18-bullseye
# Vite wants to open the browser using `open`, so we
# need to install those utils.
+2 -2
View File
@@ -1,4 +1,4 @@
FROM --platform=${BUILDPLATFORM} node:24 AS build
FROM --platform=${BUILDPLATFORM} node:18 AS build
WORKDIR /opt/node_app
@@ -13,7 +13,7 @@ ARG NODE_ENV=production
RUN npm_config_target_arch=${TARGETARCH} yarn build:app:docker
FROM nginx:1.27-alpine
FROM --platform=${TARGETPLATFORM} nginx:1.27-alpine
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
+2 -2
View File
@@ -97,8 +97,8 @@ const config = {
href: "https://discord.gg/UexuTaE",
},
{
label: "𝕏",
href: "https://x.com/excalidraw",
label: "Twitter",
href: "https://twitter.com/excalidraw",
},
{
label: "Linkedin",
+2
View File
@@ -136,6 +136,7 @@ import { useHandleAppTheme } from "./useHandleAppTheme";
import { getPreferredLanguage } from "./app-language/language-detector";
import { useAppLangCode } from "./app-language/language-state";
import DebugCanvas, {
ConsoleLogger,
debugRenderer,
isVisualDebuggerEnabled,
loadSavedDebugState,
@@ -1261,6 +1262,7 @@ const ExcalidrawWrapper = () => {
ref={debugCanvasRef}
/>
)}
{isVisualDebuggerEnabled() && <ConsoleLogger />}
</Excalidraw>
</div>
);
+1
View File
@@ -42,6 +42,7 @@ export const STORAGE_KEYS = {
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
LOCAL_STORAGE_THEME: "excalidraw-theme",
LOCAL_STORAGE_DEBUG: "excalidraw-debug",
LOCAL_STORAGE_DEBUG_CONSOLE: "excalidraw-debug-console",
VERSION_DATA_STATE: "version-dataState",
VERSION_FILES: "version-files",
+32 -3
View File
@@ -4,7 +4,8 @@ import {
eyeIcon,
} from "@excalidraw/excalidraw/components/icons";
import { MainMenu } from "@excalidraw/excalidraw/index";
import React from "react";
import DropdownMenuItemCheckbox from "@excalidraw/excalidraw/components/dropdownMenu/DropdownMenuItemCheckbox";
import React, { useState } from "react";
import { isDevEnv } from "@excalidraw/common";
@@ -13,7 +14,29 @@ import type { Theme } from "@excalidraw/element/types";
import { LanguageList } from "../app-language/LanguageList";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { saveDebugState } from "./DebugCanvas";
import {
isVisualDebuggerEnabled,
loadConsoleLoggerState,
saveDebugState,
setConsoleLoggerEnabled,
} from "./DebugCanvas";
const ConsoleLoggerToggle = () => {
const [checked, setChecked] = useState(() => loadConsoleLoggerState());
return (
<DropdownMenuItemCheckbox
checked={checked}
onSelect={(event) => {
const next = !checked;
setChecked(next);
setConsoleLoggerEnabled(next);
event.preventDefault();
}}
>
Show console log overlay
</DropdownMenuItemCheckbox>
);
};
export const AppMainMenu: React.FC<{
onCollabDialogOpen: () => any;
@@ -77,7 +100,13 @@ export const AppMainMenu: React.FC<{
</MainMenu.Item>
)}
<MainMenu.Separator />
<MainMenu.DefaultItems.Preferences />
<MainMenu.DefaultItems.Preferences
additionalItems={
isDevEnv() && isVisualDebuggerEnabled() ? (
<ConsoleLoggerToggle />
) : null
}
/>
<MainMenu.DefaultItems.ToggleTheme
allowSystemTheme
theme={props.theme}
+208 -1
View File
@@ -9,7 +9,7 @@ import {
} from "@excalidraw/excalidraw/renderer/helpers";
import { type AppState } from "@excalidraw/excalidraw/types";
import { arrayToMap, throttleRAF } from "@excalidraw/common";
import { useCallback } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
getGlobalFixedPointForBindableElement,
@@ -435,6 +435,34 @@ export const loadSavedDebugState = () => {
export const isVisualDebuggerEnabled = () =>
Array.isArray(window.visualDebug?.data);
export const loadConsoleLoggerState = (): boolean => {
try {
const raw = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_DEBUG_CONSOLE);
if (raw !== null) {
return JSON.parse(raw) === true;
}
} catch {}
return false;
};
export const saveConsoleLoggerState = (enabled: boolean) => {
try {
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_DEBUG_CONSOLE,
JSON.stringify(enabled),
);
} catch {}
};
const CONSOLE_LOGGER_TOGGLE_EVENT = "excalidraw-debug-console-toggle";
export const setConsoleLoggerEnabled = (enabled: boolean) => {
saveConsoleLoggerState(enabled);
window.dispatchEvent(
new CustomEvent<boolean>(CONSOLE_LOGGER_TOGGLE_EVENT, { detail: enabled }),
);
};
export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
const moveForward = useCallback(() => {
if (
@@ -459,6 +487,7 @@ export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
}, [onChange]);
const reset = useCallback(() => {
window.visualDebug!.currentFrame = undefined;
_clearLogsCallback?.();
onChange();
}, [onChange]);
const trashFrames = useCallback(() => {
@@ -466,6 +495,7 @@ export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
window.visualDebug.currentFrame = undefined;
window.visualDebug.data = [];
}
_clearLogsCallback?.();
onChange();
}, [onChange]);
@@ -563,4 +593,181 @@ const DebugCanvas = React.forwardRef<HTMLCanvasElement, DebugCanvasProps>(
},
);
type LogLevel = "log" | "info" | "warn" | "error";
interface LogEntry {
id: number;
level: LogLevel;
message: string;
timestamp: number;
}
const LOG_COLORS: Record<LogLevel, string> = {
log: "rgba(220,220,220,0.9)",
info: "rgba(100,180,255,0.9)",
warn: "rgba(255,200,60,0.9)",
error: "rgba(255,90,90,0.9)",
};
const MAX_LOGS = 500;
let logIdCounter = 0;
let _clearLogsCallback: (() => void) | null = null;
export const ConsoleLogger = () => {
const [enabled, setEnabled] = useState(() => loadConsoleLoggerState());
const [logs, setLogs] = useState<LogEntry[]>([]);
const logsRef = useRef<LogEntry[]>([]);
const scrollRef = useRef<HTMLDivElement>(null);
const dragState = useRef<{ startY: number; startScrollTop: number } | null>(
null,
);
const isDragging = useRef(false);
useEffect(() => {
const handler = (e: Event) => {
setEnabled((e as CustomEvent<boolean>).detail);
};
window.addEventListener(CONSOLE_LOGGER_TOGGLE_EVENT, handler);
return () => {
window.removeEventListener(CONSOLE_LOGGER_TOGGLE_EVENT, handler);
};
}, []);
useEffect(() => {
_clearLogsCallback = () => {
logsRef.current = [];
setLogs([]);
};
return () => {
_clearLogsCallback = null;
};
}, []);
useEffect(() => {
const originals: Record<LogLevel, (...args: unknown[]) => void> = {
// eslint-disable-next-line no-console
log: console.log.bind(console),
info: console.info.bind(console),
warn: console.warn.bind(console),
error: console.error.bind(console),
};
const patch = (level: LogLevel) => {
// eslint-disable-next-line no-console
console[level] = (...args: unknown[]) => {
originals[level](...args);
const message = args
.map((a) =>
typeof a === "object" ? JSON.stringify(a, null, 0) : String(a),
)
.join(" ");
const entry: LogEntry = {
id: ++logIdCounter,
level,
message,
timestamp: Date.now(),
};
logsRef.current = [...logsRef.current, entry].slice(-MAX_LOGS);
setLogs([...logsRef.current]);
if (!isDragging.current && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
};
};
(["log", "info", "warn", "error"] as LogLevel[]).forEach(patch);
return () => {
(["log", "info", "warn", "error"] as LogLevel[]).forEach((level) => {
// eslint-disable-next-line no-console
console[level] = originals[level];
});
};
}, []);
const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!scrollRef.current) {
return;
}
dragState.current = {
startY: e.clientY,
startScrollTop: scrollRef.current.scrollTop,
};
scrollRef.current.setPointerCapture(e.pointerId);
isDragging.current = true;
e.preventDefault();
}, []);
const onPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!dragState.current || !scrollRef.current) {
return;
}
const delta = dragState.current.startY - e.clientY;
scrollRef.current.scrollTop = dragState.current.startScrollTop + delta;
e.preventDefault();
}, []);
const onPointerUp = useCallback(() => {
dragState.current = null;
isDragging.current = false;
}, []);
if (!enabled || logs.length === 0) {
return null;
}
return (
<div
ref={scrollRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
style={{
position: "fixed",
top: 8,
right: 8,
zIndex: 9999,
maxWidth: 420,
maxHeight: "60vh",
overflowY: "auto",
display: "flex",
flexDirection: "column",
gap: 2,
pointerEvents: "all",
cursor: "grab",
userSelect: "none",
scrollbarWidth: "none",
}}
>
{logs.map((entry) => (
<div
key={entry.id}
style={{
background: "rgba(18,18,20,0.55)",
backdropFilter: "blur(8px) saturate(1.4)",
WebkitBackdropFilter: "blur(8px) saturate(1.4)",
borderLeft: `3px solid ${LOG_COLORS[entry.level]}`,
borderRadius: 4,
padding: "2px 8px",
fontFamily: "monospace",
fontSize: 11,
lineHeight: 1.5,
color: LOG_COLORS[entry.level],
wordBreak: "break-all",
whiteSpace: "pre-wrap",
opacity: 0.95,
boxShadow: "0 1px 4px rgba(0,0,0,0.35)",
}}
>
<span style={{ opacity: 0.5, marginRight: 6 }}>
{entry.level.toUpperCase()}
</span>
{entry.message}
</div>
))}
</div>
);
};
export default DebugCanvas;
+4 -6
View File
@@ -1943,9 +1943,9 @@ export const calculateFixedPointForElbowArrowBinding = (
return {
fixedPoint: normalizeFixedPoint([
(nonRotatedSnappedGlobalPoint[0] - hoveredElement.x) /
Math.max(hoveredElement.width, PRECISION),
hoveredElement.width,
(nonRotatedSnappedGlobalPoint[1] - hoveredElement.y) /
Math.max(hoveredElement.height, PRECISION),
hoveredElement.height,
]),
};
};
@@ -1976,11 +1976,9 @@ export const calculateFixedPointForNonElbowArrowBinding = (
// Calculate the ratio relative to the element's bounds
const fixedPointX =
(nonRotatedPoint[0] - hoveredElement.x) /
Math.max(hoveredElement.width, PRECISION);
(nonRotatedPoint[0] - hoveredElement.x) / hoveredElement.width;
const fixedPointY =
(nonRotatedPoint[1] - hoveredElement.y) /
Math.max(hoveredElement.height, PRECISION);
(nonRotatedPoint[1] - hoveredElement.y) / hoveredElement.height;
return {
fixedPoint: normalizeFixedPoint([fixedPointX, fixedPointY]),
+2 -2
View File
@@ -2124,8 +2124,8 @@ const normalizeArrowElementUpdate = (
offsetY < -MAX_POS ||
offsetY > MAX_POS ||
offsetX + points[points.length - 1][0] < -MAX_POS ||
offsetX + points[points.length - 1][0] > MAX_POS ||
offsetY + points[points.length - 1][1] < -MAX_POS ||
offsetY + points[points.length - 1][0] > MAX_POS ||
offsetX + points[points.length - 1][1] < -MAX_POS ||
offsetY + points[points.length - 1][1] > MAX_POS
) {
console.error(
+2 -146
View File
@@ -603,55 +603,6 @@ const YOUTUBE_VIDEO_STATES = new Map<
ValueOf<typeof YOUTUBE_STATES>
>();
const isGoogleDrivePreviewLink = (link: string | null | undefined) => {
if (!link) {
return false;
}
try {
const url = new URL(link);
return (
url.hostname === "drive.google.com" &&
/^\/file\/d\/[^/]+\/preview\/?$/.test(url.pathname)
);
} catch {
return false;
}
};
const isGoogleDriveEmbeddableElement = (
element: ExcalidrawElement | null | undefined,
) => {
if (!isEmbeddableElement(element)) {
return false;
}
const embedLink = getEmbedLink(toValidURL(element.link || ""));
return (
embedLink?.type === "video" && isGoogleDrivePreviewLink(embedLink.link)
);
};
const EMBEDDABLE_CANVAS_GUARD_CLASS = "excalidraw__embeddable-canvas-guard";
const EMBEDDABLE_CANVAS_GUARDS = [
{
key: "top",
style: { top: 0, left: 0, right: 0, height: "33.333%" },
},
{
key: "bottom",
style: { bottom: 0, left: 0, right: 0, height: "33.333%" },
},
{
key: "left",
style: { top: "33.333%", bottom: "33.333%", left: 0, width: "33.333%" },
},
{
key: "right",
style: { top: "33.333%", bottom: "33.333%", right: 0, width: "33.333%" },
},
] as const;
let IS_PLAIN_PASTE = false;
let IS_PLAIN_PASTE_TIMER = 0;
let PLAIN_PASTE_TOAST_SHOWN = false;
@@ -1332,46 +1283,6 @@ class App extends React.Component<AppProps, AppState> {
return this.iFrameRefs.get(element.id);
}
private deactivateActiveEmbeddable = () => {
if (this.state.activeEmbeddable) {
flushSync(() => {
this.setState({ activeEmbeddable: null });
});
}
};
private handleEmbeddableCanvasGuardPointerDown = (
event: React.PointerEvent<HTMLDivElement>,
) => {
this.deactivateActiveEmbeddable();
this.handleCanvasPointerDown(event);
};
private handleEmbeddableCanvasGuardPointerMove = (
event: React.PointerEvent<HTMLDivElement>,
) => {
this.lastViewportPosition.x = event.clientX;
this.lastViewportPosition.y = event.clientY;
this.deactivateActiveEmbeddable();
};
private handleEmbeddableCanvasGuardTouchStart = (
event: React.TouchEvent<HTMLDivElement>,
) => {
if (event.touches.length >= 2) {
this.deactivateActiveEmbeddable();
}
};
private handleEmbeddableCanvasGuardWheel = (
event: React.WheelEvent<HTMLDivElement>,
) => {
this.lastViewportPosition.x = event.clientX;
this.lastViewportPosition.y = event.clientY;
this.deactivateActiveEmbeddable();
this.handleWheel(event);
};
private handleIframeLikeElementHover = ({
hitElement,
scenePointer,
@@ -1394,25 +1305,12 @@ class App extends React.Component<AppProps, AppState> {
))
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
const isDriveEmbedActivating =
(this.state.viewModeEnabled ||
this.state.activeTool.type === "selection") &&
isGoogleDriveEmbeddableElement(hitElement);
this.setState({
activeEmbeddable: {
element: hitElement,
state: isDriveEmbedActivating ? "active" : "hover",
},
activeEmbeddable: { element: hitElement, state: "hover" },
});
return true;
} else if (this.state.activeEmbeddable?.state === "hover") {
this.setState({ activeEmbeddable: null });
} else if (
this.state.activeEmbeddable?.state === "active" &&
isGoogleDriveEmbeddableElement(this.state.activeEmbeddable.element) &&
this.state.activeEmbeddable.element !== hitElement
) {
this.setState({ activeEmbeddable: null });
}
return false;
};
@@ -1836,8 +1734,6 @@ class App extends React.Component<AppProps, AppState> {
const isHovered =
this.state.activeEmbeddable?.element === el &&
this.state.activeEmbeddable?.state === "hover";
const shouldConstrainDriveInteraction =
isActive && isGoogleDriveEmbeddableElement(el);
return (
<div
@@ -1889,7 +1785,6 @@ class App extends React.Component<AppProps, AppState> {
width: isVisible ? `${el.width}px` : 0,
height: isVisible ? `${el.height}px` : 0,
transform: isVisible ? `rotate(${el.angle}rad)` : "none",
position: "relative",
pointerEvents: isActive
? POINTER_EVENTS.enabled
: POINTER_EVENTS.disabled,
@@ -1932,26 +1827,6 @@ class App extends React.Component<AppProps, AppState> {
/>
)}
</div>
{shouldConstrainDriveInteraction &&
EMBEDDABLE_CANVAS_GUARDS.map(({ key, style }) => (
<div
key={key}
className={EMBEDDABLE_CANVAS_GUARD_CLASS}
onPointerDown={
this.handleEmbeddableCanvasGuardPointerDown
}
onPointerMove={
this.handleEmbeddableCanvasGuardPointerMove
}
onTouchStart={this.handleEmbeddableCanvasGuardTouchStart}
onWheel={this.handleEmbeddableCanvasGuardWheel}
style={{
position: "absolute",
zIndex: 2,
...style,
}}
/>
))}
</div>
</div>
);
@@ -4858,16 +4733,6 @@ class App extends React.Component<AppProps, AppState> {
});
}
if (
this.state.activeEmbeddable &&
(event.ctrlKey ||
event.metaKey ||
event.key === "Control" ||
event.key === "Meta")
) {
this.setState({ activeEmbeddable: null });
}
if (!isInputLike(event.target)) {
if (
(event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
@@ -5746,10 +5611,6 @@ class App extends React.Component<AppProps, AppState> {
selectedElementIds: makeNextSelectedElementIds({}, this.state),
activeEmbeddable: null,
});
} else if (this.state.activeEmbeddable) {
this.setState({
activeEmbeddable: null,
});
}
gesture.initialScale = this.state.zoom.value;
});
@@ -8304,10 +8165,6 @@ class App extends React.Component<AppProps, AppState> {
y: event.clientY,
});
if (gesture.pointers.size >= 2 && this.state.activeEmbeddable) {
this.setState({ activeEmbeddable: null });
}
if (gesture.pointers.size === 2) {
gesture.lastCenter = getCenter(gesture.pointers);
gesture.initialScale = this.state.zoom.value;
@@ -12727,8 +12584,7 @@ class App extends React.Component<AppProps, AppState> {
event.target instanceof HTMLTextAreaElement ||
event.target instanceof HTMLIFrameElement ||
(event.target instanceof HTMLElement &&
(event.target.classList.contains(CLASSES.FRAME_NAME) ||
event.target.classList.contains(EMBEDDABLE_CANVAS_GUARD_CLASS)))
event.target.classList.contains(CLASSES.FRAME_NAME))
)
) {
// prevent zooming the browser (but allow scrolling DOM)
+6 -41
View File
@@ -96,8 +96,6 @@ type RestoredAppState = Omit<
"offsetTop" | "offsetLeft" | "width" | "height"
>;
const MAX_ARROW_PX = 75_000;
export const AllowedExcalidrawActiveTools: Record<
AppState["activeTool"]["type"],
boolean
@@ -469,8 +467,8 @@ export const restoreElement = (
element.endArrowhead === undefined
? "arrow"
: normalizeArrowhead(element.endArrowhead);
const x = element.x as number | undefined;
const y = element.y as number | undefined;
const x: number | undefined = element.x;
const y: number | undefined = element.y;
const points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one
!Array.isArray(element.points) || element.points.length < 2
? [pointFrom(0, 0), pointFrom(element.width, element.height)]
@@ -495,8 +493,8 @@ export const restoreElement = (
startArrowhead,
endArrowhead,
points,
x: x ?? 0,
y: y ?? 0,
x,
y,
elbowed: (element as ExcalidrawArrowElement).elbowed,
...getSizeFromPoints(points),
};
@@ -515,44 +513,12 @@ export const restoreElement = (
})
: restoreElementWithProperties(element as ExcalidrawArrowElement, base);
const normalizedRestoredElement = {
return {
...restoredElement,
...LinearElementEditor.getNormalizeElementPointsAndCoords(
restoredElement,
),
};
// Last resort fix for extremely large arrows
if (
normalizedRestoredElement.width > MAX_ARROW_PX ||
normalizedRestoredElement.height > MAX_ARROW_PX
) {
console.error(
`Removing extremely large arrow ${
normalizedRestoredElement.id
} (type: ${
isElbowArrow(normalizedRestoredElement) ? "elbow" : "simple"
}, width: ${normalizedRestoredElement.width}, height: ${
normalizedRestoredElement.height
}, x: ${normalizedRestoredElement.x}, y: ${
normalizedRestoredElement.y
})`,
);
return {
...normalizedRestoredElement,
x: 0,
y: 0,
width: 100,
height: 100,
points: [
pointFrom<LocalPoint>(0, 0),
pointFrom<LocalPoint>(100, 100),
],
isDeleted: true,
};
}
return normalizedRestoredElement;
}
// generic elements
@@ -700,7 +666,6 @@ export const restoreElements = <T extends ExcalidrawElement>(
const existingElementsMap = existingElements
? arrayToMap(existingElements)
: null;
const restoredElements = syncInvalidIndices(
(targetElements || []).reduce((elements, element) => {
// filtering out selection, which is legacy, no longer kept in elements,
@@ -797,7 +762,7 @@ export const restoreElements = <T extends ExcalidrawElement>(
}
}
// NOTE (mtolmacs): Temporary fix for invalid/self-bound elbow arrows
// NOTE (mtolmacs): Temporary fix for extremely large arrows
// Need to iterate again so we have attached text nodes in elementsMap
return restoredElements.map((element) => {
if (
@@ -1,315 +0,0 @@
import { Excalidraw } from "../index";
import { Pointer } from "./helpers/ui";
import { act, fireEvent, GlobalTestState, render, waitFor } from "./test-utils";
import type { ExcalidrawProps } from "../types";
describe("embeddable interactions", () => {
const h = window.h;
const mouse = new Pointer("mouse");
const renderGoogleDriveEmbeddable = async (
excalidrawProps: Partial<ExcalidrawProps> = {},
) => {
const renderResult = await render(<Excalidraw {...excalidrawProps} />);
const fileId = "1AbCdEfGhIjKlMnOpQrStUvWxYz123456";
const src = `https://drive.google.com/file/d/${fileId}/preview`;
let embeddable!: NonNullable<
ReturnType<typeof h.app.insertEmbeddableElement>
>;
act(() => {
const insertedEmbeddable = h.app.insertEmbeddableElement({
sceneX: 40,
sceneY: 40,
link: `https://drive.google.com/file/d/${fileId}/view?usp=sharing`,
});
if (!insertedEmbeddable) {
throw new Error("Google Drive embeddable not inserted");
}
embeddable = insertedEmbeddable;
});
(
h.app as unknown as {
embedsValidationStatus: Map<string, boolean>;
}
).embedsValidationStatus.set(embeddable.id, true);
act(() => {
h.setState({ width: 1000, height: 1000 });
h.app.scene.triggerUpdate();
});
const getIframe = () => {
const iframe = renderResult.container.querySelector<HTMLIFrameElement>(
"iframe.excalidraw__embeddable",
);
if (!iframe) {
throw new Error("Google Drive iframe not rendered");
}
return iframe;
};
await waitFor(() => {
expect(getIframe().src).toBe(src);
});
return {
...renderResult,
embeddable,
getIframe,
src,
};
};
const renderYouTubeEmbeddable = async (
excalidrawProps: Partial<ExcalidrawProps> = {},
) => {
const renderResult = await render(<Excalidraw {...excalidrawProps} />);
let embeddable!: NonNullable<
ReturnType<typeof h.app.insertEmbeddableElement>
>;
act(() => {
const insertedEmbeddable = h.app.insertEmbeddableElement({
sceneX: 40,
sceneY: 40,
link: "https://www.youtube.com/watch?v=gkGMXY0wekg",
});
if (!insertedEmbeddable) {
throw new Error("YouTube embeddable not inserted");
}
embeddable = insertedEmbeddable;
});
(
h.app as unknown as {
embedsValidationStatus: Map<string, boolean>;
}
).embedsValidationStatus.set(embeddable.id, true);
act(() => {
h.setState({ width: 1000, height: 1000 });
h.app.scene.triggerUpdate();
});
await waitFor(() => {
expect(
renderResult.container.querySelector("iframe.excalidraw__embeddable"),
).not.toBeNull();
});
return {
...renderResult,
embeddable,
};
};
const setActiveEmbeddable = (
embeddable: NonNullable<ReturnType<typeof h.app.insertEmbeddableElement>>,
) => {
act(() => {
h.setState({
activeEmbeddable: { element: embeddable, state: "active" },
});
});
};
it("lets the initial Google Drive video click land in the iframe center", async () => {
const { container, embeddable, getIframe, src } =
await renderGoogleDriveEmbeddable();
mouse.moveTo(
embeddable.x + embeddable.width / 2,
embeddable.y + embeddable.height / 2,
);
await waitFor(() => {
expect(
container.querySelector<HTMLElement>(
".excalidraw__embeddable-container__inner",
)?.style.pointerEvents,
).toBe("all");
expect(h.state.activeEmbeddable?.element.id).toBe(embeddable.id);
expect(h.state.activeEmbeddable?.state).toBe("active");
expect(
container.querySelectorAll(".excalidraw__embeddable-canvas-guard"),
).toHaveLength(4);
expect(
container.querySelector(".excalidraw__embeddable-hint"),
).toBeNull();
expect(getIframe().src).toBe(src);
});
});
it("returns Drive embeddable edge interactions to the canvas", async () => {
const { container, embeddable } = await renderGoogleDriveEmbeddable();
act(() => {
h.setState({
activeEmbeddable: { element: embeddable, state: "active" },
});
});
const guard = container.querySelector<HTMLElement>(
".excalidraw__embeddable-canvas-guard",
);
expect(guard).not.toBeNull();
fireEvent.pointerMove(guard!, {
clientX: embeddable.x + 1,
clientY: embeddable.y + 1,
});
await waitFor(() => {
expect(h.state.activeEmbeddable).toBeNull();
expect(
container.querySelector<HTMLElement>(
".excalidraw__embeddable-container__inner",
)?.style.pointerEvents,
).toBe("none");
});
});
it("deactivates an interactive embeddable on Ctrl/Cmd keydown", async () => {
const { embeddable } = await renderGoogleDriveEmbeddable({
handleKeyboardGlobally: true,
});
mouse.moveTo(
embeddable.x + embeddable.width / 2,
embeddable.y + embeddable.height / 2,
);
await waitFor(() => {
expect(h.state.activeEmbeddable?.state).toBe("active");
});
fireEvent.keyDown(document, {
key: "Control",
ctrlKey: true,
});
await waitFor(() => {
expect(h.state.activeEmbeddable).toBeNull();
});
});
it("handles Ctrl/Cmd wheel on Drive embeddable guards", async () => {
const { container, embeddable } = await renderGoogleDriveEmbeddable();
act(() => {
h.setState({
activeEmbeddable: { element: embeddable, state: "active" },
});
});
const guard = container.querySelector<HTMLElement>(
".excalidraw__embeddable-canvas-guard",
);
expect(guard).not.toBeNull();
const prevZoom = h.state.zoom.value;
fireEvent.wheel(guard!, {
clientX: embeddable.x + 1,
clientY: embeddable.y + 1,
ctrlKey: true,
deltaY: -100,
});
await waitFor(() => {
expect(h.state.activeEmbeddable).toBeNull();
expect(h.state.zoom.value).toBeGreaterThan(prevZoom);
});
});
it("deactivates a non-Drive interactive embeddable on Ctrl/Cmd keydown", async () => {
const { container, embeddable } = await renderYouTubeEmbeddable({
handleKeyboardGlobally: true,
});
setActiveEmbeddable(embeddable);
await waitFor(() => {
expect(
container.querySelector<HTMLElement>(
".excalidraw__embeddable-container__inner",
)?.style.pointerEvents,
).toBe("all");
expect(
container.querySelectorAll(".excalidraw__embeddable-canvas-guard"),
).toHaveLength(0);
});
fireEvent.keyDown(document, {
key: "Control",
ctrlKey: true,
});
await waitFor(() => {
expect(h.state.activeEmbeddable).toBeNull();
expect(
container.querySelector<HTMLElement>(
".excalidraw__embeddable-container__inner",
)?.style.pointerEvents,
).toBe("none");
});
});
it("deactivates a non-Drive interactive embeddable on parent-observed pinch", async () => {
const { embeddable } = await renderYouTubeEmbeddable();
setActiveEmbeddable(embeddable);
fireEvent.pointerDown(GlobalTestState.interactiveCanvas, {
clientX: embeddable.x + 10,
clientY: embeddable.y + 10,
pointerId: 1,
pointerType: "touch",
});
fireEvent.pointerDown(GlobalTestState.interactiveCanvas, {
clientX: embeddable.x + 30,
clientY: embeddable.y + 30,
pointerId: 2,
pointerType: "touch",
});
await waitFor(() => {
expect(h.state.activeEmbeddable).toBeNull();
});
fireEvent.pointerUp(GlobalTestState.interactiveCanvas, {
pointerId: 1,
pointerType: "touch",
});
fireEvent.pointerUp(GlobalTestState.interactiveCanvas, {
pointerId: 2,
pointerType: "touch",
});
});
it("deactivates a non-Drive interactive embeddable on Safari gesturestart", async () => {
const { embeddable } = await renderYouTubeEmbeddable();
setActiveEmbeddable(embeddable);
act(() => {
document.dispatchEvent(
new Event("gesturestart", { bubbles: true, cancelable: true }),
);
});
await waitFor(() => {
expect(h.state.activeEmbeddable).toBeNull();
});
});
});
+2
View File
@@ -1,3 +1,5 @@
export const PRECISION = 10e-5;
// Legendre-Gauss abscissae (x values) and weights for n=24
// Refeerence: https://pomax.github.io/bezierinfo/legendre-gauss.html
export const LegendreGaussN24TValues = [