Compare commits

...

1 Commits

Author SHA1 Message Date
dwelle 4060682c57 dedupe & handle embeddables 2026-02-18 22:57:55 +01:00
2 changed files with 240 additions and 91 deletions
+131 -91
View File
@@ -442,10 +442,7 @@ import { searchItemInFocusAtom } from "./SearchMenu";
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import { StaticCanvas, InteractiveCanvas } from "./canvases";
import NewElementCanvas from "./canvases/NewElementCanvas";
import {
isPointHittingLink,
isPointHittingLinkIcon,
} from "./hyperlink/helpers";
import { isPointHittingLink } from "./hyperlink/helpers";
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
import { Toast } from "./Toast";
@@ -1210,12 +1207,99 @@ class App extends React.Component<AppProps, AppState> {
return this.iFrameRefs.get(element.id);
}
private handleEmbeddableCenterClick(element: ExcalidrawIframeLikeElement) {
private handleIframeLikeElementHover = ({
hitElement,
scenePointer,
moveEvent,
}: {
hitElement: NonDeleted<ExcalidrawElement> | null;
scenePointer: { x: number; y: number };
moveEvent: React.PointerEvent<HTMLCanvasElement>;
}): boolean => {
if (
this.state.activeEmbeddable?.element === element &&
hitElement &&
isIframeLikeElement(hitElement) &&
this.isIframeLikeElementCenter(
hitElement,
moveEvent,
scenePointer.x,
scenePointer.y,
)
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
this.setState({
activeEmbeddable: { element: hitElement, state: "hover" },
});
return true;
} else if (this.state.activeEmbeddable?.state === "hover") {
this.setState({ activeEmbeddable: null });
}
return false;
};
/** @returns true if iframe-like element click handled */
private handleIframeLikeCenterClick(): boolean {
if (!this.lastPointerDownEvent || !this.lastPointerUpEvent) {
return false;
}
const scenePointerStart = viewportCoordsToSceneCoords(
{
clientX: this.lastPointerDownEvent.clientX,
clientY: this.lastPointerDownEvent.clientY,
},
this.state,
);
const scenePointerEnd = viewportCoordsToSceneCoords(
{
clientX: this.lastPointerUpEvent.clientX,
clientY: this.lastPointerUpEvent.clientY,
},
this.state,
);
const hitElementStart = this.getElementAtPosition(
scenePointerStart.x,
scenePointerStart.y,
);
const hitElementEnd = this.getElementAtPosition(
scenePointerEnd.x,
scenePointerEnd.y,
);
if (
!hitElementStart ||
!hitElementEnd ||
hitElementStart !== hitElementEnd ||
this.lastPointerUpEvent.timeStamp - this.lastPointerDownEvent.timeStamp >
300 ||
gesture.pointers.size > 1 ||
!isIframeLikeElement(hitElementStart) ||
!isIframeLikeElement(hitElementEnd) ||
!this.isIframeLikeElementCenter(
hitElementStart,
this.lastPointerUpEvent,
scenePointerStart.x,
scenePointerStart.y,
) ||
!this.isIframeLikeElementCenter(
hitElementEnd,
this.lastPointerUpEvent,
scenePointerEnd.x,
scenePointerEnd.y,
)
) {
return false;
}
const iframeLikeElement = hitElementEnd;
if (
this.state.activeEmbeddable?.element === iframeLikeElement &&
this.state.activeEmbeddable?.state === "active"
) {
return;
return true;
}
// The delay serves two purposes
@@ -1226,31 +1310,34 @@ class App extends React.Component<AppProps, AppState> {
// in fullscreen mode
setTimeout(() => {
this.setState({
activeEmbeddable: { element, state: "active" },
selectedElementIds: { [element.id]: true },
activeEmbeddable: { element: iframeLikeElement, state: "active" },
selectedElementIds: { [iframeLikeElement.id]: true },
newElement: null,
selectionElement: null,
});
}, 100);
if (isIframeElement(element)) {
return;
if (isIframeElement(iframeLikeElement)) {
return true;
}
const iframe = this.getHTMLIFrameElement(element);
const iframe = this.getHTMLIFrameElement(iframeLikeElement);
if (!iframe?.contentWindow) {
return;
return true;
}
if (iframe.src.includes("youtube")) {
const state = YOUTUBE_VIDEO_STATES.get(element.id);
const state = YOUTUBE_VIDEO_STATES.get(iframeLikeElement.id);
if (!state) {
YOUTUBE_VIDEO_STATES.set(element.id, YOUTUBE_STATES.UNSTARTED);
YOUTUBE_VIDEO_STATES.set(
iframeLikeElement.id,
YOUTUBE_STATES.UNSTARTED,
);
iframe.contentWindow.postMessage(
JSON.stringify({
event: "listening",
id: element.id,
id: iframeLikeElement.id,
}),
"*",
);
@@ -1287,6 +1374,8 @@ class App extends React.Component<AppProps, AppState> {
"*",
);
}
return true;
}
private isIframeLikeElementCenter(
@@ -6622,10 +6711,14 @@ class App extends React.Component<AppProps, AppState> {
}
}
const hasDeselectedButton = Boolean(event.buttons);
const isPressingAnyButton = Boolean(event.buttons);
const isLaserTool = this.state.activeTool.type === "laser";
if (
hasDeselectedButton ||
(this.state.activeTool.type !== "selection" &&
isPressingAnyButton ||
// checking against laser so that if you mouseover with a laser tool
// over a link/embeddable, we change the cursor
(!isLaserTool &&
this.state.activeTool.type !== "selection" &&
this.state.activeTool.type !== "lasso" &&
this.state.activeTool.type !== "text" &&
this.state.activeTool.type !== "eraser")
@@ -6745,6 +6838,14 @@ class App extends React.Component<AppProps, AppState> {
);
} else {
hideHyperlinkToolip();
if (isLaserTool) {
this.handleIframeLikeElementHover({
hitElement,
scenePointer,
moveEvent: event,
});
return;
}
if (
hitElement &&
(hitElement.link || isEmbeddableElement(hitElement)) &&
@@ -6777,24 +6878,15 @@ class App extends React.Component<AppProps, AppState> {
!hitElement?.locked
) {
if (
hitElement &&
isIframeLikeElement(hitElement) &&
this.isIframeLikeElementCenter(
!this.handleIframeLikeElementHover({
hitElement,
event,
scenePointerX,
scenePointerY,
)
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
this.setState({
activeEmbeddable: { element: hitElement, state: "hover" },
});
} else if (
!hitElement ||
// Elbow arrows can only be moved when unconnected
!isElbowArrow(hitElement) ||
!(hitElement.startBinding || hitElement.endBinding)
scenePointer,
moveEvent: event,
}) &&
(!hitElement ||
// Elbow arrows can only be moved when unconnected
!isElbowArrow(hitElement) ||
!(hitElement.startBinding || hitElement.endBinding))
) {
if (
this.state.activeTool.type !== "lasso" ||
@@ -6802,9 +6894,6 @@ class App extends React.Component<AppProps, AppState> {
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
}
if (this.state.activeEmbeddable?.state === "hover") {
this.setState({ activeEmbeddable: null });
}
}
}
} else {
@@ -7456,26 +7545,9 @@ class App extends React.Component<AppProps, AppState> {
x: scenePointerX,
y: scenePointerY,
};
const clicklength =
event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0);
if (this.editorInterface.formFactor === "phone" && clicklength < 300) {
const hitElement = this.getElementAtPosition(
scenePointer.x,
scenePointer.y,
);
if (
isIframeLikeElement(hitElement) &&
this.isIframeLikeElementCenter(
hitElement,
event,
scenePointer.x,
scenePointer.y,
)
) {
this.handleEmbeddableCenterClick(hitElement);
return;
}
if (this.handleIframeLikeCenterClick()) {
return;
}
if (this.editorInterface.isTouchScreen) {
@@ -7496,20 +7568,7 @@ class App extends React.Component<AppProps, AppState> {
this.hitLinkElement &&
!this.state.selectedElementIds[this.hitLinkElement.id]
) {
if (
clicklength < 300 &&
isIframeLikeElement(this.hitLinkElement) &&
!isPointHittingLinkIcon(
this.hitLinkElement,
this.scene.getNonDeletedElementsMap(),
this.state,
pointFrom(scenePointer.x, scenePointer.y),
)
) {
this.handleEmbeddableCenterClick(this.hitLinkElement);
} else {
this.redirectToLink(event, this.editorInterface.isTouchScreen);
}
this.redirectToLink(event, this.editorInterface.isTouchScreen);
} else if (this.state.viewModeEnabled) {
this.setState({
activeEmbeddable: null,
@@ -10852,25 +10911,6 @@ class App extends React.Component<AppProps, AppState> {
suggestedBinding: null,
});
}
if (
hitElement &&
this.lastPointerUpEvent &&
this.lastPointerDownEvent &&
this.lastPointerUpEvent.timeStamp -
this.lastPointerDownEvent.timeStamp <
300 &&
gesture.pointers.size <= 1 &&
isIframeLikeElement(hitElement) &&
this.isIframeLikeElementCenter(
hitElement,
this.lastPointerUpEvent,
pointerDownState.origin.x,
pointerDownState.origin.y,
)
) {
this.handleEmbeddableCenterClick(hitElement);
}
});
}
+109
View File
@@ -0,0 +1,109 @@
import { vi } from "vitest";
import { CURSOR_TYPE } from "@excalidraw/common";
import { getElementAbsoluteCoords } from "@excalidraw/element";
import { Excalidraw } from "../index";
import { getLinkHandleFromCoords } from "../components/hyperlink/helpers";
import { API } from "./helpers/api";
import { Pointer } from "./helpers/ui";
import { act, GlobalTestState, render, waitFor } from "./test-utils";
import type { ExcalidrawProps } from "../types";
describe("laser tool interactions", () => {
const h = window.h;
const mouse = new Pointer("mouse");
it("opens links while using the laser tool", async () => {
const onLinkOpenSpy = vi.fn();
const onLinkOpen: NonNullable<ExcalidrawProps["onLinkOpen"]> = (
...args
) => {
onLinkOpenSpy(...args);
args[1].preventDefault();
};
await render(<Excalidraw onLinkOpen={onLinkOpen} />);
const linkedRect = API.createElement({
type: "rectangle",
x: 20,
y: 20,
width: 120,
height: 90,
});
API.setElements([linkedRect]);
API.updateElement(linkedRect, {
link: "https://example.com",
});
act(() => {
h.app.setActiveTool({ type: "laser" });
});
const elementsMap = h.app.scene.getNonDeletedElementsMap();
const currentRect = API.getElement(linkedRect);
const [x1, y1, x2, y2] = getElementAbsoluteCoords(currentRect, elementsMap);
const [linkX, linkY, linkWidth, linkHeight] = getLinkHandleFromCoords(
[x1, y1, x2, y2],
currentRect.angle,
h.state,
);
const iconCenterX = linkX + linkWidth / 2;
const iconCenterY = linkY + linkHeight / 2;
mouse.moveTo(iconCenterX, iconCenterY);
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
CURSOR_TYPE.POINTER,
);
mouse.clickAt(iconCenterX, iconCenterY);
expect(onLinkOpenSpy).toHaveBeenCalledTimes(1);
});
it("activates embeddables on center click while using the laser tool", async () => {
await render(<Excalidraw />);
const embeddable = API.createElement({
type: "embeddable",
x: 40,
y: 40,
width: 300,
height: 180,
});
API.setElements([embeddable]);
API.updateElement(embeddable, {
link: "https://www.youtube.com/watch?v=gkGMXY0wekg",
});
act(() => {
h.app.setActiveTool({ type: "laser" });
});
const handleIframeLikeCenterClickSpy = vi.spyOn(
h.app as unknown as {
handleIframeLikeCenterClick: () => void;
},
"handleIframeLikeCenterClick",
);
const centerX = embeddable.x + embeddable.width / 2;
const centerY = embeddable.y + embeddable.height / 2;
mouse.moveTo(centerX, centerY);
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
CURSOR_TYPE.POINTER,
);
mouse.clickAt(centerX, centerY);
expect(handleIframeLikeCenterClickSpy).toHaveBeenCalled();
await waitFor(() => {
expect(h.state.activeEmbeddable?.element.id).toBe(embeddable.id);
expect(h.state.activeEmbeddable?.state).toBe("active");
});
handleIframeLikeCenterClickSpy.mockRestore();
});
});