From 1d4e2731cf42eca7de518c6f776561b8a59cf7e7 Mon Sep 17 00:00:00 2001 From: dwelle <5153846+dwelle@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:18:18 +0200 Subject: [PATCH] wip --- packages/excalidraw/components/App.tsx | 144 +++++++++++++- packages/excalidraw/tests/embeddable.test.tsx | 179 ++++++++++++++++++ 2 files changed, 321 insertions(+), 2 deletions(-) create mode 100644 packages/excalidraw/tests/embeddable.test.tsx diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 4db823c2d4..56dbff5a3a 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -603,6 +603,55 @@ const YOUTUBE_VIDEO_STATES = new Map< ValueOf >(); +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; @@ -1283,6 +1332,46 @@ class App extends React.Component { return this.iFrameRefs.get(element.id); } + private deactivateActiveEmbeddable = () => { + if (this.state.activeEmbeddable) { + flushSync(() => { + this.setState({ activeEmbeddable: null }); + }); + } + }; + + private handleEmbeddableCanvasGuardPointerDown = ( + event: React.PointerEvent, + ) => { + this.deactivateActiveEmbeddable(); + this.handleCanvasPointerDown(event); + }; + + private handleEmbeddableCanvasGuardPointerMove = ( + event: React.PointerEvent, + ) => { + this.lastViewportPosition.x = event.clientX; + this.lastViewportPosition.y = event.clientY; + this.deactivateActiveEmbeddable(); + }; + + private handleEmbeddableCanvasGuardTouchStart = ( + event: React.TouchEvent, + ) => { + if (event.touches.length >= 2) { + this.deactivateActiveEmbeddable(); + } + }; + + private handleEmbeddableCanvasGuardWheel = ( + event: React.WheelEvent, + ) => { + this.lastViewportPosition.x = event.clientX; + this.lastViewportPosition.y = event.clientY; + this.deactivateActiveEmbeddable(); + this.handleWheel(event); + }; + private handleIframeLikeElementHover = ({ hitElement, scenePointer, @@ -1305,12 +1394,25 @@ class App extends React.Component { )) ) { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); + const isDriveEmbedActivating = + (this.state.viewModeEnabled || + this.state.activeTool.type === "selection") && + isGoogleDriveEmbeddableElement(hitElement); this.setState({ - activeEmbeddable: { element: hitElement, state: "hover" }, + activeEmbeddable: { + element: hitElement, + state: isDriveEmbedActivating ? "active" : "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; }; @@ -1734,6 +1836,8 @@ class App extends React.Component { const isHovered = this.state.activeEmbeddable?.element === el && this.state.activeEmbeddable?.state === "hover"; + const shouldConstrainDriveInteraction = + isActive && isGoogleDriveEmbeddableElement(el); return (
{ 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, @@ -1827,6 +1932,26 @@ class App extends React.Component { /> )}
+ {shouldConstrainDriveInteraction && + EMBEDDABLE_CANVAS_GUARDS.map(({ key, style }) => ( +
+ ))}
); @@ -4733,6 +4858,16 @@ class App extends React.Component { }); } + 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) && @@ -8165,6 +8300,10 @@ class App extends React.Component { 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; @@ -12584,7 +12723,8 @@ class App extends React.Component { event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLIFrameElement || (event.target instanceof HTMLElement && - event.target.classList.contains(CLASSES.FRAME_NAME)) + (event.target.classList.contains(CLASSES.FRAME_NAME) || + event.target.classList.contains(EMBEDDABLE_CANVAS_GUARD_CLASS))) ) ) { // prevent zooming the browser (but allow scrolling DOM) diff --git a/packages/excalidraw/tests/embeddable.test.tsx b/packages/excalidraw/tests/embeddable.test.tsx new file mode 100644 index 0000000000..cf4e075119 --- /dev/null +++ b/packages/excalidraw/tests/embeddable.test.tsx @@ -0,0 +1,179 @@ +import { Excalidraw } from "../index"; + +import { Pointer } from "./helpers/ui"; +import { act, fireEvent, 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 = {}, + ) => { + const renderResult = await render(); + const fileId = "1AbCdEfGhIjKlMnOpQrStUvWxYz123456"; + const src = `https://drive.google.com/file/d/${fileId}/preview`; + let embeddable!: NonNullable< + ReturnType + >; + + 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; + } + ).embedsValidationStatus.set(embeddable.id, true); + + act(() => { + h.setState({ width: 1000, height: 1000 }); + h.app.scene.triggerUpdate(); + }); + + const getIframe = () => { + const iframe = renderResult.container.querySelector( + "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, + }; + }; + + 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( + ".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( + ".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( + ".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( + ".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); + }); + }); +});