Переглянути джерело

feat(events)!: pointerevents manager and state (#529)

* new file:   playground/src/components/Box.vue
	new file:   playground/src/pages/raycaster/Propogation.vue
	  * Started work on interactive Event Propogation playground example
	modified:   src/components/TresCanvas.vue
	  * Import and use `useEventStore`
	  * defineEmits for all expected pointer events so we may emit propogated events off of the canvasa
	modified:   src/composables/index.ts
	new file:   src/composables/useEventStore/index.ts
	  * Started work on an event store. I'm not sure this counts as a store just yet
	  * Wired up majority of pointer events
	  * Added event propogation
	  * Does not require using userData scene props or nodeOps for registering objects to scene
	modified:   src/composables/useRaycaster/index.ts
	  * Added new event listeners to power newly supported pointer events. We now check whole scene/children when calling intersectObjects.
	  * Created new EventHooks for new events
	  * Added `forceUpdate` function that allows for pointer-move events to work without mouth movement (good for when camera is moving but mouse is not)

	modified:   src/core/nodeOps.ts
	  * Added supported events to array so they don't get received as props
	  * (temporarily) unhook current pointer event solution to iterate on useEventStore
	modified:   src/utils/index.ts
	  * Added Camel-to-kebab case util

* Support multiple event listeners, add support for .stop event modifier

* Set stopProgation variable to false by default, whoops

* fix typo

* fix: remove `createGlobalState` from `useEventStore`, allowing events to work while multiple TresCanvas' are being used

* fix(perf): remove extraneous intersectObjects/getIntersects calls by moving intersects into a ref that is updated on pointer-move

* chore(lint): fix lint issues

* feat: enhance events manager to include duplicates checking, pointer-missed support, and forced updating

Per file changelog:
	modified:   playground/src/components/Box.vue
	  * Added a pointer-missed handler for testing
	modified:   playground/src/pages/TheBasic.vue
	  * uses forceUpdate from EventManager to fire events even when the mouse hasn't moved
	modified:   playground/src/pages/raycaster/Propagation.vue
	  * Didn't mean to undo the lint changes, adds a pointer-missed event on the canvas 		for extra testing
	modified:   src/components/TresCanvas.vue
	  * Adds `pointer-missed` as possible event for canvas emits
	modified:   src/composables/index.ts
	  * Update export
	deleted:    src/composables/useEventStore/index.ts
	  * Rename `useEventStore` to `useTresEventManager`
	modified:   src/composables/useRaycaster/index.ts
	  * Check for empty intersects on hit test, wire up pointerMissed events eventHook
	  * Fix forceUpdate to call onPointerMove instead of triggering an EventHook
	modified:   src/composables/useTresContextProvider/index.ts
	  * Add TresEventManager type
	new file:   src/composables/useTresEventManager/index.ts
	  * add onPointerMissed
	  * create (de)registerPointerMissedObj methods so we can track objects in the scene listening to this event
	  * Note: These are passed to nodeOps via TresContext
	  * Implement duplicates checking for eventPropogation
	modified:   src/core/nodeOps.ts
	  * register/deregister pointerMissed objects

* chore: lint

* docs: new event docs

* chore: fix lint

* feat: enhance event object details and use in Box example to change material color. Add ability to force event system updates even when mouse hasn't moved. Enhance pointer-enter/leave events. Update types

  Box.vue
    * Added pointer-missed handler
    * set the materials flash color using the object coming off of the event instead of a ref
  UseRaycaster
    * Flesh out event details to include
      * all mouse event properties
      * intersections
      * tres camera
      * camera raycaster
      * source event
      * mouse position delta
      * stopPropagating stub
      * and unprojectedPoint (this needs work, cant get the math to work)
  UseTresContextProvider
    * Add TresEventManager type to TresContext
  useTresEventManager
    * Add forceUpdate method to allow apps to force an event system update even when the mouse hasnt moved
    * Add pointerMissed event
    * Properly implement pointer-enter/pointer-leave events
      * Before now, pointer-enter | leave were only called on first object in intersection, now we execute the events for all entered/left objects
    * Use stopPropagating property included on event object

* chore: lint

* chore: fix lint issues

---------

Co-authored-by: alvarosabu <alvaro.saburido@gmail.com>
Garrett Walker 1 рік тому
батько
коміт
b536ab19d1

+ 52 - 12
docs/api/events.md

@@ -6,22 +6,62 @@
 
 ## Pointer Events
 
+The following pointer events are available on `v3` and previous:
+
+- `click`
+- `pointer-move`
+- `pointer-enter`
+- `pointer-leave`
+
+From `v4.x` on, the following pointer events are been added to the list:
+
+- `context-menu` (right click)
+- `double-click`
+- `pointer-down`
+- `pointer-up`
+- `wheel`
+- `pointer-missed`
+
 ```html
 <TresMesh
-  @click="(intersection, pointerEvent) => console.log('click', intersection, pointerEvent)"
-  @pointer-move="(intersection, pointerEvent) => console.log('pointer-move', intersection, pointerEvent)"
-  @pointer-enter="(intersection, pointerEvent) => console.log('pointer-enter', intersection, pointerEvent)"
-  @pointer-leave="(intersection, pointerEvent) => console.log('pointer-leave', pointerEvent)"
+  @click="(event) => console.log('click')"
+  @context-menu="(event) => console.log('context-menu (right click)')"
+  @double-click="(event) => console.log('double-click')"
+  @pointer-move="(event) => console.log('pointer-move')"
+  @pointer-enter="(event) => console.log('pointer-enter')"
+  @pointer-leave="(event) => console.log('pointer-leave')"
+  @pointer-down="(event) => console.log('pointer-down')"
+  @pointer-up="(event) => console.log('pointer-up')"
+  @wheel="(event) => console.log('wheel')"
+  @pointer-missed="(event) => console.log('pointer-missed')"
 />
 ```
 
-| Event         | fires when ...                                                                        | Event Handler Parameter Type(s)                                                                                                                                                                       |
-| ------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| click        | ... the events pointerdown and pointerup fired on the same object one after the other | [Intersection](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/three/src/core/Raycaster.d.ts#L16), [PointerEvent](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent) |
-| pointer-move  | ... the pointer is moving above the object                                            | [Intersection](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/three/src/core/Raycaster.d.ts#L16), [PointerEvent](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent) |
-| pointer-enter | ... the pointer is entering the object                                                | [Intersection](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/three/src/core/Raycaster.d.ts#L16), [PointerEvent](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent) |
-| pointer-leave | ... the pointer is leaves the object                                                  | [PointerEvent](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent)                                                                                                                         |
+| <div style="width:160px">Event</div>            | fires when ...                                                                       | Event Handler Parameter Type(s)                                                                                                                                                                       |
+| ---------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| click            | the events pointerdown and pointerup fired on the same object one after the other    | [Intersection](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/three/src/core/Raycaster.d.ts#L16), [PointerEvent](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent) |
+| contextMenu <Badge type="warning" text="4.0.0" />     | the user triggers a context menu, often by right-clicking                            | [PointerEvent](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent)                                                                                                                         |
+| double-click <Badge type="warning" text="4.0.0" />      | the user clicks the mouse button twice in quick succession on the same object        | [Intersection](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/three/src/core/Raycaster.d.ts#L16), [PointerEvent](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent) |
+| wheel <Badge type="warning" text="4.0.0" />              | the mouse wheel or similar device is rotated                                         | [WheelEvent](https://developer.mozilla.org/en-US/docs/Web/API/WheelEvent)                                                                                                                             |
+| pointer-down <Badge type="warning" text="4.0.0" />       | the pointer is pressed down over the object                                          | [Intersection](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/three/src/core/Raycaster.d.ts#L16), [PointerEvent](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent) |
+| pointer-up <Badge type="warning" text="4.0.0" />        | the pointer is released over the object                                              | [Intersection](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/three/src/core/Raycaster.d.ts#L16), [PointerEvent](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent) |
+| pointer-leave    | the pointer is leaves the object                                                     | [PointerEvent](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent)                                                                                                                         |
+| pointer-move     | the pointer is moving above the object                                               | [Intersection](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/three/src/core/Raycaster.d.ts#L16), [PointerEvent](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent) |
+| pointer-missed <Badge type="warning" text="4.0.0" />    | the pointer interaction is attempted but misses the object                           | [PointerEvent](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent)                                                                                                                         |
+
+## Event Propagation (Bubbling 🫧) <Badge type="warning" text="^4.0.0" />
+
+Propagation of events on 3D scenes works differently than in the DOM because objects can **occlude each other in 3D**. The `intersections` array contains all the objects that the raycaster intersects with, sorted by distance from the camera. The first object in the array is the closest one to the camera.
 
-The returned [Intersection](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/three/src/core/Raycaster.d.ts#L16) includes the [Object3D](https://threejs.org/docs/index.html?q=object#api/en/core/Object3D) that triggered the event. You can access it via `intersection.object`.
+When an event is triggered, the event is propagated to the closest object in the `intersections` array. If the event is not handled by the object, it will be propagated to the next object in the array.
 
-By default, objects positioned in front of others with event handlers do not prevent those events from being triggered. This behavior can be achieved by using the prop `blocks-pointer-events`.
+`event.stopPropagation()` can be used to stop the event from propagating to the next object in the array, stoping the event from bubbling up and reaching to farther objects (the oens behind the first one).
+
+```html
+<TresMesh
+  @pointer-down="(event) => {
+    console.log('pointer-down')
+    event.stopPropagation()
+  }"
+/>
+```

+ 2 - 0
playground/components.d.ts

@@ -10,6 +10,7 @@ declare module 'vue' {
     AkuAku: typeof import('./src/components/AkuAku.vue')['default']
     AnimatedModel: typeof import('./src/components/AnimatedModel.vue')['default']
     BlenderCube: typeof import('./src/components/BlenderCube.vue')['default']
+    Box: typeof import('./src/components/Box.vue')['default']
     CameraOperator: typeof import('./src/components/CameraOperator.vue')['default']
     Cameras: typeof import('./src/components/Cameras.vue')['default']
     copy: typeof import('./src/components/TheBasic copy.vue')['default']
@@ -17,6 +18,7 @@ declare module 'vue' {
     DebugUI: typeof import('./src/components/DebugUI.vue')['default']
     DeleteMe: typeof import('./src/components/DeleteMe.vue')['default']
     DynamicModel: typeof import('./src/components/DynamicModel.vue')['default']
+    EventsPropogation: typeof import('./src/components/EventsPropogation.vue')['default']
     FBXModels: typeof import('./src/components/FBXModels.vue')['default']
     Gltf: typeof import('./src/components/gltf/index.vue')['default']
     GraphPane: typeof import('./src/components/GraphPane.vue')['default']

+ 44 - 0
playground/src/components/Box.vue

@@ -0,0 +1,44 @@
+<script setup lang="ts">
+import { ref, shallowRef } from 'vue'
+import { useRenderLoop } from '@tresjs/core'
+import { Color } from 'three'
+
+const props = defineProps(['position', 'name'])
+
+// TODO: Once we have troika text in cientos, display the count over each box
+const count = ref(0)
+const boxRef = shallowRef()
+
+// Event Testing Colors
+const black = new Color('black')
+const green = new Color('green')
+
+const blue = new Color('blue')
+
+// Once the box has flashed green, lerp it back to black
+const { onLoop } = useRenderLoop()
+onLoop(() => {
+  boxRef.value?.material.color.lerp(black, 0.1)
+})
+
+// onClick flash the box a color and update the counter
+function handleClick(color: Color, ev) {
+  count.value++
+  ev?.eventObject?.material.color.set(color)
+  // eslint-disable-next-line no-console
+  console.log(`Box ${boxRef.value.name} count=${count.value}`)
+}
+</script>
+
+<template>
+  <TresMesh
+    ref="boxRef"
+    v-bind="props"
+    @click.self="ev => handleClick(green, ev)"
+    @pointer-missed="ev => handleClick(blue, ev)"
+  >
+    <TresBoxGeometry />
+    <TresMeshStandardMaterial />
+    <slot></slot>
+  </TresMesh>
+</template>

+ 13 - 1
playground/src/pages/basic/index.vue

@@ -14,6 +14,7 @@ const state = reactive({
   toneMapping: NoToneMapping,
 })
 
+const canvasRef = ref()
 const sphereRef = ref()
 
 const { onLoop } = useRenderLoop()
@@ -21,6 +22,9 @@ const { onLoop } = useRenderLoop()
 onLoop(({ elapsed }) => {
   if (!sphereRef.value) { return }
   sphereRef.value.position.y += Math.sin(elapsed) * 0.01
+
+  // Update events without needing the mouse to move
+  canvasRef.value?.context?.eventManager.forceUpdate()
 })
 
 function onPointerEnter(ev) {
@@ -29,6 +33,10 @@ function onPointerEnter(ev) {
   }
 }
 
+function onPointerOut(ev) {
+  ev.object.material.color.set('teal')
+}
+
 const sphereExists = ref(true)
 </script>
 
@@ -37,7 +45,10 @@ const sphereExists = ref(true)
     v-model="sphereExists"
     type="checkbox"
   />
-  <TresCanvas v-bind="state">
+  <TresCanvas
+    ref="canvasRef"
+    v-bind="state"
+  >
     <TresPerspectiveCamera
       :position="[5, 5, 5]"
       :fov="45"
@@ -56,6 +67,7 @@ const sphereExists = ref(true)
         :position="[0, 4, 0]"
         cast-shadow
         @pointer-enter="onPointerEnter"
+        @pointer-out="onPointerOut"
       >
         <TresSphereGeometry :args="[2, 32, 32]" />
         <TresMeshToonMaterial color="teal" />

+ 188 - 0
playground/src/pages/events/Propagation.vue

@@ -0,0 +1,188 @@
+<script setup lang="ts">
+import { onUnmounted, ref } from 'vue'
+import {
+  TresCanvas,
+} from '@tresjs/core'
+import { BasicShadowMap, NoToneMapping, SRGBColorSpace } from 'three'
+import { OrbitControls } from '@tresjs/cientos'
+import '@tresjs/leches/styles'
+import Box from '../../components/Box.vue'
+
+const gl = {
+  clearColor: '#202020',
+  shadows: true,
+  alpha: false,
+  shadowMapType: BasicShadowMap,
+  outputColorSpace: SRGBColorSpace,
+  toneMapping: NoToneMapping,
+}
+
+const showBox = ref(true)
+
+const intervalRef = setInterval(() => {
+  // showBox.value = !showBox.value;
+}, 1000)
+
+onUnmounted(() => {
+  clearInterval(intervalRef)
+})
+</script>
+
+<template>
+  <TresCanvas
+    window-size
+    v-bind="gl"
+    @pointer-missed="event => console.log('pointer-missed', event)"
+  >
+    <TresPerspectiveCamera
+      :position="[0, 0, 6]"
+      :look-at="[0, 0, 0]"
+    />
+    <OrbitControls />
+
+    <TresDirectionalLight
+      :intensity="1"
+      :position="[1, 1, 1]"
+    />
+    <TresAmbientLight :intensity="1" />
+    <Box
+      :position="[0, 1.5, 0]"
+      name="A0"
+    >
+      <Box
+        :position="[-0.66, -1, 0]"
+        name="B0"
+      >
+        <Box
+          :position="[-0.66, -1, 0]"
+          name="C0"
+        >
+          <Box
+            :position="[-0.66, -1, 0]"
+            name="D0"
+          />
+          <Box
+            :position="[0.66, -1, 0]"
+            name="D1"
+          />
+        </Box>
+        <Box
+          :position="[0.66, -1, 0]"
+          name="C1"
+        >
+          <Box
+            :position="[0.66, -1, 0]"
+            name="D2"
+          />
+        </Box>
+      </Box>
+      <Box
+        :position="[0.66, -1, 0]"
+        name="B1"
+      >
+        <Box
+          :position="[0.66, -1, 0]"
+          name="C2"
+        >
+          <Box
+            v-if="showBox"
+            :position="[0.66, -1, 0]"
+            name="D3"
+          />
+        </Box>
+      </Box>
+    </Box>
+    <Box
+      :position="[0, 1.5, -3]"
+      name="A0"
+    >
+      <Box
+        :position="[-0.66, -1, 0]"
+        name="B0"
+      >
+        <Box
+          :position="[-0.66, -1, 0]"
+          name="C0"
+        >
+          <Box
+            :position="[-0.66, -1, 0]"
+            name="D0"
+          />
+          <Box
+            :position="[0.66, -1, 0]"
+            name="D1"
+          />
+        </Box>
+        <Box
+          :position="[0.66, -1, 0]"
+          name="C1"
+        >
+          <Box
+            :position="[0.66, -1, 0]"
+            name="D2"
+          />
+        </Box>
+      </Box>
+      <Box
+        :position="[0.66, -1, 0]"
+        name="B1"
+      >
+        <Box
+          :position="[0.66, -1, 0]"
+          name="C2"
+        >
+          <Box
+            :position="[0.66, -1, 0]"
+            name="D3"
+          />
+        </Box>
+      </Box>
+    </Box>
+    <Box
+      :position="[0, 1.5, -6]"
+      name="A0"
+    >
+      <Box
+        :position="[-0.66, -1, 0]"
+        name="B0"
+      >
+        <Box
+          :position="[-0.66, -1, 0]"
+          name="C0"
+        >
+          <Box
+            :position="[-0.66, -1, 0]"
+            name="D0"
+          />
+          <Box
+            :position="[0.66, -1, 0]"
+            name="D1"
+          />
+        </Box>
+        <Box
+          :position="[0.66, -1, 0]"
+          name="C1"
+        >
+          <Box
+            :position="[0.66, -1, 0]"
+            name="D2"
+          />
+        </Box>
+      </Box>
+      <Box
+        :position="[0.66, -1, 0]"
+        name="B1"
+      >
+        <Box
+          :position="[0.66, -1, 0]"
+          name="C2"
+        >
+          <Box
+            :position="[0.66, -1, 0]"
+            name="D3"
+          />
+        </Box>
+      </Box>
+    </Box>
+  </TresCanvas>
+</template>

+ 67 - 50
playground/src/pages/events/index.vue

@@ -1,8 +1,11 @@
+<!-- eslint-disable no-console -->
 <script setup lang="ts">
+import type { ThreeEvent } from '@tresjs/core'
 import { TresCanvas } from '@tresjs/core'
 import { BasicShadowMap, NoToneMapping, SRGBColorSpace } from 'three'
-
+import { TresLeches, useControls } from '@tresjs/leches'
 import { OrbitControls } from '@tresjs/cientos'
+import '@tresjs/leches/styles'
 
 const gl = {
   clearColor: '#202020',
@@ -13,65 +16,79 @@ const gl = {
   toneMapping: NoToneMapping,
 }
 
-function onClick(ev) {
-  if (ev) {
-    ev.object.material.color.set('#008080')
-  }
+const { stopPropagation } = useControls({
+  stopPropagation: false,
+})
+
+function onClick(ev: ThreeEvent<MouseEvent>) {
+  console.log('click', ev)
+  if (stopPropagation.value) { ev.stopPropagation() }
+  ev.object.material.color.set('#008080')
+}
+
+function onDoubleClick(ev: ThreeEvent<MouseEvent>) {
+  console.log('double-click', ev)
+  if (stopPropagation.value) { ev.stopPropagation() }
+  ev.object.material.color.set('#FFD700')
 }
 
-function onPointerEnter(ev) {
-  if (ev) {
-    ev.object.material.color.set('#CCFF03')
-  }
+function onPointerEnter(ev: ThreeEvent<MouseEvent>) {
+  if (stopPropagation.value) { ev.stopPropagation() }
+  ev.object.material.color.set('#CCFF03')
 }
 
-function onPointerLeave(ev) {
-  if (ev) {
-    /*  ev.object.material.color.set('#efefef') */
-  }
+function onPointerLeave(ev: ThreeEvent<MouseEvent>) {
+  if (stopPropagation.value) { ev.stopPropagation() }
+  /*  ev.object.material.color.set('#efefef') */
 }
 
-function onPointerMove(ev) {
-  if (ev) {
-    // eslint-disable-next-line no-console
-    console.log(ev)
-  }
+function onPointerMove(ev: ThreeEvent<MouseEvent>) {
+  if (stopPropagation.value) { ev.stopPropagation() }
 }
 
-const visible = ref(true)
+function onContextMenu(ev: ThreeEvent<MouseEvent>) {
+  console.log('context-menu', ev)
+  if (stopPropagation.value) { ev.stopPropagation() }
+  ev.object.material.color.set('#FF4500')
+}
+
+function onPointerMissed(ev: ThreeEvent<MouseEvent>) {
+  console.log('pointer-missed', ev)
+  if (stopPropagation.value) { ev.stopPropagation() }
+}
 </script>
 
 <template>
-  <button @click="visible = !visible"></button>
-  <div v-if="visible">
-    <TresCanvas
-      window-size
-      v-bind="gl"
-    >
-      <TresPerspectiveCamera
-        :position="[11, 11, 11]"
-        :look-at="[0, 0, 0]"
-      />
-      <OrbitControls />
-
-      <template v-for="x in [-2.5, 0, 2.5]">
-        <template v-for="y in [-2.5, 0, 2.5]">
-          <TresMesh
-            v-for="z in [-2.5, 0, 2.5]"
-            :key="`${[x, y, z]}`"
-            :position="[x, y, z]"
-            @click="onClick"
-            @pointer-enter="onPointerEnter"
-            @pointer-leave="onPointerLeave"
-            @pointer-move="onPointerMove"
-          >
-            <TresBoxGeometry :args="[1, 1, 1]" />
-            <TresMeshToonMaterial color="#efefef" />
-          </TresMesh>
-        </template>
+  <TresLeches />
+  <TresCanvas
+    window-size
+    v-bind="gl"
+  >
+    <TresPerspectiveCamera
+      :position="[11, 11, 11]"
+      :look-at="[0, 0, 0]"
+    />
+    <OrbitControls />
+    <template v-for="x in [-2.5, 0, 2.5]">
+      <template v-for="y in [-2.5, 0, 2.5]">
+        <TresMesh
+          v-for="z in [-2.5, 0, 2.5]"
+          :key="`${[x, y, z]}`"
+          :position="[x, y, z]"
+          @click="onClick"
+          @double-click="onDoubleClick"
+          @pointer-enter="onPointerEnter"
+          @pointer-leave="onPointerLeave"
+          @pointer-move="onPointerMove"
+          @context-menu="onContextMenu"
+          @pointer-missed="onPointerMissed"
+        >
+          <TresBoxGeometry :args="[1, 1, 1]" />
+          <TresMeshToonMaterial color="#efefef" />
+        </TresMesh>
       </template>
-      <TresDirectionalLight :intensity="1" />
-      <TresAmbientLight :intensity="1" />
-    </TresCanvas>
-  </div>
+    </template>
+    <TresDirectionalLight :intensity="1" />
+    <TresAmbientLight :intensity="1" />
+  </TresCanvas>
 </template>

+ 5 - 0
playground/src/router/index.ts

@@ -69,6 +69,11 @@ const routes = [
     name: 'Raycaster',
     component: () => import('./pages/raycaster/TheEvents.vue'),
   },
+  {
+    path: '/raycaster/propagation',
+    name: 'Event Propogation',
+    component: () => import('./pages/raycaster/Propagation.vue'),
+  },
   {
     path: '/misc/text-3d',
     name: 'Text3D',

+ 21 - 3
src/components/TresCanvas.vue

@@ -27,9 +27,9 @@ import pkg from '../../package.json'
 import {
   type TresContext,
   useLogger,
-  usePointerEventHandler,
   useRenderLoop,
   useTresContextProvider,
+  useTresEventManager,
 } from '../composables'
 import { extend } from '../core/catalogue'
 import { nodeOps } from '../core/nodeOps'
@@ -72,7 +72,24 @@ const props = withDefaults(defineProps<TresCanvasProps>(), {
   renderMode: 'always',
 })
 
-const emit = defineEmits(['render'])
+// Define emits for Pointer events, pass `emit` into useTresEventManager so we can emit events off of TresCanvas
+// Not sure of this solution, but you have to have emits defined on the component to emit them in vue
+const emit = defineEmits([
+  'render',
+  'click',
+  'double-click',
+  'context-menu',
+  'pointer-move',
+  'pointer-up',
+  'pointer-down',
+  'pointer-enter',
+  'pointer-leave',
+  'pointer-over',
+  'pointer-out',
+  'pointer-missed',
+  'wheel',
+])
+
 const slots = defineSlots<{
   default: () => any
 }>()
@@ -132,6 +149,7 @@ const disableRender = computed(() => props.disableRender)
 const context = shallowRef<TresContext | null>(null)
 
 defineExpose({ context, dispose: () => dispose(context.value as TresContext, true) })
+
 onMounted(() => {
   const existingCanvas = canvas as Ref<HTMLCanvasElement>
 
@@ -144,7 +162,7 @@ onMounted(() => {
     emit,
   })
 
-  usePointerEventHandler(context.value)
+  useTresEventManager(scene.value, context.value, emit)
 
   const { registerCamera, camera, cameras, deregisterCamera } = context.value
 

+ 1 - 0
src/composables/index.ts

@@ -8,3 +8,4 @@ export * from './useLogger'
 export * from './useSeek'
 export * from './usePointerEventHandler'
 export * from './useTresContextProvider'
+export * from './useTresEventManager'

+ 135 - 14
src/composables/useRaycaster/index.ts

@@ -1,7 +1,7 @@
-import { Vector2 } from 'three'
+import { Vector2, Vector3 } from 'three'
 import type { Intersection, Object3D, Object3DEventMap } from 'three'
-import type { Ref } from 'vue'
-import { computed, onUnmounted } from 'vue'
+import type { Ref, ShallowRef } from 'vue'
+import { computed, onUnmounted, shallowRef } from 'vue'
 import type { EventHook } from '@vueuse/core'
 import { createEventHook, useElementBounding, usePointer } from '@vueuse/core'
 
@@ -18,14 +18,20 @@ interface PointerClickEventPayload {
   event: PointerEvent
 }
 
+interface WheelEventPayload {
+  intersects: Intersects
+  event: WheelEvent
+}
+
 export const useRaycaster = (
   objects: Ref<Object3D[]>,
   ctx: TresContext,
 ) => {
   // having a separate computed makes useElementBounding work
   const canvas = computed(() => ctx.renderer.value.domElement as HTMLCanvasElement)
-
+  const intersects: ShallowRef<Intersects[]> = shallowRef([])
   const { x, y } = usePointer({ target: canvas })
+  let delta = 0
 
   const { width, height, top, left } = useElementBounding(canvas)
 
@@ -43,10 +49,11 @@ export const useRaycaster = (
 
     ctx.raycaster.value.setFromCamera(new Vector2(x, y), ctx.camera.value)
 
-    return ctx.raycaster.value.intersectObjects(objects.value, false)
+    intersects.value = ctx.raycaster.value.intersectObjects(objects.value, true)
+    return intersects.value
   }
 
-  const getIntersects = (event?: PointerEvent | MouseEvent) => {
+  const getIntersects = (event?: PointerEvent | MouseEvent | WheelEvent) => {
     const pointerPosition = getRelativePointerPosition({
       x: event?.clientX ?? x.value,
       y: event?.clientY ?? y.value,
@@ -56,39 +63,144 @@ export const useRaycaster = (
     return getIntersectsByRelativePointerPosition(pointerPosition) || []
   }
 
-  const intersects = computed<Intersects>(() => getIntersects())
-
   const eventHookClick = createEventHook<PointerClickEventPayload>()
+  const eventHookDblClick = createEventHook<PointerClickEventPayload>()
   const eventHookPointerMove = createEventHook<PointerMoveEventPayload>()
+  const eventHookPointerUp = createEventHook<PointerMoveEventPayload>()
+  const eventHookPointerDown = createEventHook<PointerMoveEventPayload>()
+  const eventHookPointerMissed = createEventHook<PointerClickEventPayload>()
+  const eventHookContextMenu = createEventHook<PointerClickEventPayload>()
+  const eventHookWheel = createEventHook<WheelEventPayload>()
+
+  /* ({
+    ...DomEvent                   // All the original event data
+    ...Intersection               // All of Three's intersection data - see note 2
+    intersections: Intersection[] // The first intersection of each intersected object
+    object: Object3D              // The object that was actually hit (added to event payload in TresEventManager)
+    eventObject: Object3D         // The object that registered the event (added to event payload in TresEventManager)
+    unprojectedPoint: Vector3     // Camera-unprojected point
+    ray: Ray                      // The ray that was used to strike the object
+    camera: Camera                // The camera that was used in the raycaster
+    sourceEvent: DomEvent         // A reference to the host event
+    delta: number                 // Distance between mouse down and mouse up event in pixels
+  }) => ... */
+
+  // Mouse Event props aren't enumerable, so we can't be simple and use Object.assign or the spread operator
+  // Manually copies the mouse event props into a new object that we can spread in triggerEventHook
+  function copyMouseEventProperties(event: MouseEvent | PointerEvent | WheelEvent) {
+    const mouseEventProperties: any = {}
+
+    for (const property in event) {
+      // Copy all non-function properties
+      if (typeof property !== 'function') { mouseEventProperties[property] = event[property] }
+    }
+    return mouseEventProperties
+  }
 
-  const triggerEventHook = (eventHook: EventHook, event: PointerEvent | MouseEvent) => {
-    eventHook.trigger({ event, intersects: getIntersects(event) })
+  const triggerEventHook = (eventHook: EventHook, event: PointerEvent | MouseEvent | WheelEvent) => {
+    const eventProperties = copyMouseEventProperties(event)
+
+    eventHook.trigger({
+      ...eventProperties,
+      intersections: intersects.value,
+      // The unprojectedPoint is wrong, math needs to be fixed
+      unprojectedPoint: new Vector3(event?.clientX, event?.clientY, 0).unproject(ctx.camera?.value),
+      ray: ctx.raycaster?.value.ray,
+      camera: ctx.camera?.value,
+      sourceEvent: event,
+      delta,
+      stopPropagating: false,
+    })
   }
 
+  let previousPointerMoveEvent: PointerEvent | undefined
   const onPointerMove = (event: PointerEvent) => {
+    // Update the raycast intersects
+    getIntersects(event)
     triggerEventHook(eventHookPointerMove, event)
+    previousPointerMoveEvent = event
   }
 
-  // a click event is fired whenever a pointerdown happened after pointerup on the same object
+  const forceUpdate = () => {
+    if (previousPointerMoveEvent) { onPointerMove(previousPointerMoveEvent) }
+  }
 
+  // a click event is fired whenever a pointerdown happened after pointerup on the same object
   let mouseDownObject: Object3D | undefined
+  let mouseDownPosition
+  let mouseUpPosition
 
   const onPointerDown = (event: PointerEvent) => {
-    mouseDownObject = getIntersects(event)[0]?.object
+    mouseDownObject = intersects.value[0]?.object
+
+    delta = 0
+    mouseDownPosition = new Vector2(
+      event?.clientX ?? x.value,
+      event?.clientY ?? y.value,
+    )
+
+    triggerEventHook(eventHookPointerDown, event)
   }
 
+  let previousClickObject: Object3D | undefined
+  let doubleClickConfirmed: boolean = false
+
   const onPointerUp = (event: MouseEvent) => {
     if (!(event instanceof PointerEvent)) { return } // prevents triggering twice on mobile devices
 
-    if (mouseDownObject === getIntersects(event)[0]?.object) { triggerEventHook(eventHookClick, event) }
+    // We missed every object, trigger the pointer missed event
+    if (intersects.value.length === 0) {
+      triggerEventHook(eventHookPointerMissed, event)
+    }
+
+    if (mouseDownObject === intersects.value[0]?.object) {
+      mouseUpPosition = new Vector2(
+        event?.clientX ?? x.value,
+        event?.clientY ?? y.value,
+      )
+
+      // Compute the distance between the mouse down and mouse up events
+      delta = mouseDownPosition.distanceTo(mouseUpPosition)
+
+      if (event.button === 0) {
+        // Left click
+        triggerEventHook(eventHookClick, event)
+
+        if (previousClickObject === intersects.value[0]?.object) {
+          doubleClickConfirmed = true
+        }
+        else {
+          previousClickObject = intersects.value[0]?.object
+          doubleClickConfirmed = false
+        }
+      }
+      else if (event.button === 2) {
+        // Right click
+        triggerEventHook(eventHookContextMenu, event)
+      }
+    }
+
+    triggerEventHook(eventHookPointerUp, event)
   }
 
-  const onPointerLeave = (event: PointerEvent) => eventHookPointerMove.trigger({ event, intersects: [] })
+  const onDoubleClick = (event: MouseEvent) => {
+    if (doubleClickConfirmed) {
+      triggerEventHook(eventHookDblClick, event)
+      previousClickObject = undefined
+      doubleClickConfirmed = false
+    }
+  }
+
+  const onPointerLeave = (event: PointerEvent) => triggerEventHook(eventHookPointerMove, event)
+
+  const onWheel = (event: WheelEvent) => triggerEventHook(eventHookWheel, event)
 
   canvas.value.addEventListener('pointerup', onPointerUp)
   canvas.value.addEventListener('pointerdown', onPointerDown)
   canvas.value.addEventListener('pointermove', onPointerMove)
   canvas.value.addEventListener('pointerleave', onPointerLeave)
+  canvas.value.addEventListener('dblclick', onDoubleClick)
+  canvas.value.addEventListener('wheel', onWheel)
 
   onUnmounted(() => {
     if (!canvas?.value) { return }
@@ -96,11 +208,20 @@ export const useRaycaster = (
     canvas.value.removeEventListener('pointerdown', onPointerDown)
     canvas.value.removeEventListener('pointermove', onPointerMove)
     canvas.value.removeEventListener('pointerleave', onPointerLeave)
+    canvas.value.removeEventListener('dblclick', onDoubleClick)
+    canvas.value.removeEventListener('wheel', onWheel)
   })
 
   return {
     intersects,
     onClick: (fn: (value: PointerClickEventPayload) => void) => eventHookClick.on(fn).off,
+    onDblClick: (fn: (value: PointerClickEventPayload) => void) => eventHookDblClick.on(fn).off,
+    onContextMenu: (fn: (value: PointerClickEventPayload) => void) => eventHookContextMenu.on(fn).off,
     onPointerMove: (fn: (value: PointerMoveEventPayload) => void) => eventHookPointerMove.on(fn).off,
+    onPointerUp: (fn: (value: PointerMoveEventPayload) => void) => eventHookPointerUp.on(fn).off,
+    onPointerDown: (fn: (value: PointerMoveEventPayload) => void) => eventHookPointerDown.on(fn).off,
+    onPointerMissed: (fn: (value: PointerClickEventPayload) => void) => eventHookPointerMissed.on(fn).off,
+    onWheel: (fn: (value: WheelEventPayload) => void) => eventHookWheel.on(fn).off,
+    forceUpdate,
   }
 }

+ 2 - 0
src/composables/useTresContextProvider/index.ts

@@ -11,6 +11,7 @@ import { extend } from '../../core/catalogue'
 import { useLogger } from '../useLogger'
 import type { TresScene } from '../../types'
 import type { EventProps } from '../usePointerEventHandler'
+import type { TresEventManager } from '../useTresEventManager'
 import useSizes, { type SizesType } from '../useSizes'
 
 export interface InternalState {
@@ -68,6 +69,7 @@ export interface TresContext {
   registerCamera: (camera: Camera) => void
   setCameraActive: (cameraOrUuid: Camera | string) => void
   deregisterCamera: (camera: Camera) => void
+  eventManager: TresEventManager
   // Events
   // Temporaly add the methods to the context, this should be handled later by the EventManager state on the context https://github.com/Tresjs/tres/issues/515
   // When thats done maybe we can short the names of the methods since the parent will give the context.

+ 194 - 0
src/composables/useTresEventManager/index.ts

@@ -0,0 +1,194 @@
+import { computed, shallowRef } from 'vue'
+import type { Object3D, Scene } from 'three'
+import type { TresContext } from '../useTresContextProvider'
+import { useRaycaster } from '../useRaycaster'
+import type { Intersects } from '../useRaycaster'
+import { hyphenate } from '../../utils'
+
+export interface TresEventManager {
+  /**
+   * Forces the event system to refire events with the previous mouse event
+   */
+  forceUpdate: () => void
+  /**
+   * pointer-missed events by definition are fired when the pointer missed every object in the scene
+   * So we need to track them separately
+   * Note: These are used in nodeOps
+   */
+  registerPointerMissedObject: (object: Object3D) => void
+  deregisterPointerMissedObject: (object: Object3D) => void
+}
+
+export function useTresEventManager(
+  scene: Scene,
+  context: TresContext,
+  emit: (event: string, ...args: any[]) => void,
+) {
+  const _scene = shallowRef<Scene>()
+  const _context = shallowRef<TresContext>()
+
+  if (scene) { _scene.value = scene }
+  if (context) { _context.value = context }
+
+  // TODO: Optimize to not hit test on the whole scene
+  const sceneChildren = computed(() =>
+    _scene.value ? _scene.value.children : [],
+  )
+
+  function executeEventListeners(
+    listeners: Function | Function[],
+    event,
+  ) {
+    // Components with multiple event listeners will have an array of functions
+    if (Array.isArray(listeners)) {
+      for (const listener of listeners) {
+        listener(event)
+      }
+    }
+
+    // Single listener will be a function
+    if (typeof listeners === 'function') {
+      listeners(event)
+    }
+  }
+
+  /**
+   * propogateEvent
+   *
+   * Propogates an event to all intersected objects and their parents
+   * @param eventName - The name of the event to propogate
+   * @param event - The event object to propogate
+   */
+  function propogateEvent(eventName: string, event) {
+    // Array of objects we've already propogated to
+    const duplicates = []
+
+    // Flag that is set to true when the stopProgatingFn is called
+    const stopPropagatingFn = () => (event.stopPropagating = true)
+    event.stopPropagation = stopPropagatingFn
+
+    // Loop through all intersected objects and call their event handler
+    for (const intersection of event?.intersections) {
+      if (event.stopPropagating) { return }
+
+      // Add intersection data to event object
+      event = { ...event, ...intersection }
+
+      const { object } = intersection
+      event.eventObject = object
+      executeEventListeners(object[eventName], event)
+      duplicates.push(object)
+
+      // Propogate the event up the parent chain before moving on to the next intersected object
+      let parentObj = object.parent
+      while (parentObj !== null && !event.stopPropagating) {
+        // We've already been here, break the loop
+        if (duplicates.includes(parentObj)) {
+          break
+        }
+
+        // Sets eventObject to object that registered the event listener
+        event.eventObject = parentObj
+        executeEventListeners(parentObj[eventName], event)
+        duplicates.push(parentObj)
+        parentObj = parentObj.parent
+      }
+
+      // Convert eventName to kebab case and emit event from TresCanvas
+      const kebabEventName = hyphenate(eventName.slice(2))
+      emit(kebabEventName, { intersection, event })
+    }
+  }
+
+  const {
+    onClick,
+    onDblClick,
+    onContextMenu,
+    onPointerMove,
+    onPointerDown,
+    onPointerUp,
+    onPointerMissed,
+    onWheel,
+    forceUpdate,
+  } = useRaycaster(sceneChildren, context)
+
+  onPointerUp(event => propogateEvent('onPointerUp', event))
+  onPointerDown(event => propogateEvent('onPointerDown', event))
+  onClick(event => propogateEvent('onClick', event))
+  onDblClick(event => propogateEvent('onDoubleClick', event))
+  onContextMenu(event => propogateEvent('onContextMenu', event))
+  onWheel(event => propogateEvent('onWheel', event))
+
+  let prevIntersections: Intersects = []
+
+  onPointerMove((event) => {
+    // Current intersections mapped as meshes
+    const hits = event.intersections.map(({ object }) => object)
+
+    // Previously intersected mesh is no longer intersected, fire onPointerLeave
+    prevIntersections.forEach((hit) => {
+      if (
+        !hits.includes(hit)
+      ) {
+        propogateEvent('onPointerLeave', event)
+        propogateEvent('onPointerOut', event)
+      }
+    })
+
+    // Newly intersected mesh is not in the previous intersections, fire onPointerEnter
+    event.intersections.forEach(({ object: hit }) => {
+      if (!prevIntersections.includes(hit)) {
+        propogateEvent('onPointerEnter', event)
+        propogateEvent('onPointerOver', event)
+      }
+    })
+
+    // Fire onPointerMove for all intersected objects
+    propogateEvent('onPointerMove', event)
+
+    // Update previous intersections
+    prevIntersections = hits
+  })
+
+  /**
+   * We need to track pointer missed objects separately
+   * since they will not be a part of the raycaster intersection
+   */
+  const pointerMissedObjects: Object3D[] = []
+  onPointerMissed((event) => {
+    // Flag that is set to true when the stopProgatingFn is called
+    const stopPropagatingFn = () => (event.stopPropagating = true)
+    event.stopPropagation = stopPropagatingFn
+
+    pointerMissedObjects.forEach((object: Object3D) => {
+      if (event.stopPropagating) { return }
+
+      // Set eventObject to object that registered the event
+      event.eventObject = object
+
+      executeEventListeners(object.onPointerMissed, event)
+    })
+    // Emit pointer-missed from TresCanvas
+    emit('pointer-missed', { event })
+  })
+
+  function registerPointerMissedObject(object: Object3D) {
+    pointerMissedObjects.push(object)
+  }
+
+  function deregisterPointerMissedObject(object: Object3D) {
+    const index = pointerMissedObjects.indexOf(object)
+    if (index > -1) {
+      pointerMissedObjects.splice(index, 1)
+    }
+  }
+
+  // Attach methods to tres context
+  context.eventManager = {
+    forceUpdate,
+    registerPointerMissedObject,
+    deregisterPointerMissedObject,
+  }
+
+  return { forceUpdate, registerPointerMissedObject, deregisterPointerMissedObject }
+}

+ 46 - 33
src/core/nodeOps.ts

@@ -17,9 +17,19 @@ const { logError } = useLogger()
 
 const supportedPointerEvents = [
   'onClick',
+  'onContextMenu',
   'onPointerMove',
   'onPointerEnter',
   'onPointerLeave',
+  'onPointerOver',
+  'onPointerOut',
+  'onDoubleClick',
+  'onPointerDown',
+  'onPointerUp',
+  'onPointerCancel',
+  'onPointerMissed',
+  'onLostPointerCapture',
+  'onWheel',
 ]
 
 export function invalidateInstance(instance: TresObject) {
@@ -54,7 +64,9 @@ export const nodeOps: () => RendererOptions<TresObject, TresObject | null> = ()
     else {
       const target = catalogue.value[name]
       if (!target) {
-        logError(`${name} is not defined on the THREE namespace. Use extend to add it to the catalog.`)
+        logError(
+          `${name} is not defined on the THREE namespace. Use extend to add it to the catalog.`,
+        )
       }
       // eslint-disable-next-line new-cap
       instance = new target(...props.args)
@@ -108,14 +120,14 @@ export const nodeOps: () => RendererOptions<TresObject, TresObject | null> = ()
     const parentObject = parent || scene
 
     if (child?.isObject3D) {
-      const { registerCamera, registerObjectAtPointerEventHandler } = child.__tres.root
+      const { registerCamera } = child.__tres.root
       if (child?.isCamera) {
         registerCamera(child as unknown as Camera)
       }
-      if (
-        child && supportedPointerEvents.some(eventName => child[eventName])
-      ) {
-        registerObjectAtPointerEventHandler(child as Object3D)
+
+      // Track onPointerMissed objects separate from the scene
+      if (child.onPointerMissed) {
+        child.__tres.root.eventManager.registerPointerMissedObject(child as Object3D)
       }
     }
 
@@ -139,11 +151,6 @@ export const nodeOps: () => RendererOptions<TresObject, TresObject | null> = ()
     // remove is only called on the node being removed and not on child nodes.
     node.parent = node.parent || scene
 
-    const {
-      deregisterObjectAtPointerEventHandler,
-      deregisterBlockingObjectAtPointerEventHandler,
-    } = ctx.root
-
     if (node.isObject3D) {
       const disposeMaterialsAndGeometries = (object3D: TresObject) => {
         const tresObject3D = object3D as TresObject3D
@@ -156,13 +163,6 @@ export const nodeOps: () => RendererOptions<TresObject, TresObject | null> = ()
         }
       }
 
-      const deregisterAtPointerEventHandlerIfRequired = (object: TresObject) => {
-        deregisterBlockingObjectAtPointerEventHandler(object as Object3D)
-        if (
-          object && supportedPointerEvents.some(eventName => object[eventName])
-        ) { deregisterObjectAtPointerEventHandler?.(object as Object3D) }
-      }
-
       const deregisterCameraIfRequired = (object: Object3D) => {
         const deregisterCamera = node.__tres.root.deregisterCamera
 
@@ -174,12 +174,15 @@ export const nodeOps: () => RendererOptions<TresObject, TresObject | null> = ()
       node.traverse((child: Object3D) => {
         disposeMaterialsAndGeometries(child as TresObject)
         deregisterCameraIfRequired(child)
-        deregisterAtPointerEventHandlerIfRequired?.(child as TresObject)
+        // deregisterAtPointerEventHandlerIfRequired?.(child as TresObject)
+        if (child.onPointerMissed) {
+          ctx.root.eventManager.deregisterPointerMissedObject(child)
+        }
       })
 
       disposeMaterialsAndGeometries(node)
       deregisterCameraIfRequired(node as Object3D)
-      deregisterAtPointerEventHandlerIfRequired?.(node as TresObject)
+      /*  deregisterAtPointerEventHandlerIfRequired?.(node as TresObject) */
       invalidateInstance(node as TresObject)
       node.dispose?.()
     }
@@ -215,17 +218,20 @@ export const nodeOps: () => RendererOptions<TresObject, TresObject | null> = ()
       }
 
       if (node.__tres.root) {
-        const {
-          registerBlockingObjectAtPointerEventHandler,
-          deregisterBlockingObjectAtPointerEventHandler,
-        } = node.__tres.root
-
-        if (node.isObject3D && key === 'blocks-pointer-events') {
-          if (nextValue || nextValue === '') { registerBlockingObjectAtPointerEventHandler(node as Object3D) }
-          else { deregisterBlockingObjectAtPointerEventHandler(node as Object3D) }
-
-          return
-        }
+        // const {
+        //   registerBlockingObjectAtPointerEventHandler,
+        //   deregisterBlockingObjectAtPointerEventHandler,
+        // } = node.__tres.root
+      }
+      if (node?.isObject3D && key === 'blocks-pointer-events') {
+      //   if (nextValue || nextValue === '')
+      //     registerBlockingObjectAtPointerEventHandler(node as Object3D)
+      //   else
+      //     deregisterBlockingObjectAtPointerEventHandler(node as Object3D)
+
+        if (nextValue || nextValue === '') { node[key] = nextValue }
+        else { delete node[key] }
+        return
       }
 
       let finalKey = kebabToCamel(key)
@@ -237,8 +243,15 @@ export const nodeOps: () => RendererOptions<TresObject, TresObject | null> = ()
         const args = nextValue ?? []
         const instanceName = node.__tres.type || node.type
 
-        if (instanceName && prevArgs.length && !deepArrayEqual(prevArgs, args)) {
-          root = Object.assign(prevNode, new catalogue.value[instanceName](...nextValue))
+        if (
+          instanceName
+          && prevArgs.length
+          && !deepArrayEqual(prevArgs, args)
+        ) {
+          root = Object.assign(
+            prevNode,
+            new catalogue.value[instanceName](...nextValue),
+          )
         }
         return
       }

+ 6 - 0
src/utils/index.ts

@@ -40,6 +40,12 @@ export function kebabToCamel(str: string) {
   return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
 }
 
+// CamelCase to kebab-case
+const hyphenateRE = /\B([A-Z])/g
+export function hyphenate(str: string) {
+  return str.replace(hyphenateRE, '-$1').toLowerCase()
+}
+
 export function makeMap(str: string, expectsLowerCase?: boolean): (key: string) => boolean {
   const map: Record<string, boolean> = Object.create(null)
   const list: Array<string> = str.split(',')