Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4060682c57 |
@@ -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);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user