diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index 8684d24..2abb218 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import type { Page } from '@playwright/test'; +import type { CDPSession, Page } from '@playwright/test'; import { resolve } from 'node:path'; import { Euler, Quaternion } from 'three'; @@ -204,6 +204,84 @@ async function moveReactRootIntoOpenShadowRoot(page: Page): Promise { }); } +async function dispatchChromiumPointerDown( + client: CDPSession, + pointerType: 'touch' | 'pen', + x: number, + y: number, +): Promise { + if (pointerType === 'touch') { + await client.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [{ x, y, id: 41, radiusX: 2, radiusY: 2, force: 1 }], + }); + return; + } + await client.send('Input.dispatchMouseEvent', { + type: 'mousePressed', + x, + y, + button: 'left', + buttons: 1, + clickCount: 1, + pointerType: 'pen', + }); +} + +async function dispatchChromiumPointerMove( + client: CDPSession, + pointerType: 'touch' | 'pen', + x: number, + y: number, +): Promise { + if (pointerType === 'touch') { + await client.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: [{ x, y, id: 41, radiusX: 2, radiusY: 2, force: 1 }], + }); + return; + } + await client.send('Input.dispatchMouseEvent', { + type: 'mouseMoved', + x, + y, + button: 'none', + buttons: 1, + pointerType: 'pen', + }); +} + +async function dispatchChromiumPointerUp( + client: CDPSession, + pointerType: 'touch' | 'pen', + x: number, + y: number, +): Promise { + if (pointerType === 'touch') { + await client.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + return; + } + await client.send('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x, + y, + button: 'left', + buttons: 0, + clickCount: 1, + pointerType: 'pen', + }); +} + +async function dispatchChromiumPointer( + client: CDPSession, + pointerType: 'touch' | 'pen', + x: number, + y: number, +): Promise { + await dispatchChromiumPointerDown(client, pointerType, x, y); + await dispatchChromiumPointerUp(client, pointerType, x, y); +} + function vectorDistance(a: readonly number[], b: readonly number[]): number { return Math.hypot(...a.map((value, index) => value - (b[index] ?? 0))); } @@ -441,7 +519,7 @@ test('does not expire a long-held right-drag before releasing outside the viewpo expect(await contextMenu).toBe(true); }); -test('allows a new outside right-click after an orphaned viewport pointerdown', async ({ page }) => { +test('allows a new outside right-click after blur recovers an orphaned viewport pointerdown', async ({ page }) => { await page.getByTestId('tree-row-sample-camera').click(); await page.getByTestId('view-mode-select').selectOption('sample-camera'); await expect(page.getByTestId('camera-control-status')).toContainText('主摄像机推镜'); @@ -470,9 +548,8 @@ test('allows a new outside right-click after an orphaned viewport pointerdown', }); }); - // Seed the abnormal state directly: a real browser cannot begin a second - // physical right-click until the first button is released, while the bug is - // specifically that the application missed that terminating event. + // Seed the abnormal state directly, then use the explicit blur recovery path. + // A different pointer pair must not implicitly steal or clear the owner. await viewport.dispatchEvent('pointerdown', { bubbles: true, composed: true, @@ -483,6 +560,7 @@ test('allows a new outside right-click after an orphaned viewport pointerdown', clientX: viewportBounds.x + viewportBounds.width * 0.5, clientY: viewportBounds.y + viewportBounds.height * 0.5, }); + await page.evaluate(() => window.dispatchEvent(new Event('blur'))); await page.mouse.click(toolbarBounds.x + 8, toolbarBounds.y + toolbarBounds.height / 2, { button: 'right' }); const result = await page.evaluate(() => { @@ -499,6 +577,230 @@ test('allows a new outside right-click after an orphaned viewport pointerdown', expect(result.contextMenus).toContain(false); }); +for (const pointerType of ['touch', 'pen'] as const) { + test(`active camera look isolates a real Chromium ${pointerType} pointer`, async ({ page, browserName }) => { + test.skip(browserName !== 'chromium', 'CDP pointer injection is a Chromium regression'); + await startRecording(page); + await hideViewportOverlays(page); + const viewport = page.getByTestId('lumora-viewport'); + const bounds = await viewport.boundingBox(); + if (!bounds) throw new Error('viewport is unavailable'); + const x = bounds.x + bounds.width * 0.5; + const y = bounds.y + bounds.height * 0.5; + await page.evaluate((secondaryType) => { + const scope = globalThis as typeof globalThis & { + __lumoraSecondaryPointerCaptures?: number; + __lumoraSecondaryPointerBubbles?: number; + __lumoraSecondaryPointerIdentity?: { pointerId: number; pointerType: string }; + __lumoraSecondaryMoveCaptures?: number; + __lumoraSecondaryMoveBubbles?: number; + }; + scope.__lumoraSecondaryPointerCaptures = 0; + scope.__lumoraSecondaryPointerBubbles = 0; + scope.__lumoraSecondaryPointerIdentity = undefined; + scope.__lumoraSecondaryMoveCaptures = 0; + scope.__lumoraSecondaryMoveBubbles = 0; + window.addEventListener('pointerdown', (event) => { + if ( + event.pointerType === secondaryType && + event.composedPath().includes(document.querySelector('[data-testid="lumora-viewport"]')) + ) { + scope.__lumoraSecondaryPointerCaptures! += 1; + scope.__lumoraSecondaryPointerIdentity = { + pointerId: event.pointerId, + pointerType: event.pointerType, + }; + } + }, true); + window.addEventListener('pointerdown', (event) => { + if (event.pointerType === secondaryType) scope.__lumoraSecondaryPointerBubbles! += 1; + }); + window.addEventListener('pointermove', (event) => { + if (event.pointerType === secondaryType) scope.__lumoraSecondaryMoveCaptures! += 1; + }, true); + window.addEventListener('pointermove', (event) => { + if (event.pointerType === secondaryType) scope.__lumoraSecondaryMoveBubbles! += 1; + }); + }, pointerType); + + const ownerStarted = await viewport.evaluate((element, point) => { + const down = new PointerEvent('pointerdown', { + bubbles: true, + cancelable: true, + composed: true, + button: 2, + buttons: 2, + pointerId: 31, + pointerType: 'mouse', + clientX: point.x - 30, + clientY: point.y + 20, + }); + element.dispatchEvent(down); + const move = new PointerEvent('pointermove', { + bubbles: true, + cancelable: true, + composed: true, + button: -1, + buttons: 2, + pointerId: 31, + pointerType: 'mouse', + clientX: point.x + 30, + clientY: point.y - 20, + }); + element.dispatchEvent(move); + return { down: down.defaultPrevented, move: move.defaultPrevented }; + }, { x, y }); + expect(ownerStarted).toEqual({ down: true, move: true }); + const cameraBefore = await stableCameraPose(page, 'sample-camera'); + const directorBefore = await stableCameraPose(page, '__rendered__'); + const client = await page.context().newCDPSession(page); + await dispatchChromiumPointer(client, pointerType, x, y); + await page.waitForTimeout(100); + + const observed = await page.evaluate(() => { + const scope = globalThis as typeof globalThis & { + __lumoraSecondaryPointerCaptures?: number; + __lumoraSecondaryPointerBubbles?: number; + __lumoraSecondaryPointerIdentity?: { pointerId: number; pointerType: string }; + }; + return { + captures: scope.__lumoraSecondaryPointerCaptures ?? 0, + bubbles: scope.__lumoraSecondaryPointerBubbles ?? 0, + identity: scope.__lumoraSecondaryPointerIdentity ?? null, + }; + }); + expect(observed).toMatchObject({ captures: 1, bubbles: 0 }); + expect(observed.identity?.pointerType).toBe(pointerType); + if (!observed.identity) throw new Error('secondary pointer identity was not captured'); + const secondaryContextMenuAllowed = await viewport.evaluate((element, identity) => { + const event = new PointerEvent('contextmenu', { + bubbles: true, + cancelable: true, + composed: true, + button: 2, + pointerId: identity.pointerId, + pointerType: identity.pointerType, + }); + return element.dispatchEvent(event); + }, observed.identity); + expect(secondaryContextMenuAllowed).toBe(false); + await expect(page.getByTestId('tree-row-sample-camera')).toHaveAttribute('aria-selected', 'true'); + const cameraAfter = await stableCameraPose(page, 'sample-camera'); + const directorAfter = await stableCameraPose(page, '__rendered__'); + expect(vectorDistance(cameraAfter.position, cameraBefore.position)).toBeLessThan(0.000001); + expect(quaternionAngle(cameraAfter.rotation, cameraBefore.rotation)).toBeLessThan(0.00005); + expect(vectorDistance(directorAfter.position, directorBefore.position)).toBeLessThan(0.000001); + expect(quaternionAngle(directorAfter.rotation, directorBefore.rotation)).toBeLessThan(0.000001); + + await viewport.dispatchEvent('pointermove', { + bubbles: true, + cancelable: true, + composed: true, + button: -1, + buttons: 2, + pointerId: 31, + pointerType: 'mouse', + clientX: x + 70, + clientY: y - 45, + }); + const cameraAfterOwnerMove = await stableCameraPose(page, 'sample-camera'); + expect(quaternionAngle(cameraAfterOwnerMove.rotation, cameraAfter.rotation)).toBeGreaterThan(0.001); + + await page.evaluate(() => { + document.body.dispatchEvent(new PointerEvent('pointerup', { + bubbles: true, + cancelable: true, + composed: true, + button: 2, + buttons: 0, + pointerId: 31, + pointerType: 'mouse', + })); + }); + const outsideContextMenuSequence = await page.evaluate(() => [0, 1].map(() => { + const event = new PointerEvent('contextmenu', { + bubbles: true, + cancelable: true, + composed: true, + button: 2, + pointerId: 31, + pointerType: 'mouse', + }); + document.body.dispatchEvent(event); + return event.defaultPrevented; + })); + expect([ + !secondaryContextMenuAllowed, + ...outsideContextMenuSequence, + ]).toEqual([true, true, false]); + + await page.evaluate(() => { + const scope = globalThis as typeof globalThis & { + __lumoraSecondaryMoveCaptures?: number; + __lumoraSecondaryMoveBubbles?: number; + }; + scope.__lumoraSecondaryMoveCaptures = 0; + scope.__lumoraSecondaryMoveBubbles = 0; + }); + await dispatchChromiumPointerDown(client, pointerType, x, y); + const secondOwnerStarted = await viewport.evaluate((element, point) => { + const event = new PointerEvent('pointerdown', { + bubbles: true, + cancelable: true, + composed: true, + button: 2, + buttons: 2, + pointerId: 32, + pointerType: 'mouse', + clientX: point.x - 20, + clientY: point.y + 10, + }); + element.dispatchEvent(event); + return event.defaultPrevented; + }, { x, y }); + expect(secondOwnerStarted).toBe(true); + const selectionBeforePreExistingMove = await page + .getByTestId('tree-row-sample-camera') + .getAttribute('aria-selected'); + const cameraBeforePreExistingMove = await stableCameraPose(page, 'sample-camera'); + const directorBeforePreExistingMove = await stableCameraPose(page, '__rendered__'); + await dispatchChromiumPointerMove(client, pointerType, x + 12, y + 12); + await page.waitForTimeout(100); + const movePropagation = await page.evaluate(() => { + const scope = globalThis as typeof globalThis & { + __lumoraSecondaryMoveCaptures?: number; + __lumoraSecondaryMoveBubbles?: number; + }; + return { + captures: scope.__lumoraSecondaryMoveCaptures ?? 0, + bubbles: scope.__lumoraSecondaryMoveBubbles ?? 0, + }; + }); + expect(movePropagation).toEqual({ captures: 1, bubbles: 0 }); + expect(await page.getByTestId('tree-row-sample-camera').getAttribute('aria-selected')) + .toBe(selectionBeforePreExistingMove); + const cameraAfterPreExistingMove = await stableCameraPose(page, 'sample-camera'); + const directorAfterPreExistingMove = await stableCameraPose(page, '__rendered__'); + expect(vectorDistance(cameraAfterPreExistingMove.position, cameraBeforePreExistingMove.position)).toBeLessThan(0.000001); + expect(quaternionAngle(cameraAfterPreExistingMove.rotation, cameraBeforePreExistingMove.rotation)).toBeLessThan(0.00005); + expect(vectorDistance(directorAfterPreExistingMove.position, directorBeforePreExistingMove.position)).toBeLessThan(0.000001); + expect(quaternionAngle(directorAfterPreExistingMove.rotation, directorBeforePreExistingMove.rotation)).toBeLessThan(0.000001); + await dispatchChromiumPointerUp(client, pointerType, x + 12, y + 12); + await client.detach(); + await page.evaluate(() => { + document.body.dispatchEvent(new PointerEvent('pointerup', { + bubbles: true, + cancelable: true, + composed: true, + button: 2, + buttons: 0, + pointerId: 32, + pointerType: 'mouse', + })); + }); + }); +} + test('director free-drive orientation remains stable across pixel and line-mode wheel gestures', async ({ page }) => { const viewport = page.getByTestId('lumora-viewport'); await hideViewportOverlays(page); diff --git a/packages/studio/src/components/editor/EditorViewport.tsx b/packages/studio/src/components/editor/EditorViewport.tsx index 5919dba..d73aaa6 100644 --- a/packages/studio/src/components/editor/EditorViewport.tsx +++ b/packages/studio/src/components/editor/EditorViewport.tsx @@ -80,6 +80,7 @@ function useViewportContextMenuGuard(viewportRef: React.RefObject { gesturePointerId = null; + gesturePointerType = null; clearGestureExpiry(); clearPendingContextMenu(); }; - const armGesture = (pointerId: number) => { + const clearGesture = () => { + gesturePointerId = null; + gesturePointerType = null; + clearGestureExpiry(); + }; + const armGesture = (pointerId: number, pointerType: string) => { clearPendingContextMenu(); gesturePointerId = pointerId; + gesturePointerType = pointerType; clearGestureExpiry(); // A stuck pointer stream is abnormal; normal long presses remain armed // until pointerup/pointercancel rather than expiring after two seconds. gestureExpiryTimer = globalThis.setTimeout(() => { gesturePointerId = null; + gesturePointerType = null; gestureExpiryTimer = null; }, 5 * 60_000); }; + const isGestureOwner = (event: Pick): boolean => + event.pointerId === gesturePointerId && event.pointerType === gesturePointerType; const eventPath = (event: Event): EventTarget[] => { const path = event.composedPath?.(); return path && path.length > 0 ? path : event.target ? [event.target] : []; @@ -129,25 +140,42 @@ function useViewportContextMenuGuard(viewportRef: React.RefObject { - // Every pointer sequence supersedes any stale gesture state before the - // current event is evaluated for viewport context-menu suppression. - clearAll(); + if (gesturePointerId !== null) return; + clearPendingContextMenu(); if (event.button !== 2 || !isWithinViewport(event) || isInteractiveTarget(event)) return; - armGesture(event.pointerId); + armGesture(event.pointerId, event.pointerType); }; const onPointerUp = (event: PointerEvent) => { - if (event.pointerId !== gesturePointerId) return; - gesturePointerId = null; - clearGestureExpiry(); + if (!isGestureOwner(event)) return; + clearGesture(); pendingContextMenu = true; if (pendingContextMenuTimer !== null) globalThis.clearTimeout(pendingContextMenuTimer); pendingContextMenuTimer = globalThis.setTimeout(clearPendingContextMenu, 10_000); }; + const onPointerMove = (event: PointerEvent) => { + if (isGestureOwner(event) && (event.buttons & 2) === 0) clearAll(); + }; const onPointerCancel = (event: PointerEvent) => { - if (event.pointerId === gesturePointerId) clearAll(); + if (isGestureOwner(event)) clearAll(); + }; + const onLostPointerCapture = (event: PointerEvent) => { + if (isGestureOwner(event)) clearAll(); }; const onContextMenu = (event: MouseEvent) => { - if (gesturePointerId !== null || pendingContextMenu || isWithinViewport(event)) { + if (gesturePointerId !== null) { + event.preventDefault(); + const pointerEvent = event as MouseEvent & Partial>; + if ( + typeof pointerEvent.pointerId === 'number' && + typeof pointerEvent.pointerType === 'string' && + pointerEvent.pointerType.length > 0 && + isGestureOwner(pointerEvent as Pick) + ) { + clearAll(); + } + return; + } + if (pendingContextMenu || isWithinViewport(event)) { event.preventDefault(); clearAll(); } @@ -157,18 +185,22 @@ function useViewportContextMenuGuard(viewportRef: React.RefObject { clearAll(); window.removeEventListener('pointerdown', onPointerDown, true); + window.removeEventListener('pointermove', onPointerMove, true); window.removeEventListener('pointerup', onPointerUp, true); window.removeEventListener('pointercancel', onPointerCancel, true); window.removeEventListener('contextmenu', onContextMenu, true); window.removeEventListener('blur', onWindowBlur); + viewport.removeEventListener('lostpointercapture', onLostPointerCapture); document.removeEventListener('visibilitychange', onVisibilityChange); }; }, [viewportRef]); @@ -531,6 +563,7 @@ function useCameraDrive( let mirrorNode: THREE.Object3D | null = null; const heldKeys = new Set(); let lookPointerId: number | null = null; + let lookPointerType: string | null = null; let lookClientX = 0; let lookClientY = 0; const previousPrimaryPosition = new THREE.Vector3(); @@ -545,6 +578,7 @@ function useCameraDrive( if (lookPointerId === null) return; const pointerId = lookPointerId; lookPointerId = null; + lookPointerType = null; const viewport = viewportRef.current; try { viewport?.releasePointerCapture(pointerId); @@ -647,6 +681,16 @@ function useCameraDrive( (!hasTracks || st.recording) ); }; + const isLookOwner = (event: Pick): boolean => + event.pointerId === lookPointerId && event.pointerType === lookPointerType; + const blockSecondaryPointer = (event: PointerEvent): boolean => { + if (lookPointerId === null || isLookOwner(event)) return false; + if (event.currentTarget === viewportRef.current) { + event.preventDefault(); + event.stopPropagation(); + } + return true; + }; const onKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented || !driveEnabledRef.current) return; @@ -680,6 +724,7 @@ function useCameraDrive( } }; const onPointerDown = (event: PointerEvent) => { + if (blockSecondaryPointer(event)) return; if (event.button !== 2) return; if (lookPointerId !== null) return; const target = event.target; @@ -694,6 +739,7 @@ function useCameraDrive( if (!attachCurrentCamera()) return; drive.cancelTranslationMomentum(); lookPointerId = event.pointerId; + lookPointerType = event.pointerType; activityRef.current = true; lookClientX = event.clientX; lookClientY = event.clientY; @@ -707,7 +753,8 @@ function useCameraDrive( } }; const onPointerMove = (event: PointerEvent) => { - if (lookPointerId === null || event.pointerId !== lookPointerId) return; + if (blockSecondaryPointer(event)) return; + if (lookPointerId === null || !isLookOwner(event)) return; if ((event.buttons & 2) === 0) { endLookGesture(); return; @@ -721,13 +768,15 @@ function useCameraDrive( event.preventDefault(); }; const onPointerUp = (event: PointerEvent) => { - if (event.pointerId === lookPointerId) endLookGesture(); + if (blockSecondaryPointer(event)) return; + if (isLookOwner(event)) endLookGesture(); }; const onPointerCancel = (event: PointerEvent) => { - if (event.pointerId === lookPointerId) clearDrive(); + if (blockSecondaryPointer(event)) return; + if (isLookOwner(event)) clearDrive(); }; const onLostPointerCapture = (event: PointerEvent) => { - if (event.pointerId !== lookPointerId) return; + if (lookPointerId === null || !isLookOwner(event)) return; drive.cancelLook(); endLookGesture(); }; @@ -768,9 +817,9 @@ function useCameraDrive( document.addEventListener('focusout', onFocusOut); const viewport = viewportRef.current; viewport?.addEventListener('pointerdown', onPointerDown, true); - viewport?.addEventListener('pointermove', onPointerMove); - viewport?.addEventListener('pointerup', onPointerUp); - viewport?.addEventListener('pointercancel', onPointerCancel); + viewport?.addEventListener('pointermove', onPointerMove, true); + viewport?.addEventListener('pointerup', onPointerUp, true); + viewport?.addEventListener('pointercancel', onPointerCancel, true); viewport?.addEventListener('lostpointercapture', onLostPointerCapture); const restoreIfNeeded = () => { @@ -911,9 +960,9 @@ function useCameraDrive( document.removeEventListener('focusin', onFocusIn); document.removeEventListener('focusout', onFocusOut); viewport?.removeEventListener('pointerdown', onPointerDown, true); - viewport?.removeEventListener('pointermove', onPointerMove); - viewport?.removeEventListener('pointerup', onPointerUp); - viewport?.removeEventListener('pointercancel', onPointerCancel); + viewport?.removeEventListener('pointermove', onPointerMove, true); + viewport?.removeEventListener('pointerup', onPointerUp, true); + viewport?.removeEventListener('pointercancel', onPointerCancel, true); viewport?.removeEventListener('lostpointercapture', onLostPointerCapture); restoreIfNeeded(); clearDrive(); diff --git a/packages/studio/test/camera-drive-routing.test.tsx b/packages/studio/test/camera-drive-routing.test.tsx index 9d412e1..77b442e 100644 --- a/packages/studio/test/camera-drive-routing.test.tsx +++ b/packages/studio/test/camera-drive-routing.test.tsx @@ -484,12 +484,214 @@ describe('camera drive keyboard routing', () => { fireEvent.pointerDown(viewport, { button: 2, buttons: 2, pointerId: 15, clientX: 80, clientY: 50 }); fireEvent.pointerDown(document.body, { button: 2, buttons: 2, pointerId: 16 }); + expect(fireEvent.contextMenu(document.body)).toBe(false); + fireEvent.pointerUp(window, { button: 2, pointerId: 15 }); + expect(fireEvent.contextMenu(document.body)).toBe(false); expect(fireEvent.contextMenu(document.body)).toBe(true); const callsAtWindowRelease = look.mock.calls.length; fireEvent.pointerMove(viewport, { buttons: 2, pointerId: 12, clientX: 100, clientY: 60 }); expect(look).toHaveBeenCalledTimes(callsAtWindowRelease); }); + it.each([ + { label: 'touch pointer with a different id', pointerId: 72, pointerType: 'touch' }, + { label: 'pen pointer with a different id', pointerId: 73, pointerType: 'pen' }, + { label: 'touch pointer reusing the owner id', pointerId: 71, pointerType: 'touch' }, + ])('keeps the mouse context-menu owner through a secondary $label', async ({ pointerId, pointerType }) => { + const studio = await mountStudio(`lumora://drive-secondary-context-${pointerType}-${pointerId}`); + act(() => studio.handle.current!.runtime.editor.setSelection(['sample-camera'])); + const viewport = within(studio.root).getByTestId('lumora-viewport'); + Object.assign(viewport, { setPointerCapture: vi.fn(), releasePointerCapture: vi.fn() }); + await act(async () => delay(60)); + + fireEvent.pointerDown(viewport, { + button: 2, + buttons: 2, + pointerId: 71, + pointerType: 'mouse', + clientX: 40, + clientY: 40, + }); + const secondaryContextMenu = new PointerEvent('contextmenu', { + bubbles: true, + cancelable: true, + composed: true, + button: 2, + pointerId, + pointerType, + }); + expect(viewport.dispatchEvent(secondaryContextMenu)).toBe(false); + expect(secondaryContextMenu.defaultPrevented).toBe(true); + fireEvent.pointerUp(window, { button: 2, pointerId: 71, pointerType: 'mouse' }); + + expect(fireEvent.contextMenu(document.body)).toBe(false); + expect(fireEvent.contextMenu(document.body)).toBe(true); + }); + + it('keeps the active owner when a context-menu event has no pointer identity', async () => { + const studio = await mountStudio('lumora://drive-unknown-context-owner'); + act(() => studio.handle.current!.runtime.editor.setSelection(['sample-camera'])); + const viewport = within(studio.root).getByTestId('lumora-viewport'); + Object.assign(viewport, { setPointerCapture: vi.fn(), releasePointerCapture: vi.fn() }); + await act(async () => delay(60)); + + fireEvent.pointerDown(viewport, { + button: 2, + buttons: 2, + pointerId: 74, + pointerType: 'mouse', + clientX: 40, + clientY: 40, + }); + expect(fireEvent.contextMenu(viewport)).toBe(false); + fireEvent.pointerUp(window, { button: 2, pointerId: 74, pointerType: 'mouse' }); + + expect(fireEvent.contextMenu(document.body)).toBe(false); + expect(fireEvent.contextMenu(document.body)).toBe(true); + }); + + it.each([ + { ownerId: 81, ownerType: 'touch', secondaryId: 82, secondaryType: 'touch' }, + { ownerId: 83, ownerType: 'pen', secondaryId: 84, secondaryType: 'pen' }, + { ownerId: 85, ownerType: 'mouse', secondaryId: 85, secondaryType: 'pen' }, + ])( + 'matches context-menu ownership by pointer id and type ($ownerType -> $secondaryType)', + async ({ ownerId, ownerType, secondaryId, secondaryType }) => { + const studio = await mountStudio(`lumora://drive-context-owner-${ownerType}-${secondaryType}`); + const viewport = within(studio.root).getByTestId('lumora-viewport'); + await act(async () => delay(60)); + + fireEvent.pointerDown(viewport, { + button: 2, + buttons: 2, + pointerId: ownerId, + pointerType: ownerType, + clientX: 50, + clientY: 50, + }); + fireEvent.pointerDown(viewport, { + button: 0, + buttons: 1, + pointerId: secondaryId, + pointerType: secondaryType, + clientX: 60, + clientY: 60, + }); + fireEvent.pointerUp(window, { + button: 0, + pointerId: secondaryId, + pointerType: secondaryType, + }); + fireEvent.pointerUp(window, { + button: 2, + pointerId: ownerId, + pointerType: ownerType, + }); + + expect(fireEvent.contextMenu(document.body)).toBe(false); + expect(fireEvent.contextMenu(document.body)).toBe(true); + }, + ); + + it.each(['touch', 'pen'] as const)( + 'blocks a secondary %s pointer before selection and outer pointer handlers', + async (pointerType) => { + const studio = await mountStudio(`lumora://drive-secondary-${pointerType}`); + act(() => studio.handle.current!.runtime.editor.setSelection(['sample-camera'])); + const viewport = within(studio.root).getByTestId('lumora-viewport'); + Object.assign(viewport, { setPointerCapture: vi.fn(), releasePointerCapture: vi.fn() }); + const outerPointerDown = vi.fn(); + window.addEventListener('pointerdown', outerPointerDown); + await act(async () => delay(60)); + + fireEvent.pointerDown(viewport, { + button: 2, + buttons: 2, + pointerId: 91, + pointerType: 'mouse', + clientX: 40, + clientY: 40, + }); + outerPointerDown.mockClear(); + const secondaryPointerDown = new PointerEvent('pointerdown', { + bubbles: true, + cancelable: true, + composed: true, + button: 0, + buttons: 1, + pointerId: 92, + pointerType, + clientX: 50, + clientY: 50, + }); + const dispatched = viewport.dispatchEvent(secondaryPointerDown); + + expect(dispatched).toBe(false); + expect(secondaryPointerDown.defaultPrevented).toBe(true); + expect(outerPointerDown).not.toHaveBeenCalled(); + expect(studio.handle.current!.runtime.editor.getSelection()).toEqual(['sample-camera']); + window.removeEventListener('pointerdown', outerPointerDown); + }, + ); + + it.each([ + { label: 'touch pointer', pointerId: 102, pointerType: 'touch' }, + { label: 'pen pointer', pointerId: 103, pointerType: 'pen' }, + { label: 'pen pointer reusing the owner id', pointerId: 101, pointerType: 'pen' }, + ])( + 'blocks a pre-existing secondary $label move at the viewport capture boundary', + async ({ pointerId, pointerType }) => { + const studio = await mountStudio(`lumora://drive-pre-existing-secondary-${pointerType}-${pointerId}`); + act(() => studio.handle.current!.runtime.editor.setSelection(['sample-camera'])); + const viewport = within(studio.root).getByTestId('lumora-viewport'); + const canvas = within(viewport).getByTestId('mock-canvas'); + Object.assign(viewport, { setPointerCapture: vi.fn(), releasePointerCapture: vi.fn() }); + const innerPointerMove = vi.fn(); + const outerPointerMove = vi.fn(); + canvas.addEventListener('pointermove', innerPointerMove); + window.addEventListener('pointermove', outerPointerMove); + await act(async () => delay(60)); + + fireEvent.pointerDown(canvas, { + button: 0, + buttons: 1, + pointerId, + pointerType, + clientX: 30, + clientY: 30, + }); + fireEvent.pointerDown(viewport, { + button: 2, + buttons: 2, + pointerId: 101, + pointerType: 'mouse', + clientX: 40, + clientY: 40, + }); + const selectionBeforeMove = studio.handle.current!.runtime.editor.getSelection(); + const secondaryMove = new PointerEvent('pointermove', { + bubbles: true, + cancelable: true, + composed: true, + button: -1, + buttons: 1, + pointerId, + pointerType, + clientX: 50, + clientY: 50, + }); + const dispatched = canvas.dispatchEvent(secondaryMove); + + expect(dispatched).toBe(false); + expect(secondaryMove.defaultPrevented).toBe(true); + expect(innerPointerMove).not.toHaveBeenCalled(); + expect(outerPointerMove).not.toHaveBeenCalled(); + expect(studio.handle.current!.runtime.editor.getSelection()).toEqual(selectionBeforeMove); + canvas.removeEventListener('pointermove', innerPointerMove); + window.removeEventListener('pointermove', outerPointerMove); + }, + ); + it.each(['track', 'playback', 'export'] as const)( 'suppresses the viewport context menu while %s disables camera drive', async (owner) => {