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 { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import { StaticCanvas, InteractiveCanvas } from "./canvases"; import { StaticCanvas, InteractiveCanvas } from "./canvases";
import NewElementCanvas from "./canvases/NewElementCanvas"; import NewElementCanvas from "./canvases/NewElementCanvas";
import { import { isPointHittingLink } from "./hyperlink/helpers";
isPointHittingLink,
isPointHittingLinkIcon,
} from "./hyperlink/helpers";
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons"; import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
import { Toast } from "./Toast"; import { Toast } from "./Toast";
@@ -1210,12 +1207,99 @@ class App extends React.Component<AppProps, AppState> {
return this.iFrameRefs.get(element.id); 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 ( 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" this.state.activeEmbeddable?.state === "active"
) { ) {
return; return true;
} }
// The delay serves two purposes // The delay serves two purposes
@@ -1226,31 +1310,34 @@ class App extends React.Component<AppProps, AppState> {
// in fullscreen mode // in fullscreen mode
setTimeout(() => { setTimeout(() => {
this.setState({ this.setState({
activeEmbeddable: { element, state: "active" }, activeEmbeddable: { element: iframeLikeElement, state: "active" },
selectedElementIds: { [element.id]: true }, selectedElementIds: { [iframeLikeElement.id]: true },
newElement: null, newElement: null,
selectionElement: null, selectionElement: null,
}); });
}, 100); }, 100);
if (isIframeElement(element)) { if (isIframeElement(iframeLikeElement)) {
return; return true;
} }
const iframe = this.getHTMLIFrameElement(element); const iframe = this.getHTMLIFrameElement(iframeLikeElement);
if (!iframe?.contentWindow) { if (!iframe?.contentWindow) {
return; return true;
} }
if (iframe.src.includes("youtube")) { if (iframe.src.includes("youtube")) {
const state = YOUTUBE_VIDEO_STATES.get(element.id); const state = YOUTUBE_VIDEO_STATES.get(iframeLikeElement.id);
if (!state) { if (!state) {
YOUTUBE_VIDEO_STATES.set(element.id, YOUTUBE_STATES.UNSTARTED); YOUTUBE_VIDEO_STATES.set(
iframeLikeElement.id,
YOUTUBE_STATES.UNSTARTED,
);
iframe.contentWindow.postMessage( iframe.contentWindow.postMessage(
JSON.stringify({ JSON.stringify({
event: "listening", event: "listening",
id: element.id, id: iframeLikeElement.id,
}), }),
"*", "*",
); );
@@ -1287,6 +1374,8 @@ class App extends React.Component<AppProps, AppState> {
"*", "*",
); );
} }
return true;
} }
private isIframeLikeElementCenter( 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 ( if (
hasDeselectedButton || isPressingAnyButton ||
(this.state.activeTool.type !== "selection" && // 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 !== "lasso" &&
this.state.activeTool.type !== "text" && this.state.activeTool.type !== "text" &&
this.state.activeTool.type !== "eraser") this.state.activeTool.type !== "eraser")
@@ -6745,6 +6838,14 @@ class App extends React.Component<AppProps, AppState> {
); );
} else { } else {
hideHyperlinkToolip(); hideHyperlinkToolip();
if (isLaserTool) {
this.handleIframeLikeElementHover({
hitElement,
scenePointer,
moveEvent: event,
});
return;
}
if ( if (
hitElement && hitElement &&
(hitElement.link || isEmbeddableElement(hitElement)) && (hitElement.link || isEmbeddableElement(hitElement)) &&
@@ -6777,24 +6878,15 @@ class App extends React.Component<AppProps, AppState> {
!hitElement?.locked !hitElement?.locked
) { ) {
if ( if (
hitElement && !this.handleIframeLikeElementHover({
isIframeLikeElement(hitElement) &&
this.isIframeLikeElementCenter(
hitElement, hitElement,
event, scenePointer,
scenePointerX, moveEvent: event,
scenePointerY, }) &&
) (!hitElement ||
) { // Elbow arrows can only be moved when unconnected
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); !isElbowArrow(hitElement) ||
this.setState({ !(hitElement.startBinding || hitElement.endBinding))
activeEmbeddable: { element: hitElement, state: "hover" },
});
} else if (
!hitElement ||
// Elbow arrows can only be moved when unconnected
!isElbowArrow(hitElement) ||
!(hitElement.startBinding || hitElement.endBinding)
) { ) {
if ( if (
this.state.activeTool.type !== "lasso" || this.state.activeTool.type !== "lasso" ||
@@ -6802,9 +6894,6 @@ class App extends React.Component<AppProps, AppState> {
) { ) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
} }
if (this.state.activeEmbeddable?.state === "hover") {
this.setState({ activeEmbeddable: null });
}
} }
} }
} else { } else {
@@ -7456,26 +7545,9 @@ class App extends React.Component<AppProps, AppState> {
x: scenePointerX, x: scenePointerX,
y: scenePointerY, y: scenePointerY,
}; };
const clicklength =
event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0);
if (this.editorInterface.formFactor === "phone" && clicklength < 300) { if (this.handleIframeLikeCenterClick()) {
const hitElement = this.getElementAtPosition( return;
scenePointer.x,
scenePointer.y,
);
if (
isIframeLikeElement(hitElement) &&
this.isIframeLikeElementCenter(
hitElement,
event,
scenePointer.x,
scenePointer.y,
)
) {
this.handleEmbeddableCenterClick(hitElement);
return;
}
} }
if (this.editorInterface.isTouchScreen) { if (this.editorInterface.isTouchScreen) {
@@ -7496,20 +7568,7 @@ class App extends React.Component<AppProps, AppState> {
this.hitLinkElement && this.hitLinkElement &&
!this.state.selectedElementIds[this.hitLinkElement.id] !this.state.selectedElementIds[this.hitLinkElement.id]
) { ) {
if ( this.redirectToLink(event, this.editorInterface.isTouchScreen);
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);
}
} else if (this.state.viewModeEnabled) { } else if (this.state.viewModeEnabled) {
this.setState({ this.setState({
activeEmbeddable: null, activeEmbeddable: null,
@@ -10852,25 +10911,6 @@ class App extends React.Component<AppProps, AppState> {
suggestedBinding: null, 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();
});
});