diff --git a/e2e/page-objects/GraphCameraComponentObject.ts b/e2e/page-objects/GraphCameraComponentObject.ts index 1bba2a93..f2be711f 100644 --- a/e2e/page-objects/GraphCameraComponentObject.ts +++ b/e2e/page-objects/GraphCameraComponentObject.ts @@ -127,6 +127,83 @@ export class GraphCameraComponentObject { await this.graphPO.waitForFrames(3); } + /** + * Pan the camera via trackpad wheel events so that the given world point ends up + * under the mouse cursor. + * + * handleTrackpadMove does camera.move(-deltaX, -deltaY), so to shift the camera by + * (dx, dy) screen pixels we pass wheel(dx, dy) directly. A non-zero deltaX triggers + * trackpad detection in isTrackpadWheelEvent() (horizontal scroll → trackpad). + * + * Fires multiple small steps (≤8 px) to stay within Camera's edge-guard limits. + * + * @param worldX - Target world X coordinate to bring under cursor + * @param worldY - Target world Y coordinate to bring under cursor + * @param viewportX - Cursor viewport X (defaults to canvas center) + * @param viewportY - Cursor viewport Y (defaults to canvas center) + */ + async panWorldPointUnderCursor( + worldX: number, + worldY: number, + viewportX?: number, + viewportY?: number + ): Promise { + const canvasBounds = await this.getCanvasBounds(); + const vx = viewportX ?? canvasBounds.x + canvasBounds.width / 2; + const vy = viewportY ?? canvasBounds.y + canvasBounds.height / 2; + + const delta = await this.page.evaluate( + ({ wx, wy, vx, vy }) => { + const canvas = window.graph.getGraphCanvas(); + const rect = canvas.getBoundingClientRect(); + const [currentWX, currentWY] = window.graph.cameraService.getRelativeXY( + vx - rect.left, + vy - rect.top + ); + const { scale } = window.graph.cameraService.getCameraState(); + return { + dx: (wx - currentWX) * scale, + dy: (wy - currentWY) * scale, + }; + }, + { wx: worldX, wy: worldY, vx, vy } + ); + + await this.page.mouse.move(vx, vy); + + const STEP = 8; + const steps = Math.ceil(Math.max(Math.abs(delta.dx), Math.abs(delta.dy)) / STEP); + const stepDx = steps > 0 ? delta.dx / steps : 0; + const stepDy = steps > 0 ? delta.dy / steps : 0; + + for (let i = 0; i < steps; i++) { + const wheelDx = stepDx !== 0 ? stepDx : 0.1; + await this.page.mouse.wheel(wheelDx, stepDy); + await this.graphPO.waitForFrames(1); + } + } + + /** + * Pan the camera via trackpad wheel events by the given screen-pixel amount. + * Positive dx moves content to the left (camera right), positive dy moves content up. + * Mouse must already be positioned on the canvas before calling. + * + * @param dx - Horizontal pan amount in screen pixels + * @param dy - Vertical pan amount in screen pixels + */ + async trackpadPan(dx: number, dy: number): Promise { + const STEP = 8; + const totalSteps = Math.ceil(Math.max(Math.abs(dx), Math.abs(dy)) / STEP); + const stepDx = totalSteps > 0 ? dx / totalSteps : 0; + const stepDy = totalSteps > 0 ? dy / totalSteps : 0; + + for (let moved = 0; moved < totalSteps; moved++) { + const wheelDx = stepDx !== 0 ? -stepDx : -0.1; + await this.page.mouse.wheel(wheelDx, -stepDy); + await this.graphPO.waitForFrames(1); + } + } + /** * Emulate camera pan with mouse drag * @param deltaX - Horizontal drag distance in pixels diff --git a/e2e/page-objects/GraphPageObject.ts b/e2e/page-objects/GraphPageObject.ts index 4c6e25d2..fb54f5f8 100644 --- a/e2e/page-objects/GraphPageObject.ts +++ b/e2e/page-objects/GraphPageObject.ts @@ -5,6 +5,65 @@ import { GraphBlockComponentObject } from "./GraphBlockComponentObject"; import { GraphConnectionComponentObject } from "./GraphConnectionComponentObject"; import { GraphCameraComponentObject } from "./GraphCameraComponentObject"; +let listenerIdCounter = 0; + +/** + * Collects graph events fired in the browser context and allows analyzing them + * via a callback that runs in the browser — no DOM serialization needed. + * + * Usage: + * const listener = await graphPO.listenGraphEvents("mouseenter"); + * // ... trigger actions ... + * const ids = await listener.analyze((events) => + * events.map((e) => e.detail?.target?.props?.id).filter(Boolean) + * ); + */ +export class GraphEventListener { + private readonly storageKey: string; + + constructor( + private readonly page: Page, + storageKey: string + ) { + this.storageKey = storageKey; + } + + /** + * Runs `fn` inside the browser with the collected events array as the first argument. + * Additional serializable values from Node.js can be passed as extra arguments. + * Returns whatever `fn` returns (must be serializable). + */ + async analyze( + fn: (events: CustomEvent[], ...args: TArgs) => TResult, + ...args: TArgs + ): Promise { + return this.page.evaluate( + ({ key, fnStr, args }) => { + const events = (window as any)[key] ?? []; + // eslint-disable-next-line no-new-func + return new Function("events", "...args", `return (${fnStr})(events, ...args)`)( + events, + ...args + ); + }, + { key: this.storageKey, fnStr: fn.toString(), args } + ); + } + + /** Removes the event listener and cleans up the storage key from window. */ + async stop(): Promise { + await this.page.evaluate((key) => { + const handler = (window as any)[`${key}_handler`]; + if (handler) { + window.graph.off((window as any)[`${key}_eventName`], handler); + } + delete (window as any)[key]; + delete (window as any)[`${key}_handler`]; + delete (window as any)[`${key}_eventName`]; + }, this.storageKey); + } +} + export interface GraphConfig { blocks?: TBlock[]; connections?: TConnection[]; @@ -390,4 +449,38 @@ export class GraphPageObject { return window.getComputedStyle(root).cursor; }); } + + /** + * Starts collecting graph events of the given name in the browser context. + * Returns a {@link GraphEventListener} whose `analyze()` method lets you + * inspect the collected events inside the browser — no DOM serialization needed. + * + * @example + * const listener = await graphPO.listenGraphEvents("mouseenter"); + * // ... trigger actions ... + * const ids = await listener.analyze((events) => + * events.map((e) => e.detail?.target?.props?.id).filter(Boolean) + * ); + * expect(ids).toContain("block-1"); + */ + async listenGraphEvents( + eventName: string + ): Promise> { + const key = `__graphListener_${listenerIdCounter++}_${eventName}`; + + await this.page.evaluate( + ({ key, eventName }) => { + (window as any)[key] = []; + (window as any)[`${key}_eventName`] = eventName; + const handler = (event: CustomEvent) => { + (window as any)[key].push(event); + }; + (window as any)[`${key}_handler`] = handler; + window.graph.on(eventName as any, handler); + }, + { key, eventName } + ); + + return new GraphEventListener(this.page, key); + } } diff --git a/e2e/tests/camera/camera-mouse-emulation.spec.ts b/e2e/tests/camera/camera-mouse-emulation.spec.ts new file mode 100644 index 00000000..550d93e5 --- /dev/null +++ b/e2e/tests/camera/camera-mouse-emulation.spec.ts @@ -0,0 +1,356 @@ +import { test, expect } from "@playwright/test"; +import { GraphPageObject } from "../../page-objects/GraphPageObject"; + +const BLOCK_1 = { + id: "block-1", + is: "Block" as const, + x: 100, + y: 100, + width: 200, + height: 100, + name: "Block 1", + anchors: [], + selected: false, +}; + +const BLOCK_2 = { + id: "block-2", + is: "Block" as const, + x: 500, + y: 100, + width: 200, + height: 100, + name: "Block 2", + anchors: [], + selected: false, +}; + +test.describe("Camera mouse event emulation (emulateMouseEventsOnCameraChange)", () => { + test.describe("setting = false (default) — no emulation", () => { + let graphPO: GraphPageObject; + + test.beforeEach(async ({ page }) => { + graphPO = new GraphPageObject(page); + await graphPO.initialize({ + blocks: [BLOCK_1, BLOCK_2], + connections: [], + settings: { + canDragCamera: true, + canZoomCamera: true, + // emulateMouseEventsOnCameraChange NOT set → defaults to false + }, + }); + }); + + test("should NOT fire mouseenter when trackpad pans a block under the static cursor", async () => { + const camera = graphPO.getCamera(); + await camera.zoomToCenter(); + await graphPO.waitForFrames(3); + + const canvasBounds = await camera.getCanvasBounds(); + const screenCenterX = canvasBounds.x + canvasBounds.width / 2; + const screenCenterY = canvasBounds.y + canvasBounds.height / 2; + + // Park mouse at canvas center (empty space) to establish lastMouseCanvasX/Y in GraphLayer + await graphPO.page.mouse.move(screenCenterX, screenCenterY); + await graphPO.waitForFrames(3); + + const listener = await graphPO.listenGraphEvents("mouseenter"); + + await camera.panWorldPointUnderCursor( + BLOCK_1.x + BLOCK_1.width / 2, + BLOCK_1.y + BLOCK_1.height / 2, + screenCenterX, + screenCenterY + ); + await graphPO.waitForFrames(5); + + const count = await listener.analyze((events) => + events.filter((e) => e.detail?.target?.props?.id).length + ); + expect(count).toBe(0); + }); + }); + + test.describe("setting = true — emulation enabled", () => { + let graphPO: GraphPageObject; + + test.beforeEach(async ({ page }) => { + graphPO = new GraphPageObject(page); + await graphPO.initialize({ + blocks: [BLOCK_1, BLOCK_2], + connections: [], + settings: { + canDragCamera: true, + canZoomCamera: true, + emulateMouseEventsOnCameraChange: true, + }, + }); + }); + + test("should fire mouseenter on block when trackpad pans block under static cursor", async () => { + const camera = graphPO.getCamera(); + await camera.zoomToCenter(); + await graphPO.waitForFrames(3); + + const canvasBounds = await camera.getCanvasBounds(); + const screenCenterX = canvasBounds.x + canvasBounds.width / 2; + const screenCenterY = canvasBounds.y + canvasBounds.height / 2; + + await graphPO.page.mouse.move(screenCenterX, screenCenterY); + await graphPO.waitForFrames(3); + + const listener = await graphPO.listenGraphEvents("mouseenter"); + + await camera.panWorldPointUnderCursor( + BLOCK_1.x + BLOCK_1.width / 2, + BLOCK_1.y + BLOCK_1.height / 2, + screenCenterX, + screenCenterY + ); + await graphPO.waitForFrames(5); + + const enterIds = await listener.analyze((events) => + events.map((e) => e.detail?.target?.props?.id).filter(Boolean) + ); + expect(enterIds).toContain(BLOCK_1.id); + }); + + test("should fire mouseleave on block when trackpad pans block away from cursor", async () => { + const block1 = graphPO.getBlockCOM("block-1"); + const camera = graphPO.getCamera(); + + // Hover over block-1 to make it the current GraphLayer target + await block1.hover({ waitFrames: 3 }); + await graphPO.waitForFrames(3); + + // Move mouse explicitly to block-1 center + const block1Center = await block1.getWorldCenter(); + const blockScreenPos = await graphPO.page.evaluate(({ wx, wy }) => { + const [sx, sy] = window.graph.cameraService.getAbsoluteXY(wx, wy); + const canvas = window.graph.getGraphCanvas(); + const rect = canvas.getBoundingClientRect(); + return { x: sx + rect.left, y: sy + rect.top }; + }, { wx: block1Center.x, wy: block1Center.y }); + + await graphPO.page.mouse.move(blockScreenPos.x, blockScreenPos.y); + await graphPO.waitForFrames(2); + + const listener = await graphPO.listenGraphEvents("mouseleave"); + + await camera.trackpadPan(400, 0); + await graphPO.waitForFrames(5); + + const leaveIds = await listener.analyze((events) => + events.map((e) => e.detail?.target?.props?.id).filter(Boolean) + ); + expect(leaveIds).toContain(BLOCK_1.id); + }); + + test("should fire mouseenter then mouseleave as trackpad pans block through cursor", async () => { + const camera = graphPO.getCamera(); + await camera.zoomToCenter(); + await graphPO.waitForFrames(3); + + const canvasBounds = await camera.getCanvasBounds(); + const screenCenterX = canvasBounds.x + canvasBounds.width / 2; + const screenCenterY = canvasBounds.y + canvasBounds.height / 2; + + await graphPO.page.mouse.move(screenCenterX, screenCenterY); + await graphPO.waitForFrames(3); + + const enterListener = await graphPO.listenGraphEvents("mouseenter"); + const leaveListener = await graphPO.listenGraphEvents("mouseleave"); + + // Phase 1: pan block-1 under cursor → mouseenter + await camera.panWorldPointUnderCursor( + BLOCK_1.x + BLOCK_1.width / 2, + BLOCK_1.y + BLOCK_1.height / 2, + screenCenterX, + screenCenterY + ); + await graphPO.waitForFrames(5); + + // Phase 2: pan camera so block-1 leaves cursor → mouseleave + await camera.trackpadPan(400, 0); + await graphPO.waitForFrames(5); + + const enterIds = await enterListener.analyze((events) => + events.map((e) => e.detail?.target?.props?.id).filter(Boolean) + ); + const leaveIds = await leaveListener.analyze((events) => + events.map((e) => e.detail?.target?.props?.id).filter(Boolean) + ); + + expect(enterIds).toContain(BLOCK_1.id); + expect(leaveIds).toContain(BLOCK_1.id); + }); + + test("should NOT fire block events when cursor is parked over empty canvas space during pan", async () => { + const camera = graphPO.getCamera(); + await camera.zoomToCenter(); + await graphPO.waitForFrames(3); + + const canvasBounds = await camera.getCanvasBounds(); + + // Park cursor in the top-left corner of the canvas — far from any block + const emptyAreaX = canvasBounds.x + 10; + const emptyAreaY = canvasBounds.y + 10; + await graphPO.page.mouse.move(emptyAreaX, emptyAreaY); + await graphPO.waitForFrames(3); + + const enterListener = await graphPO.listenGraphEvents("mouseenter"); + const leaveListener = await graphPO.listenGraphEvents("mouseleave"); + + // Pan camera — cursor stays over empty space, no block should enter/leave + await camera.trackpadPan(40, 0); + await graphPO.waitForFrames(5); + + const enterCount = await enterListener.analyze((events) => + events.filter((e) => Boolean(e.detail?.target?.props?.id)).length + ); + const leaveCount = await leaveListener.analyze((events) => + events.filter((e) => Boolean(e.detail?.target?.props?.id)).length + ); + + expect(enterCount).toBe(0); + expect(leaveCount).toBe(0); + }); + + test("cursor should change to pointer when trackpad pans block under static cursor", async () => { + const camera = graphPO.getCamera(); + await camera.zoomToCenter(); + await graphPO.waitForFrames(3); + + const canvasBounds = await camera.getCanvasBounds(); + const screenCenterX = canvasBounds.x + canvasBounds.width / 2; + const screenCenterY = canvasBounds.y + canvasBounds.height / 2; + + await graphPO.page.mouse.move(screenCenterX, screenCenterY); + await graphPO.waitForFrames(3); + + const cursorBefore = await graphPO.getCursor(); + expect(cursorBefore).toBe("auto"); + + await camera.panWorldPointUnderCursor( + BLOCK_1.x + BLOCK_1.width / 2, + BLOCK_1.y + BLOCK_1.height / 2, + screenCenterX, + screenCenterY + ); + + // Wait for cursor layer debounce to settle + await graphPO.waitForFrames(10); + + const cursorAfter = await graphPO.getCursor(); + expect(cursorAfter).toBe("pointer"); + }); + + test("cursor should revert to auto when trackpad pans block away from cursor", async () => { + const block1 = graphPO.getBlockCOM("block-1"); + const camera = graphPO.getCamera(); + + // Hover over block-1 → cursor becomes pointer + await block1.hover({ waitFrames: 3 }); + await graphPO.waitForFrames(5); + + const cursorOnBlock = await graphPO.getCursor(); + expect(cursorOnBlock).toBe("pointer"); + + // Trackpad-pan block-1 far away from cursor + await camera.trackpadPan(400, 0); + + // Wait for cursor layer debounce + await graphPO.waitForFrames(10); + + const cursorAfterPan = await graphPO.getCursor(); + expect(cursorAfterPan).toBe("auto"); + }); + + test("should correctly switch hover when trackpad pans from block-1 to block-2", async () => { + const camera = graphPO.getCamera(); + await camera.zoomToCenter(); + await graphPO.waitForFrames(3); + + const canvasBounds = await camera.getCanvasBounds(); + const screenCenterX = canvasBounds.x + canvasBounds.width / 2; + const screenCenterY = canvasBounds.y + canvasBounds.height / 2; + + await graphPO.page.mouse.move(screenCenterX, screenCenterY); + await graphPO.waitForFrames(3); + + const enterListener = await graphPO.listenGraphEvents("mouseenter"); + const leaveListener = await graphPO.listenGraphEvents("mouseleave"); + + // Pan block-1 under cursor + await camera.panWorldPointUnderCursor( + BLOCK_1.x + BLOCK_1.width / 2, + BLOCK_1.y + BLOCK_1.height / 2, + screenCenterX, + screenCenterY + ); + await graphPO.waitForFrames(5); + + // Pan block-2 under cursor (block-1 leaves, block-2 enters) + await camera.panWorldPointUnderCursor( + BLOCK_2.x + BLOCK_2.width / 2, + BLOCK_2.y + BLOCK_2.height / 2, + screenCenterX, + screenCenterY + ); + await graphPO.waitForFrames(5); + + const enterIds = await enterListener.analyze((events) => + events.map((e) => e.detail?.target?.props?.id).filter(Boolean) + ); + const leaveIds = await leaveListener.analyze((events) => + events.map((e) => e.detail?.target?.props?.id).filter(Boolean) + ); + + expect(enterIds).toContain(BLOCK_1.id); + expect(enterIds).toContain(BLOCK_2.id); + expect(leaveIds).toContain(BLOCK_1.id); + }); + + test("should fire mouseenter when pinch-zoom brings block under cursor", async () => { + const camera = graphPO.getCamera(); + + // Zoom out so block-1 is too small to be hit-tested + await camera.zoomToScale(0.05); + await graphPO.waitForFrames(3); + + // Move mouse over block-1's screen position to establish canvas coordinates + const block1 = graphPO.getBlockCOM("block-1"); + const block1Center = await block1.getWorldCenter(); + await graphPO.hover(block1Center.x, block1Center.y, { waitFrames: 3 }); + + const listener = await graphPO.listenGraphEvents("mouseenter"); + + // Position mouse over block-1's screen position before zooming + const mouseScreenPos = await graphPO.page.evaluate(() => { + const canvas = window.graph.getGraphCanvas(); + const rect = canvas.getBoundingClientRect(); + const geo = window.graph.blocks.getBlockState("block-1")!.$geometry.value; + const [sx, sy] = window.graph.cameraService.getAbsoluteXY( + geo.x + geo.width / 2, + geo.y + geo.height / 2 + ); + return { x: sx + rect.left, y: sy + rect.top }; + }); + + await graphPO.page.mouse.move(mouseScreenPos.x, mouseScreenPos.y); + + // Zoom in 25 steps — block-1 grows until it covers the cursor hit area + for (let i = 0; i < 25; i++) { + await graphPO.page.mouse.wheel(0, -100); + await graphPO.waitForFrames(1); + } + await graphPO.waitForFrames(5); + + const enterIds = await listener.analyze((events) => + events.map((e) => e.detail?.target?.props?.id).filter(Boolean) + ); + expect(enterIds).toContain(BLOCK_1.id); + }); + }); +}); diff --git a/src/components/canvas/layers/graphLayer/GraphLayer.ts b/src/components/canvas/layers/graphLayer/GraphLayer.ts index d409639e..2dfbfbf0 100644 --- a/src/components/canvas/layers/graphLayer/GraphLayer.ts +++ b/src/components/canvas/layers/graphLayer/GraphLayer.ts @@ -3,8 +3,9 @@ import { GraphMouseEventNames, isGraphEvent, isNativeGraphEventName } from "../. import { Component } from "../../../../lib/Component"; import { Layer, LayerContext, LayerProps } from "../../../../services/Layer"; import { Camera, TCameraProps } from "../../../../services/camera/Camera"; -import { ICamera } from "../../../../services/camera/CameraService"; +import { ICamera, TCameraState } from "../../../../services/camera/CameraService"; import { getEventDelta } from "../../../../utils/functions"; +import { Point } from "../../../../utils/types/shapes"; import { EventedComponent } from "../../EventedComponent/EventedComponent"; import { Blocks } from "../../blocks/Blocks"; import { BlockConnection } from "../../connections/BlockConnection"; @@ -61,6 +62,11 @@ export class GraphLayer extends Layer { private capturedTargetComponent?: EventedComponent; + /** Last known canvas-relative mouse coordinates, used for camera-change emulation */ + private lastMouseCanvasX?: number; + + private lastMouseCanvasY?: number; + constructor(props: TGraphLayerProps) { super({ canvas: { @@ -102,6 +108,11 @@ export class GraphLayer extends Layer { // Subscribe to graph events here instead of in the constructor this.onGraphEvent("camera-change", this.performRender); + this.onGraphEvent("camera-change", (event) => { + if (this.context.graph.rootStore.settings.getConfigFlag("emulateMouseEventsOnCameraChange")) { + this.onCameraChangeEmulateMouseEvents(event.detail); + } + }); this.context.graph.rootStore.blocksList.$blocks.subscribe(() => { this.performRender(); }); @@ -218,9 +229,37 @@ export class GraphLayer extends Layer { const point = this.context.graph.getPointInCameraSpace(event); + // Store canvas-relative coordinates for camera-change emulation + if (point.origPoint) { + this.lastMouseCanvasX = point.origPoint.x; + this.lastMouseCanvasY = point.origPoint.y; + } + this.targetComponent = this.context.graph.getElementOverPoint(point) || this.$.camera; } + private onCameraChangeEmulateMouseEvents(camera: TCameraState) { + if (this.lastMouseCanvasX === undefined || this.lastMouseCanvasY === undefined) return; + if (this.capturedTargetComponent) return; + + const [worldX, worldY] = this.context.camera.applyToPoint(this.lastMouseCanvasX, this.lastMouseCanvasY); + const point = new Point(worldX, worldY, { x: this.lastMouseCanvasX, y: this.lastMouseCanvasY }); + const newTarget = this.context.graph.getElementOverPoint(point) || this.$.camera; + + if (newTarget === this.targetComponent) return; + + const fakeEvent = new MouseEvent("mousemove", { + clientX: camera.x, + clientY: camera.y, + bubbles: false, + cancelable: false, + }); + + this.prevTargetComponent = this.targetComponent; + this.targetComponent = newTarget; + this.onRootPointerMove(fakeEvent); + } + private onRootPointerMove(event: MouseEvent) { if (this.targetComponent !== this.prevTargetComponent) { this.applyEventToTargetComponent( diff --git a/src/store/settings.ts b/src/store/settings.ts index b7181b5b..8a83fb6d 100644 --- a/src/store/settings.ts +++ b/src/store/settings.ts @@ -54,6 +54,13 @@ export type TGraphSettingsConfig>; connection?: typeof BlockConnection; background?: typeof Component; + /** + * When enabled, mouseenter/mouseleave events are re-evaluated after each camera change. + * Useful for trackpads where panning does not trigger native mousemove events, + * so hovering over elements requires this emulation to work correctly. + * Default: false + */ + emulateMouseEventsOnCameraChange?: boolean; }; export const DefaultSettings: TGraphSettingsConfig = {