diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 32bac77e1e..14ebca6bce 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -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 { return this.iFrameRefs.get(element.id); } - private handleEmbeddableCenterClick(element: ExcalidrawIframeLikeElement) { + private handleIframeLikeElementHover = ({ + hitElement, + scenePointer, + moveEvent, + }: { + hitElement: NonDeleted | null; + scenePointer: { x: number; y: number }; + moveEvent: React.PointerEvent; + }): 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 { // 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 { "*", ); } + + return true; } private isIframeLikeElementCenter( @@ -6622,10 +6711,14 @@ class App extends React.Component { } } - 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 { ); } 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 { !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 { ) { 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 { 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 { 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 { 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); - } }); } diff --git a/packages/excalidraw/tests/laser.test.tsx b/packages/excalidraw/tests/laser.test.tsx new file mode 100644 index 0000000000..b8b6510259 --- /dev/null +++ b/packages/excalidraw/tests/laser.test.tsx @@ -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 = ( + ...args + ) => { + onLinkOpenSpy(...args); + args[1].preventDefault(); + }; + await render(); + + 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(); + + 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(); + }); +});