Peter 1 年之前
父節點
當前提交
75e043638a

文件差異過大導致無法顯示
+ 54 - 0
playground/vue/src/pages/events/PointerCapture.vue


+ 5 - 0
playground/vue/src/router/routes/events.ts

@@ -64,6 +64,11 @@ export const eventsRoutes = [
     name: 'Vue Event Modifiers',
     component: () => import('../../pages/events/EventModifiers.vue'),
   },
+  {
+    path: '/events/pointer-capture',
+    name: 'Pointer Capture',
+    component: () => import('../../pages/events/PointerCapture.vue'),
+  },
   {
     path: '/events/deprecated-event-names',
     name: 'Deprecated Event Names',

+ 9 - 1
src/types/index.ts

@@ -75,6 +75,14 @@ export interface LocalState {
 export interface TresObject3D extends THREE.Object3D<THREE.Object3DEventMap> {
   geometry?: THREE.BufferGeometry & TresBaseObject
   material?: THREE.Material & TresBaseObject
+  // NOTE: Below are "fake" DOM Element methods that allow objects
+  // to communicate with Tres' `EventManager` about the pointer.
+  // Marked as optional to avoid interfering with existing types.
+  // TODO: Make non-optional?
+  // See: https://developer.mozilla.org/en-US/docs/Web/API/Element/setPointerCapture
+  setPointerCapture?: (pointerId: number) => void
+  releasePointerCapture?: (pointerId: number) => void
+  hasPointerCapture?: (pointerId: number) => boolean
 }
 
 export type TresObject =
@@ -112,7 +120,7 @@ export interface IntersectionEvent<TSourceEvent> extends Intersection {
   pointer: THREE.Vector2
   /** Delta between first click and this event */
   delta: number
-  /** The ray that pierced it */
+  /** The ray that pierced `eventObject` */
   ray: THREE.Ray
   /** The camera that was used by the raycaster */
   camera: THREE.Camera

+ 392 - 14
src/utils/createEventManager/eventsRaycast.test.ts

@@ -1,13 +1,13 @@
-import type { TresContext } from 'src/composables/useTresContextProvider'
-import type { ThreeEvent } from 'src/types'
-import type { Object3D } from 'three'
 import { BoxGeometry, MeshBasicMaterial, Scene, Vector3 } from 'three'
 import * as THREE from 'three'
 import { describe, expect, it, vi } from 'vitest'
 import { shallowRef } from 'vue'
+import type { TresContext } from 'src/composables/useTresContextProvider'
+import type { ThreeEvent, TresObject } from 'src/types'
+import type { Object3D } from 'three'
 import catalogue from '../../core/catalogue'
 import { nodeOps as getNodeOps } from '../../core/nodeOps'
-import { DOM_TO_THREE, type ThreeEventName } from './const'
+import { DOM_TO_THREE, type DomEventName, type ThreeEventName } from './const'
 import { createEventManager } from './createEventManager'
 import { eventsRaycast } from './eventsRaycast'
 
@@ -18,7 +18,7 @@ const clear = () => { vi.clearAllMocks(); mockIntersections.length = 0 }
 // `domEvent` is the type of the Tres handler to be triggered
 // `nativeEvent` is the canvas event that triggers the Tres handler
 // `call` triggers the Tres handler when run
-const DOM_NATIVE_CALL = [{
+const DOM_NATIVE_CALL: { domEvent: DomEventName, nativeEvent: PointerEvent | MouseEvent, call: (mock, nativeEvent, objects) => void }[] = [{
   domEvent: 'click',
   nativeEvent: new MouseEvent('click'),
   call: (mock, nativeEvent, objs) => { mock.apply(nativeEvent).to(objs) },
@@ -259,11 +259,14 @@ describe('eventsRaycast', () => {
         mock.nodeOps.patchProp(m, DOM_TO_THREE[domEvent], null, (e: ThreeEvent<any>) => { event = { ...e } })
         call(mock, nativeEvent, [m])
 
-        expect(typeof event.stopPropagation).toBe('function')
-        expect(typeof event.preventDefault).toBe('function')
         expect(event.eventObject).toBe(m)
         expect(event.eventObject).toBe(event.currentTarget)
         expect(event.target.uuid).toBe(m.uuid)
+        expect(typeof event.stopPropagation).toBe('function')
+        expect(typeof event.preventDefault).toBe('function')
+        expect(typeof event.eventObject.setPointerCapture).toBe('function')
+        expect(typeof event.eventObject.hasPointerCapture).toBe('function')
+        expect(typeof event.eventObject.releasePointerCapture).toBe('function')
 
         expect(event).toHaveProperty('intersections')
         expect(Array.isArray(event.intersections)).toBe(true)
@@ -488,7 +491,7 @@ describe('eventsRaycast', () => {
         { domEvent: 'dblclick' },
         { domEvent: 'pointerdown' },
         { domEvent: 'pointerup' },
-      ],
+      ] as { domEvent: DomEventName }[],
     )('$domEvent on the DOM element', ({ domEvent }) => {
       const threeEvent = DOM_TO_THREE[domEvent]
       it(`calls \`${threeEvent}\` methods on objects under pointer and ancestors`, () => {
@@ -572,7 +575,7 @@ describe('eventsRaycast', () => {
           { domEvent: 'click' },
           { domEvent: 'contextmenu' },
           { domEvent: 'dblclick' },
-        ],
+        ] as { domEvent: DomEventName }[],
       )('$domEvent', ({ domEvent }) => {
         it('calls `pointermissed` on all elements in subtrees that were not hit', () => {
           const mock = mockTresUsingEventManagerProps()
@@ -714,7 +717,8 @@ describe('eventsRaycast', () => {
         { domEventName: 'pointerdown', threeEventName: 'onPointerdown' },
         { domEventName: 'pointermove', threeEventName: 'onPointermove' },
         { domEventName: 'wheel', threeEventName: 'onWheel' },
-      ])('stops $domEventName', ({ domEventName, threeEventName }) => {
+      ] as { domEventName: DomEventName, threeEventName: ThreeEventName }[],
+      )('stops $domEventName', ({ domEventName, threeEventName }) => {
         const mock = mockTresUsingEventManagerProps()
         const { gListener, gStopper, mSource } = mock.add.DAG('gListener -> gStopper; gStopper -> mSource')
         const gListenerSpies = mock.add.eventsTo(gListener)
@@ -1621,6 +1625,373 @@ describe('eventsRaycast', () => {
       })
     })
   })
+  describe('{set,release,lost}pointercapture', () => {
+    const POINTER_ID = mockEvt('pointerdown').pointerId
+    describe('object.setPointerCapture(pointerId) in event handler', () => {
+      it('calls `setPointerCapture(pointerId)` on `eventManager`\'s DOM Element', () => {
+        const mock = mockTresUsingEventManagerProps()
+        const { m } = mock.add.DAG('m')
+        mock.nodeOps.patchProp(m, 'onPointerdown', undefined, (e) => {
+          e.eventObject.setPointerCapture(e.pointerId)
+        })
+
+        expect(mock.canvas.hasPointerCapture(POINTER_ID)).toBe(false)
+        mock.apply('pointerdown').to([m])
+        expect(mock.canvas.hasPointerCapture(POINTER_ID)).toBe(true)
+      })
+    })
+    describe('when a pointer is captured', () => {
+      it('adds captured object intersections to the front of `event.intersections`', () => {
+        const mock = mockTresUsingEventManagerProps()
+        const { m, n, o, p } = mock.add.DAG('m; n; o; p')
+        mock.add.eventsTo(m)
+        mock.add.eventsTo(n)
+        mock.add.eventsTo(o)
+        mock.add.eventsTo(p)
+        mock.nodeOps.patchProp(m, 'onPointerdown', undefined, (e) => {
+          e.eventObject.setPointerCapture(e.pointerId)
+        })
+        mock.nodeOps.patchProp(p, 'onPointermove', undefined, (e) => {
+          e.eventObject.setPointerCapture(e.pointerId)
+        })
+
+        mock.apply('pointerdown').to([m])
+        mock.apply('pointermove').to([n])
+        expect(getLast('pointermove').on(n).intersections.length).toBe(2)
+        expect(getLast('pointermove').on(n).intersections[0].eventObject).toBe(m)
+
+        mock.apply('pointermove').to([n, o])
+        expect(getLast('pointermove').on(n).intersections.length).toBe(3)
+        expect(getLast('pointermove').on(n).intersections[0].eventObject).toBe(m)
+
+        // NOTE: This adds p to pointer capture
+        mock.apply('pointermove').to([n, p])
+        expect(getLast('pointermove').on(n).intersections[0].eventObject.name).toBe('m')
+        expect(getLast('pointermove').on(n).intersections[1].eventObject.name).toBe('n')
+        expect(getLast('pointermove').on(n).intersections[2].eventObject.name).toBe('p')
+
+        mock.apply('pointermove').to([n, o, m, p])
+        expect(getLast('pointermove').on(n).intersections.length).toBe(4)
+        expect(getLast('pointermove').on(n).intersections[0].eventObject.name).toBe('p')
+        expect(getLast('pointermove').on(n).intersections[1].eventObject.name).toBe('m')
+        expect(getLast('pointermove').on(n).intersections[2].eventObject.name).toBe('n')
+        expect(getLast('pointermove').on(n).intersections[3].eventObject.name).toBe('o')
+
+        mock.apply('pointermove').to([n, o])
+        expect(getLast('pointermove').on(n).intersections.length).toBe(4)
+        expect(getLast('pointermove').on(n).intersections[0].eventObject.name).toBe('p')
+        expect(getLast('pointermove').on(n).intersections[1].eventObject.name).toBe('m')
+        expect(getLast('pointermove').on(n).intersections[2].eventObject.name).toBe('n')
+        expect(getLast('pointermove').on(n).intersections[3].eventObject.name).toBe('o')
+
+        mock.apply('pointermove').to([])
+        expect(getLast('pointermove').on(m).intersections.length).toBe(2)
+        expect(getLast('pointermove').on(m).intersections[0].eventObject.name).toBe('p')
+        expect(getLast('pointermove').on(m).intersections[1].eventObject.name).toBe('m')
+      })
+      it.only('calls object\'s event handlers, if they exist, even if the object is not hit', () => {
+        const mock = mockTresUsingEventManagerProps()
+        const { g, m } = mock.add.DAG('g -> m')
+        mock.add.eventsTo(g)
+        mock.add.eventsTo(m)
+        mock.nodeOps.patchProp(m, 'onPointerdown', undefined, (e) => {
+          e.eventObject.setPointerCapture(e.pointerId)
+        })
+
+        mock.apply('pointerdown').to([m])
+
+        expect(getLast('pointermove').on(g)).toBeNull()
+        expect(getLast('pointermove').on(m)).toBeNull()
+        mock.apply('pointermove').to([])
+        expect(getLast('pointermove').on(g)).not.toBeNull()
+        expect(getLast('pointermove').on(m)).not.toBeNull()
+      })
+      it('calls pointer{over,out,enter,leave}', () => {
+        const mock = mockTresUsingEventManagerProps()
+        const { g, m, n, o } = mock.add.DAG('g -> m; n; o')
+        mock.add.eventsTo(g)
+        mock.add.eventsTo(m)
+        mock.add.eventsTo(n)
+        mock.add.eventsTo(o)
+        mock.nodeOps.patchProp(m, 'onPointerdown', undefined, (e) => {
+          e.eventObject.setPointerCapture(e.pointerId)
+        })
+
+        mock.apply('pointerdown').to([m])
+      })
+    })
+    describe('object.releasePointerCapture(pointerId)', () => {
+      it('calls `releasePointerCapture(pointerId)` on `eventManager`\'s DOM Element if there are no remaining captures', () => {
+        const mock = mockTresUsingEventManagerProps()
+        const { m, n } = mock.add.DAG('m; n')
+        mock.nodeOps.patchProp(m, 'onPointerdown', undefined, (e) => {
+          e.eventObject.setPointerCapture(e.pointerId)
+        })
+        mock.nodeOps.patchProp(m, 'onPointermove', undefined, (e) => {
+          e.eventObject.releasePointerCapture(e.pointerId)
+        })
+
+        let COUNT_UNTIL_RELEASE = 5
+        mock.nodeOps.patchProp(n, 'onPointerdown', undefined, (e) => {
+          e.eventObject.setPointerCapture(e.pointerId)
+        })
+        mock.nodeOps.patchProp(n, 'onPointermove', undefined, (e) => {
+          if (COUNT_UNTIL_RELEASE-- <= 0) {
+            e.eventObject.releasePointerCapture(e.pointerId)
+          }
+        })
+
+        mock.apply('pointerdown').to([m, n])
+        expect(mock.canvas.hasPointerCapture(POINTER_ID)).toBe(true)
+
+        mock.apply('pointermove').to([m, n])
+        expect(mock.canvas.hasPointerCapture(POINTER_ID)).toBe(true)
+
+        mock.apply('pointermove').to([m, n])
+        mock.apply('pointermove').to([m, n])
+        mock.apply('pointermove').to([m, n])
+        mock.apply('pointermove').to([m, n])
+        mock.apply('pointermove').to([m, n])
+        expect(mock.canvas.hasPointerCapture(POINTER_ID)).toBe(false)
+      })
+      it('does not call `releasePointerCapture(pointerId)` on `eventManager`\'s DOM Element if there are remaining captures', () => {
+        const mock = mockTresUsingEventManagerProps()
+        interface PointerCapture {
+          setPointerCapture: (id: number) => void
+          releasePointerCapture: (id: number) => void
+          hasPointerCapture: (id: number) => void
+        }
+        const { m, n } = mock.add.DAG('m; n') as Record<string, TresObject & Object3D & PointerCapture>
+
+        let OBJECTS_TO_CAPTURE = [m, n] as (Object3D & { setPointerCapture?: (id: number) => void })[]
+        let OBJECTS_TO_RELEASE = [] as (Object3D & { releasePointerCapture?: (id: number) => void })[]
+
+        const down = (e) => {
+          e.eventObject.setPointerCapture(e.pointerId)
+        }
+
+        const move = (e) => {
+          for (const o of OBJECTS_TO_CAPTURE) {
+            o.setPointerCapture(e.pointerId)
+          }
+          for (const o of OBJECTS_TO_RELEASE) {
+            o.releasePointerCapture(e.pointerId)
+          }
+        }
+
+        mock.nodeOps.patchProp(m, 'onPointerdown', undefined, down)
+        mock.nodeOps.patchProp(n, 'onPointerdown', undefined, down)
+        mock.nodeOps.patchProp(m, 'onPointermove', undefined, move)
+        mock.nodeOps.patchProp(n, 'onPointermove', undefined, move)
+
+        mock.apply('pointerdown').to([m, n])
+        expect(mock.canvas.hasPointerCapture(POINTER_ID)).toBe(true)
+        expect(m.hasPointerCapture(POINTER_ID)).toBe(true)
+        expect(n.hasPointerCapture(POINTER_ID)).toBe(true)
+        expect(mock.canvas.hasPointerCapture(POINTER_ID)).toBe(true)
+
+        mock.apply('pointermove').to([])
+        expect(m.hasPointerCapture(POINTER_ID)).toBe(true)
+        expect(n.hasPointerCapture(POINTER_ID)).toBe(true)
+        expect(mock.canvas.hasPointerCapture(POINTER_ID)).toBe(true)
+
+        OBJECTS_TO_RELEASE = [m]
+        OBJECTS_TO_CAPTURE = [n]
+        mock.apply('pointermove').to([])
+        expect(m.hasPointerCapture(POINTER_ID)).toBe(false)
+        expect(n.hasPointerCapture(POINTER_ID)).toBe(true)
+        expect(mock.canvas.hasPointerCapture(POINTER_ID)).toBe(true)
+
+        OBJECTS_TO_RELEASE = [m]
+        OBJECTS_TO_CAPTURE = []
+        mock.apply('pointermove').to([])
+        expect(m.hasPointerCapture(POINTER_ID)).toBe(false)
+        expect(n.hasPointerCapture(POINTER_ID)).toBe(true)
+        expect(mock.canvas.hasPointerCapture(POINTER_ID)).toBe(true)
+
+        OBJECTS_TO_RELEASE = [n]
+        OBJECTS_TO_CAPTURE = [m]
+        mock.apply('pointermove').to([])
+        expect(m.hasPointerCapture(POINTER_ID)).toBe(true)
+        expect(n.hasPointerCapture(POINTER_ID)).toBe(false)
+        expect(mock.canvas.hasPointerCapture(POINTER_ID)).toBe(true)
+
+        OBJECTS_TO_RELEASE = [m]
+        OBJECTS_TO_CAPTURE = [n]
+        mock.apply('pointermove').to([n])
+        expect(mock.context.eventManager.config.pointerToCapturedObjects.get(POINTER_ID).size).toBe(1)
+        expect(m.hasPointerCapture(POINTER_ID)).toBe(false)
+        expect(n.hasPointerCapture(POINTER_ID)).toBe(true)
+        expect(mock.canvas.hasPointerCapture(POINTER_ID)).toBe(true)
+
+        OBJECTS_TO_RELEASE = []
+        OBJECTS_TO_CAPTURE = [m]
+        mock.apply('pointermove').to([])
+        expect(m.hasPointerCapture(POINTER_ID)).toBe(true)
+        expect(n.hasPointerCapture(POINTER_ID)).toBe(true)
+        expect(mock.canvas.hasPointerCapture(POINTER_ID)).toBe(true)
+
+        OBJECTS_TO_RELEASE = [m]
+        OBJECTS_TO_CAPTURE = [n]
+        mock.apply('pointermove').to([])
+        expect(m.hasPointerCapture(POINTER_ID)).toBe(false)
+        expect(n.hasPointerCapture(POINTER_ID)).toBe(true)
+        expect(mock.canvas.hasPointerCapture(POINTER_ID)).toBe(true)
+
+        OBJECTS_TO_RELEASE = [n]
+        OBJECTS_TO_CAPTURE = []
+        mock.apply('pointermove').to([])
+        expect(mock.canvas.hasPointerCapture(POINTER_ID)).toBe(false)
+      })
+      describe('pointerup', () => {
+        it('releases a single pointer capture', () => {
+          const mock = mockTresUsingEventManagerProps()
+          const { m } = mock.add.DAG('m')
+          mock.nodeOps.patchProp(m, 'onPointerdown', undefined, (e) => {
+            e.eventObject.setPointerCapture(e.pointerId)
+          })
+
+          mock.apply('pointerdown').to([m])
+          expect(mock.canvas.hasPointerCapture(POINTER_ID)).toBe(true)
+          mock.apply('pointerup').to([])
+          expect(mock.canvas.hasPointerCapture(POINTER_ID)).toBe(false)
+        })
+        it('releases all pointer captures for a pointerId', () => {
+          const mock = mockTresUsingEventManagerProps()
+          const { m, n, o, p } = mock.add.DAG('m; n; o; p')
+          function down(e) { e.eventObject.setPointerCapture(e.pointerId) }
+          mock.nodeOps.patchProp(m, 'onPointerdown', undefined, down)
+          mock.nodeOps.patchProp(n, 'onPointerdown', undefined, down)
+          mock.nodeOps.patchProp(o, 'onPointerdown', undefined, down)
+          mock.nodeOps.patchProp(p, 'onPointerdown', undefined, down)
+
+          const POINTER_ID_A = 10
+          const eventA = mockEvt('pointerdown', { pointerId: POINTER_ID_A, currentTarget: mock.canvas }) as any
+          mock.apply(eventA).to([m, n, o, p])
+          expect(mock.canvas.hasPointerCapture(eventA.pointerId)).toBe(true)
+
+          const POINTER_ID_B = 99
+          const eventB = mockEvt('pointerdown', { pointerId: POINTER_ID_B, currentTarget: mock.canvas }) as any
+          mock.apply(eventB).to([m])
+          expect(mock.canvas.hasPointerCapture(eventB.pointerId)).toBe(true)
+
+          eventB.type = 'pointerup'
+          mock.apply(eventB).to([])
+          expect(mock.canvas.hasPointerCapture(eventB.pointerId)).toBe(false)
+
+          expect(mock.canvas.hasPointerCapture(eventA.pointerId)).toBe(true)
+          eventA.type = 'pointerup'
+          mock.apply(eventA).to([m, n, o, p])
+          expect(mock.canvas.hasPointerCapture(eventA.pointerId)).toBe(false)
+        })
+      })
+      describe('pointercancel', () => {
+        it('releases a single pointer capture', () => {
+          const mock = mockTresUsingEventManagerProps()
+          const { m } = mock.add.DAG('m')
+          mock.nodeOps.patchProp(m, 'onPointerdown', undefined, (e) => {
+            e.eventObject.setPointerCapture(e.pointerId)
+          })
+
+          mock.apply('pointerdown').to([m])
+          expect(mock.canvas.hasPointerCapture(POINTER_ID)).toBe(true)
+          mock.apply('pointercancel').to([])
+          expect(mock.canvas.hasPointerCapture(POINTER_ID)).toBe(false)
+        })
+        it('releases all pointer captures', () => {
+          const mock = mockTresUsingEventManagerProps()
+          const { m, n, o, p } = mock.add.DAG('m; n; o; p')
+          function down(e) { e.eventObject.setPointerCapture(e.pointerId) }
+          mock.nodeOps.patchProp(m, 'onPointerdown', undefined, down)
+          mock.nodeOps.patchProp(n, 'onPointerdown', undefined, down)
+          mock.nodeOps.patchProp(o, 'onPointerdown', undefined, down)
+          mock.nodeOps.patchProp(p, 'onPointerdown', undefined, down)
+
+          const POINTER_ID_A = 10
+          const eventA = mockEvt('pointerdown', { pointerId: POINTER_ID_A, currentTarget: mock.canvas }) as any
+          mock.apply(eventA).to([m, n, o, p])
+          expect(mock.canvas.hasPointerCapture(eventA.pointerId)).toBe(true)
+
+          const POINTER_ID_B = 99
+          const eventB = mockEvt('pointerdown', { pointerId: POINTER_ID_B, currentTarget: mock.canvas }) as any
+          mock.apply(eventB).to([m])
+          expect(mock.canvas.hasPointerCapture(eventB.pointerId)).toBe(true)
+
+          eventB.type = 'pointercancel'
+          mock.apply(eventB).to([])
+          expect(mock.canvas.hasPointerCapture(eventB.pointerId)).toBe(false)
+
+          expect(mock.canvas.hasPointerCapture(eventA.pointerId)).toBe(true)
+          eventA.type = 'pointercancel'
+          mock.apply(eventA).to([m, n, o, p])
+          expect(mock.canvas.hasPointerCapture(eventA.pointerId)).toBe(false)
+        })
+      })
+    })
+    describe('lostpointercapture', () => {
+      it('exists', () => {
+        const mock = mockTresUsingEventManagerProps()
+        const { m } = mock.add.DAG('m')
+        mock.add.eventsTo(m)
+        expect('onLostpointercapture' in m).toBe(true)
+      })
+      it('is called when releasing the pointer via `e.target.releasePointerCapture`', () => {
+        const mock = mockTresUsingEventManagerProps()
+        const { m } = mock.add.DAG('m')
+        mock.add.eventsTo(m)
+        mock.nodeOps.patchProp(m, 'onPointerdown', undefined, (e) => {
+          e.eventObject.setPointerCapture(e.pointerId)
+          e.target.releasePointerCapture(e.pointerId)
+        })
+        mock.apply('pointerdown').to([m])
+        expect(getLast('lostpointercapture').on(m)).toBeDefined()
+      })
+      it('is called when releasing the pointer via `pointercancel`', () => {
+        const mock = mockTresUsingEventManagerProps()
+        const { m } = mock.add.DAG('m')
+        mock.add.eventsTo(m)
+        mock.nodeOps.patchProp(m, 'onPointerdown', undefined, (e) => {
+          e.eventObject.setPointerCapture(e.pointerId)
+        })
+
+        mock.apply('pointerdown').to([m])
+        expect(getLast('lostpointercapture').on(m)).toBeNull()
+        mock.apply('pointercancel').to([])
+        expect(getLast('lostpointercapture').on(m)).not.toBeNull()
+      })
+      it('is called with expected event object fields', () => {
+        const mock = mockTresUsingEventManagerProps()
+        const { m } = mock.add.DAG('m')
+        mock.add.eventsTo(m)
+        mock.nodeOps.patchProp(m, 'onPointerdown', undefined, (e) => {
+          e.eventObject.setPointerCapture(e.pointerId)
+        })
+        let event = null
+        mock.nodeOps.patchProp(m, 'onLostpointercapture', undefined, (e) => {
+          event = { ...e }
+        })
+        mock.apply('pointerdown').to([m])
+        expect(getLast('lostpointercapture').on(m)).toBeNull()
+        mock.apply('pointercancel').to([])
+
+        expect(event.type).toBe('lostpointercapture')
+        expect(event.target.uuid).toBe(m.uuid)
+      })
+      it('is not called on objects that don\'t have the pointer capture', () => {
+        const mock = mockTresUsingEventManagerProps()
+        const { m, n } = mock.add.DAG('m; n')
+        mock.add.eventsTo(m, n)
+        mock.nodeOps.patchProp(m, 'onPointerdown', undefined, (e) => {
+          e.eventObject.setPointerCapture(e.pointerId)
+        })
+
+        mock.apply('pointerdown').to([m])
+        expect(getLast('lostpointercapture').on(n)).toBeNull()
+        mock.apply('pointercancel').to([])
+        expect(getLast('lostpointercapture').on(n)).toBeNull()
+      })
+    })
+  })
 })
 
 function mockTresUsingEventManagerProps(props = eventsRaycast) {
@@ -1650,6 +2021,7 @@ function mockTresUsingEventManagerProps(props = eventsRaycast) {
   }
 
   const canvasEvents = { }
+  const canvasPointerCapture: Set<number> = new Set()
   const canvas = {
     addEventListener: (eventName, fn) => {
       canvasEvents[eventName] = fn
@@ -1657,6 +2029,11 @@ function mockTresUsingEventManagerProps(props = eventsRaycast) {
     removeEventListener: (eventName) => {
       delete canvasEvents[eventName]
     },
+    setPointerCapture: (pointerId: number) => {
+      canvasPointerCapture.add(pointerId)
+    },
+    releasePointerCapture: (pointerId: number) => { canvasPointerCapture.delete(pointerId) },
+    hasPointerCapture: (pointerId: number) => canvasPointerCapture.has(pointerId),
     _call: (eventName, pointer) => {
       if (eventName in canvasEvents) {
         canvasEvents[eventName](pointer)
@@ -1771,9 +2148,9 @@ function mockTresUsingEventManagerProps(props = eventsRaycast) {
     mockIntersection: () => mockIntersections,
   }
 
-  const apply = (eventOrEvents: (string | Record<string, any>) | (string | Record<string, any>)[]) => {
+  const apply = (eventOrEvents: (DomEventName | Record<string, any>) | (string | Record<string, any>)[]) => {
     const events = (Array.isArray(eventOrEvents) ? eventOrEvents : [eventOrEvents])
-      .map(stringOrObject => typeof stringOrObject === 'string' ? mockEvt(stringOrObject) : stringOrObject)
+      .map(stringOrObject => typeof stringOrObject === 'string' ? mockEvt(stringOrObject, { target: canvas, currentTarget: canvas }) : stringOrObject)
     return { to: (intersectedGroupOrGroups: Object3D[] | Object3D[][]) => {
       if (intersectedGroupOrGroups.length === 0) {
         // NOTE: We have no groups of objects.
@@ -1812,13 +2189,14 @@ function mockTresUsingEventManagerProps(props = eventsRaycast) {
       }
     } }
   }
+
   return { canvas, context, nodeOps, add, get, set, clear, apply }
 }
 
 function mockEvt(type: PointerEvent['type'], options: Record<string, any> = {}) {
-  return { type, ...options, stopPropagation: () => {}, preventDefault: () => {} } as PointerEvent
+  return { type, pointerId: 42, ...options, stopPropagation: () => {}, preventDefault: () => {} } as PointerEvent
 }
 
-function getLast(domEventName: string) {
+function getLast(domEventName: DomEventName) {
   return { on: (obj: Object3D) => obj.userData[DOM_TO_THREE[domEventName]] ?? null }
 }

+ 218 - 66
src/utils/createEventManager/eventsRaycast.ts

@@ -1,12 +1,12 @@
-import type { Object3D, Intersection as ThreeIntersection } from 'three'
-import { isProd, useLogger, type TresContext } from '../../composables'
-import type { EventHandlers, IntersectionEvent, Properties, ThreeEvent, TresCamera, TresInstance, TresObject } from '../../types'
-import type { CreateEventManagerProps } from './createEventManager'
 import { Raycaster, Vector2, Vector3 } from 'three'
+import type { Object3D, Intersection as ThreeIntersection } from 'three'
 import { prepareTresInstance } from '..'
+import { isProd, type TresContext, useLogger } from '../../composables'
 import * as is from '../is'
 import { DOM_EVENT_NAMES, DOM_TO_PASSIVE, DOM_TO_THREE, type DomEventName, type DomEventTarget, POINTER_EVENT_NAMES, THREE_EVENT_NAMES } from './const'
 import { deprecatedEventsToNewEvents } from './deprecatedEvents'
+import type { EventHandlers, IntersectionEvent, Properties, ThreeEvent, TresCamera, TresInstance, TresObject } from '../../types'
+import type { CreateEventManagerProps } from './createEventManager'
 
 // NOTE:
 // This file consists of type definitions and functions
@@ -14,16 +14,12 @@ import { deprecatedEventsToNewEvents } from './deprecatedEvents'
 //
 // In particular, see `handle` in `./createEventManager` to
 // see the call order of the functions here.
-//
-// For portability and simplicity, note that the functions
-// here act only on their arguments and do not reach out to
-// the enclosing scope. Please attempt to keep it that way.
 
 // NOTE: Aliasing these and grouping here to make
 // future modifications simpler if Event type changes.
 type RaycastEvent = MouseEvent | PointerEvent | WheelEvent
 type RaycastEventTarget = DomEventTarget
-type ThreeEventStub<DomEvent> = Omit<ThreeEvent<DomEvent>, 'eventObject' | 'object' | 'currentTarget' | 'target' | 'distance' | 'point'> & Partial<IntersectionEvent<DomEvent>> & { currentTarget: TresObject | null | undefined }
+type ThreeEventStub<DomEvent> = Omit<ThreeEvent<DomEvent>, 'eventObject' | 'object' | 'currentTarget' | 'target' | 'distance' | 'point'> & Partial<IntersectionEvent<DomEvent>>
 type Object3DWithEvents = Object3D & EventHandlers
 
 function getInitialEvent() {
@@ -44,8 +40,14 @@ type RaycastProps = CreateEventManagerProps<
 >
 type Config = ReturnType<typeof getInitialConfig>
 
+export interface PointerCaptureTarget {
+  event: ThreeEvent<PointerEvent>
+  target: Element
+}
+
 function getInitialConfig(context: TresContext) {
   return {
+    DEBUG: [],
     context,
 
     isEventsDirty: true,
@@ -66,6 +68,10 @@ function getInitialConfig(context: TresContext) {
     blockingObjects: new Set<Object3D>(),
 
     lastMoveEvent: getInitialEvent(),
+
+    pointerToCapturedIntersections: new Map() as Map<number, Set<ThreeIntersection>>,
+    pointerToCapturedObjects: new Map() as Map<number, Set<TresObject>>,
+    capturableEvent: undefined as ThreeEvent<PointerEvent> | undefined,
   }
 }
 
@@ -214,11 +220,101 @@ function patchProp(instance: TresObject, propName: string, prevValue: any, nextV
 
   instance[propName] = is.arr(nextValue) ? (event: ThreeEvent<unknown>) => nextValue.forEach(fn => fn(event)) : nextValue
 
+  instance.setPointerCapture = (pointerId: number) => setPointerCapture(pointerId, instance, config)
+  instance.releasePointerCapture = (pointerId: number) => releasePointerCapture(pointerId, instance, config)
+  instance.hasPointerCapture = (pointerId: number) => hasPointerCapture(pointerId, instance, config) ?? false
+
   config.isEventsDirty = true
 
   return true
 }
 
+function setPointerCapture(pointerId: number, instance: TresObject, config: Config) {
+  if (!config.capturableEvent) {
+    // NOTE: If this is called outside of an event handler
+    // `config.capturableEvent` will be undefined.
+    // Like in the DOM, it should fail silently if
+    // the method cannot complete.
+    return
+  }
+
+  if (!config.pointerToCapturedIntersections.has(pointerId)) {
+    config.pointerToCapturedIntersections.set(pointerId, new Set())
+  }
+
+  if (!config.pointerToCapturedObjects.has(pointerId)) {
+    config.pointerToCapturedObjects.set(pointerId, new Set())
+  }
+
+  config.pointerToCapturedIntersections.get(pointerId)!.add({ ...config.capturableEvent })
+  config.pointerToCapturedObjects.get(pointerId)!.add(instance)
+
+  // NOTE: call `setPointerCapture` on the actual
+  // DOM element e.g. the canvas.
+  const maybeDOMElement = config.capturableEvent.nativeEvent?.currentTarget
+  if (maybeDOMElement && 'setPointerCapture' in maybeDOMElement && is.fun(maybeDOMElement.setPointerCapture)) {
+    maybeDOMElement.setPointerCapture(pointerId)
+  }
+}
+
+function releasePointerCapture(pointerId: number, instance: TresObject, config: Config) {
+  let removedIntersection: ThreeIntersection | null = null
+
+  // NOTE: Delete the event
+  if (config.pointerToCapturedIntersections.has(pointerId)) {
+    const capturedIntersectionsForPointer = config.pointerToCapturedIntersections.get(pointerId)!
+    for (const intersection of capturedIntersectionsForPointer) {
+      if (intersection.object === instance) {
+        capturedIntersectionsForPointer.delete(intersection)
+        removedIntersection = intersection
+      }
+    }
+    if (capturedIntersectionsForPointer.size === 0) {
+      config.pointerToCapturedIntersections.delete(pointerId)
+    }
+  }
+
+  // NOTE: Delete the object
+  if (config.pointerToCapturedObjects.has(pointerId)) {
+    const capturedObjectsForPointer = config.pointerToCapturedObjects.get(pointerId)!
+    capturedObjectsForPointer.delete(instance)
+
+    if (capturedObjectsForPointer.size === 0) {
+      config.pointerToCapturedObjects.delete(pointerId)
+    }
+
+    if (instance && 'onLostpointercapture' in instance && is.fun(instance.onLostpointercapture)) {
+      const object = removedIntersection!.object
+      instance.onLostpointercapture(
+        Object.assign(
+          removedIntersection!,
+          {
+            type: 'lostpointercapture',
+            eventObject: object,
+            currentTarget: object,
+            target: object,
+            stopPropagation: () => {},
+            preventDefault: () => {},
+            stopped: false,
+          },
+        ),
+      )
+    }
+  }
+
+  // NOTE: If no objects are still capturing, release the DOM element
+  if (!config.pointerToCapturedObjects.get(pointerId)) {
+    const maybeDomElement = removedIntersection?.nativeEvent?.currentTarget
+    if (maybeDomElement && 'releasePointerCapture' in maybeDomElement && is.fun(maybeDomElement.releasePointerCapture)) {
+      maybeDomElement.releasePointerCapture(pointerId)
+    }
+  }
+}
+
+function hasPointerCapture(pointerId: number, instance: TresObject, config: Config) {
+  return config.pointerToCapturedObjects.get(pointerId)?.has(instance)
+}
+
 function remove(instance: TresObject, config: Config) {
   if (!instance.isObject3D) { return }
 
@@ -245,6 +341,14 @@ function remove(instance: TresObject, config: Config) {
 
   const intersections = config.priorIntersections.filter(intersection => !instanceAndDescendants.has(intersection.object))
 
+  // NOTE: Remove references from pointer capture data
+  for (const instance of instanceAndDescendants) {
+    for (const [pointerId, objects] of config.pointerToCapturedObjects) {
+      for (const object of objects) {
+        if (object === instance) { releasePointerCapture(pointerId, object, config) }
+      }
+    }
+  }
   // NOTE: We will call `pointerout` and `pointerleave` if the to-be-removed
   // object was under the mouse. That logic is contained within `handleEvent`.
   // So rehandle the saved event, but without instance and its descendants
@@ -252,20 +356,39 @@ function remove(instance: TresObject, config: Config) {
   handleIntersections(getLastEvent(config), intersections, config)
 
   // NOTE: Remove the remaining traces of the object and descendants
-  for (const instance of instanceAndDescendants) {
-    config.priorHits.delete(instance)
-  }
+  config.priorHits.delete(instance as Object3D)
+
   config.isEventsDirty = true
 }
 
 function handleIntersections(incomingEvent: RaycastEvent, intersections: ThreeIntersection[], config: Config) {
   // NOTE: STRUCTURE OF THIS FUNCTION BODY
+  // 0) Add captured intersections if pointer is captured.
   // 1) Gather `hits`, `hitsEntered` and `hitsLeft`.
   // The `hits` Set typically includes all `intersections`
   // objects and their ancestors.
   // 2) Create an outgoing event "stub" with the fields
   // common to all events handlers will receive.
-  // 3) Call event handlers
+  // 3) Call event handlers.
+  // 4) Release the pointer, if necessary.
+  // 5) `null` the event.
+  // 6) Save data for next event.
+
+  // NOTE:
+  // 0) Add capturing objects' intersections if pointer is captured.
+  const HAS_POINTER_CAPTURE = ('pointerId' in incomingEvent && config.pointerToCapturedIntersections.has(incomingEvent.pointerId))
+  if (HAS_POINTER_CAPTURE) {
+    // NOTE: We want to preserve intersections order and only add
+    // capturing objects' intersections if they aren't already
+    // present. So check if `intersections` already has the capturing
+    // object. And push it if not.
+    const objectsInIntersections = new Set(intersections.map(intr => intr.object))
+    for (const intersection of config.pointerToCapturedIntersections.get(incomingEvent.pointerId)!) {
+      if (!objectsInIntersections.has(intersection.object)) {
+        intersections.push(intersection)
+      }
+    }
+  }
 
   // NOTE:
   // 1) Gather `hits`
@@ -291,8 +414,10 @@ function handleIntersections(incomingEvent: RaycastEvent, intersections: ThreeIn
   const hitsEntered = new Set<Object3D>()
 
   const initialHits = new Set<Object3D>()
+
   const filteredIntersections = []
   const eventIntersections = []
+
   let obj: TresObject | null = null
   let hasBlockingObject = false
   for (const intersection of intersections) {
@@ -347,6 +472,8 @@ function handleIntersections(incomingEvent: RaycastEvent, intersections: ThreeIn
   const outgoingEvent: ThreeEventStub<typeof incomingEvent> = Object.assign(
     getEventNonFunctionProperties(incomingEvent),
     {
+      // NOTE: `currentTarget` on `incomingEvent` is a DOMElement. Not the proper type for `outgoingEvent`.
+      currentTarget: undefined,
       intersections: eventIntersections,
       unprojectedPoint,
       pointer: config.pointer,
@@ -363,20 +490,30 @@ function handleIntersections(incomingEvent: RaycastEvent, intersections: ThreeIn
     },
   )
 
-  outgoingEvent.stopPropagation = () => { outgoingEvent.stopped = true; incomingEvent.stopPropagation() }
+  // NOTE: If a pointer is captured, we *must* fire
+  // the captured objects' events. So disallow
+  // stopping propagation for Tres events.
+  outgoingEvent.stopPropagation = HAS_POINTER_CAPTURE
+    ? () => { incomingEvent.stopPropagation() }
+    : () => { outgoingEvent.stopped = true; incomingEvent.stopPropagation() }
+
+  if (incomingEvent.type.startsWith('pointer') && !(incomingEvent.type === 'pointerup' || incomingEvent.type === 'pointercancel')) {
+    config.capturableEvent = outgoingEvent as ThreeEvent<PointerEvent>
+  }
 
   // NOTE:
   // 3) Propagate the events to handlers.
-  //
+
   // NOTE: Call `pointermissed`.
   // `pointermissed` is not bubbled and cannot be stopped with `stopPropagation`.
-  if (incomingEvent.type === 'click' || incomingEvent.type === 'dblclick' || incomingEvent.type === 'contextmenu') {
+  if (incomingEvent.type === 'pointerup') {
     outgoingEvent.stopped = false
     for (const obj of config.objectsWithEvents) {
       if (hits.has(obj)) { continue }
       outgoingEvent.eventObject = obj
-      outgoingEvent.currentTarget = obj
+      outgoingEvent.object = obj
       // NOTE: All misses are "self" misses: `currentTarget` matches `target`
+      outgoingEvent.currentTarget = obj
       outgoingEvent.target = obj
       outgoingEvent.eventObject[DOM_TO_THREE['pointermissed' as DomEventName]]?.(outgoingEvent)
     }
@@ -386,62 +523,61 @@ function handleIntersections(incomingEvent: RaycastEvent, intersections: ThreeIn
   if (hitsLeft.size) {
     // NOTE: Propagate `pointerleave`
     // `pointerleave` is not bubbled and cannot be stopped with `stopPropagation`.
-    // TODO: Should use config.priorHits, not config.priorIntersections
     callIntersectionObjectsIf('pointerleave', outgoingEvent, config.priorIntersections, (obj: Object3D) => { return hitsLeft.has(obj) })
-  }
 
-  {
+    {
     // NOTE: Bubble `pointerout`
-    // NOTE: Should use config.initialHits, not config.priorIntersections
-    const duplicates = new Set()
-    outgoingEvent.stopped = false
-    for (const intersection of config.priorIntersections) {
-      if (outgoingEvent.stopped) { break }
-
-      let object: TresObject | null = intersection.object
-      if (initialHits.has(object) || duplicates.has(object)) { continue }
-
-      // NOTE: An event "is-a" `Intersection`,
-      // so copy intersection values to the event.
-      Object.assign(outgoingEvent, intersection)
-      outgoingEvent.target = object
-
-      while (object && !outgoingEvent.stopped && !duplicates.has(object)) {
-        outgoingEvent.eventObject = object
-        outgoingEvent.currentTarget = object
-        object.onPointerout?.(outgoingEvent)
-        duplicates.add(object)
-        object = object.parent
+    // `pointerout` is bubbled and can be stopped with `stopPropagation`.
+      const duplicates = new Set()
+      outgoingEvent.stopped = false
+      for (const intersection of config.priorIntersections) {
+        if (outgoingEvent.stopped) { break }
+
+        let object: TresObject | null = intersection.object
+        if (initialHits.has(object) || duplicates.has(object)) { continue }
+
+        // NOTE: An event "is-a" `Intersection`,
+        // so copy intersection values to the event.
+        Object.assign(outgoingEvent, intersection)
+        outgoingEvent.target = object
+
+        while (object && !outgoingEvent.stopped && !duplicates.has(object)) {
+          outgoingEvent.eventObject = object
+          outgoingEvent.currentTarget = object
+          object.onPointerout?.(outgoingEvent)
+          duplicates.add(object)
+          object = object.parent
+        }
       }
     }
   }
 
   if (hitsEntered.size) {
-    // TODO: Should use config.initialHits, not intersections
     callIntersectionObjectsIf('pointerenter', outgoingEvent, intersections, (obj: Object3D) => { return hitsEntered.has(obj) })
-  }
 
-  {
+    {
     // NOTE: Bubble pointerover
-    const duplicates = new Set()
-    outgoingEvent.stopped = false
-    for (const intersection of filteredIntersections) {
-      if (outgoingEvent.stopped) { break }
-
-      let object: TresObject | null = intersection.object
-      if (config.priorInitialHits.has(object) || duplicates.has(object)) { continue }
-
-      // NOTE: An event "is-a" `Intersection`,
-      // so copy intersection values to the event.
-      Object.assign(outgoingEvent, intersection)
-      outgoingEvent.target = object
-
-      while (object && !outgoingEvent.stopped && !duplicates.has(object)) {
-        outgoingEvent.eventObject = object
-        outgoingEvent.currentTarget = object
-        object.onPointerover?.(outgoingEvent)
-        duplicates.add(object)
-        object = object.parent
+    // `pointerover` is bubbled and can be stopped with `stopPropagation`.
+      const duplicates = new Set()
+      outgoingEvent.stopped = false
+      for (const intersection of filteredIntersections) {
+        if (outgoingEvent.stopped) { break }
+
+        let object: TresObject | null = intersection.object
+        if (config.priorInitialHits.has(object) || duplicates.has(object)) { continue }
+
+        // NOTE: An event "is-a" `Intersection`,
+        // so copy intersection values to the event.
+        Object.assign(outgoingEvent, intersection)
+        outgoingEvent.target = object
+
+        while (object && !outgoingEvent.stopped && !duplicates.has(object)) {
+          outgoingEvent.eventObject = object
+          outgoingEvent.currentTarget = object
+          object.onPointerover?.(outgoingEvent)
+          duplicates.add(object)
+          object = object.parent
+        }
       }
     }
   }
@@ -471,10 +607,21 @@ function handleIntersections(incomingEvent: RaycastEvent, intersections: ThreeIn
     }
   }
 
-  config.priorIntersections = filteredIntersections
-  config.priorInitialHits = initialHits
-  config.priorHits = hits
-  // NOTE:
+  // NOTE: 4) Release the pointer, if necessary
+  if ((incomingEvent.type === 'pointerup' || incomingEvent.type === 'pointercancel')
+    && config.pointerToCapturedObjects.has((incomingEvent as PointerEvent).pointerId)) {
+    const pointerId = (incomingEvent as PointerEvent).pointerId
+    for (const object of config.pointerToCapturedObjects.get(pointerId)!) {
+      releasePointerCapture(pointerId, object, config)
+    }
+  }
+
+  // NOTE: Setting `capturableEvent` to `undefined` here has the effect
+  // of disallowing pointer capture outside of event handlers.
+  // This is similar to DOM behavior.
+  config.capturableEvent = undefined
+
+  // NOTE: 5) `null` the event
   // Like DOM events, we set this to null after we're done with the event.
   // We reuse events for multiple handlers, changing `currentTarget` and
   // other fields. This keeps us from creating new objects and copying
@@ -488,6 +635,11 @@ function handleIntersections(incomingEvent: RaycastEvent, intersections: ThreeIn
   outgoingEvent.target = null
   // @ts-expect-error – same
   outgoingEvent.object = null
+
+  // NOTE: 6) Save data for next event
+  config.priorIntersections = filteredIntersections
+  config.priorInitialHits = initialHits
+  config.priorHits = hits
 }
 
 function callIntersectionObjectsIf(domEventName: DomEventName, event: ThreeEventStub<MouseEvent>, intersections: ThreeIntersection[], cond: (a: any) => boolean) {

文件差異過大導致無法顯示
+ 219 - 0
vite.config.ts.timestamp-1727348665686-a275c925ea43b.mjs


部分文件因文件數量過多而無法顯示