Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b3a58b6c2d |
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user