From 0921539d93d5a06f6e82631a91eec70bb2e5755d Mon Sep 17 00:00:00 2001 From: haywirez Date: Fri, 1 Jul 2022 10:40:17 +0200 Subject: [PATCH 1/4] fix: typo in events test --- packages/fiber/tests/core/events.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fiber/tests/core/events.test.tsx b/packages/fiber/tests/core/events.test.tsx index 04781e2927..f9efebcdde 100644 --- a/packages/fiber/tests/core/events.test.tsx +++ b/packages/fiber/tests/core/events.test.tsx @@ -210,7 +210,7 @@ describe('events', () => { expect(handlePointerOut).toHaveBeenCalled() }) - it('should handle stopPropogation', async () => { + it('should handle stopPropagation', async () => { const handlePointerEnter = jest.fn().mockImplementation((e) => { expect(() => e.stopPropagation()).not.toThrow() }) From ec958d98e70ca37e53917925896bffb07b097b30 Mon Sep 17 00:00:00 2001 From: haywirez Date: Fri, 1 Jul 2022 11:04:22 +0200 Subject: [PATCH 2/4] feat: implement handling of drag events (file drag & drop) Apart from the usual canvas dragenter & dragleave, this commits adds special handlers: onDragOverEnter, onDragOverLeave & onDragOverMissed. These are fired when dragover events intersect with objects in a scene or miss all of them, similar to how onPointerMissed already works. onDrop and onDropMissed are other additions. These can come handy when working on editor UIs etc. that need to attribute different drag & drop actions to different objects. --- docs/API/events.mdx | 9 +- example/src/demos/FileDragDrop.tsx | 53 ++++++++ example/src/demos/index.tsx | 2 + packages/fiber/src/core/events.ts | 74 ++++++++++- packages/fiber/src/core/index.tsx | 10 ++ packages/fiber/src/core/store.ts | 6 + packages/fiber/src/core/utils.ts | 3 +- packages/fiber/src/web/Canvas.tsx | 6 + packages/fiber/src/web/events.ts | 4 + packages/fiber/tests/core/events.test.tsx | 145 +++++++++++++++++++++- 10 files changed, 303 insertions(+), 9 deletions(-) create mode 100644 example/src/demos/FileDragDrop.tsx diff --git a/docs/API/events.mdx b/docs/API/events.mdx index 977652e7fa..bd610a3c4d 100644 --- a/docs/API/events.mdx +++ b/docs/API/events.mdx @@ -8,13 +8,20 @@ nav: 8 Additionally, there's a special `onUpdate` that is called every time the object gets fresh props, which is good for things like `self => (self.verticesNeedUpdate = true)`. -Also notice the `onPointerMissed` on the canvas element, which fires on clicks that haven't hit _any_ meshes. +Also notice the `onPointerMissed` on the canvas element, which fires on clicks that haven't hit _any_ meshes. Similarly, `onDragOverMissed` and `onDropMissed` can handle actions that need to be taken when file drag & drop drag events are not hitting items in the scene. ```jsx console.log('click')} onContextMenu={(e) => console.log('context menu')} onDoubleClick={(e) => console.log('double click')} + onDragEnter={(e) => console.log('drag enter')} + onDragLeave={(e) => console.log('drag leave')} + onDragOverEnter={(e) => console.log('dragover enter')} + onDragOverLeave={(e) => console.log('dragover leave')} + onDragOverMissed={(e) => console.log('dragover missed')} + onDrop={(e) => console.log('dropped')} + onDropMissed={(e) => console.log('drop missed')} onWheel={(e) => console.log('wheel spins')} onPointerUp={(e) => console.log('up')} onPointerDown={(e) => console.log('down')} diff --git a/example/src/demos/FileDragDrop.tsx b/example/src/demos/FileDragDrop.tsx new file mode 100644 index 0000000000..bb8407f422 --- /dev/null +++ b/example/src/demos/FileDragDrop.tsx @@ -0,0 +1,53 @@ +import React, { SyntheticEvent, useState } from 'react' +import { Canvas } from '@react-three/fiber' +import { a, useSpring } from '@react-spring/three' +import { OrbitControls } from '@react-three/drei' + +export default function Box() { + const [active, setActive] = useState(0) + const [activeBg, setActiveBg] = useState(0) + // create a common spring that will be used later to interpolate other values + const { spring } = useSpring({ + spring: active, + config: { mass: 5, tension: 400, friction: 50, precision: 0.0001 }, + }) + // interpolate values from commong spring + const scale = spring.to([0, 1], [1, 2]) + const rotation = spring.to([0, 1], [0, Math.PI]) + const color = active ? spring.to([0, 1], ['#6246ea', '#e45858']) : spring.to([0, 1], ['#620000', '#e40000']) + const bgColor = activeBg ? 'lightgreen' : 'lightgray' + const preventDragDropDefaults = { + onDrop: (e: SyntheticEvent) => e.preventDefault(), + onDragEnter: (e: SyntheticEvent) => e.preventDefault(), + onDragOver: (e: SyntheticEvent) => e.preventDefault(), + } + return ( + { + console.log('drop missed!') + setActiveBg(0) + }} + onDragOverMissed={(e) => setActiveBg(1)} + onDragLeave={() => setActiveBg(0)}> + + { + console.log('dropped!') + setActive(0) + }} + onDragOverEnter={() => { + setActive(1) + setActiveBg(0) + }} + onDragOverLeave={() => setActive(0)}> + + + + + + ) +} diff --git a/example/src/demos/index.tsx b/example/src/demos/index.tsx index e2c31327e7..90807ec669 100644 --- a/example/src/demos/index.tsx +++ b/example/src/demos/index.tsx @@ -4,6 +4,7 @@ const Animation = { Component: lazy(() => import('./Animation')) } const AutoDispose = { Component: lazy(() => import('./AutoDispose')) } const ClickAndHover = { Component: lazy(() => import('./ClickAndHover')) } const ContextMenuOverride = { Component: lazy(() => import('./ContextMenuOverride')) } +const FileDragDrop = { Component: lazy(() => import('./FileDragDrop')) } const Gestures = { Component: lazy(() => import('./Gestures')) } const Gltf = { Component: lazy(() => import('./Gltf')) } const Inject = { Component: lazy(() => import('./Inject')) } @@ -30,6 +31,7 @@ export { AutoDispose, ClickAndHover, ContextMenuOverride, + FileDragDrop, Gestures, Gltf, Inject, diff --git a/packages/fiber/src/core/events.ts b/packages/fiber/src/core/events.ts index 749d17ca65..a16d372b32 100644 --- a/packages/fiber/src/core/events.ts +++ b/packages/fiber/src/core/events.ts @@ -41,6 +41,12 @@ export type Events = { onClick: EventListener onContextMenu: EventListener onDoubleClick: EventListener + onDragEnter: EventListener + onDragLeave: EventListener + onDragOverEnter: EventListener + onDragOverLeave: EventListener + onDrop: EventListener + onDropMissed: EventListener onWheel: EventListener onPointerDown: EventListener onPointerUp: EventListener @@ -54,6 +60,13 @@ export type EventHandlers = { onClick?: (event: ThreeEvent) => void onContextMenu?: (event: ThreeEvent) => void onDoubleClick?: (event: ThreeEvent) => void + onDragEnter?: (event: ThreeEvent) => void + onDragLeave?: (event: ThreeEvent) => void + onDragOverEnter?: (event: ThreeEvent) => void + onDragOverLeave?: (event: ThreeEvent) => void + onDragOverMissed?: (event: DragEvent) => void + onDrop?: (event: ThreeEvent) => void + onDropMissed?: (event: DragEvent) => void onPointerUp?: (event: ThreeEvent) => void onPointerDown?: (event: ThreeEvent) => void onPointerOver?: (event: ThreeEvent) => void @@ -105,10 +118,14 @@ export function getEventPriority() { case 'click': case 'contextmenu': case 'dblclick': + case 'dragenter': + case 'dragleave': + case 'drop': case 'pointercancel': case 'pointerdown': case 'pointerup': return DiscreteEventPriority + case 'dragover': case 'pointermove': case 'pointerout': case 'pointerover': @@ -171,10 +188,14 @@ export function createEvents(store: UseBoundStore) { /** Returns true if an instance has a valid pointer-event registered, this excludes scroll, clicks etc */ function filterPointerEvents(objects: THREE.Object3D[]) { - return objects.filter((obj) => - ['Move', 'Over', 'Enter', 'Out', 'Leave'].some( - (name) => (obj as unknown as Instance).__r3f?.handlers[('onPointer' + name) as keyof EventHandlers], - ), + return objects.filter( + (obj) => + ['Move', 'Over', 'Enter', 'Out', 'Leave'].some( + (name) => (obj as unknown as Instance).__r3f?.handlers[('onPointer' + name) as keyof EventHandlers], + ) || + ['Over', 'Enter', 'Leave'].some( + (name) => (obj as unknown as Instance).__r3f?.handlers[('onDrag' + name) as keyof EventHandlers], + ), ) } @@ -377,6 +398,8 @@ export function createEvents(store: UseBoundStore) { const data = { ...hoveredObj, intersections } handlers.onPointerOut?.(data as ThreeEvent) handlers.onPointerLeave?.(data as ThreeEvent) + // @ts-ignore + handlers.onDragOverLeave?.(data) } } }) @@ -387,6 +410,7 @@ export function createEvents(store: UseBoundStore) { switch (name) { case 'onPointerLeave': case 'onPointerCancel': + case 'onDragLeave': return () => cancelPointer([]) case 'onLostPointerCapture': return (event: DomEvent) => { @@ -402,13 +426,15 @@ export function createEvents(store: UseBoundStore) { // Any other pointer goes here ... return (event: DomEvent) => { - const { onPointerMissed, internal } = store.getState() + const { onPointerMissed, onDragOverMissed, onDropMissed, internal } = store.getState() //prepareRay(event) internal.lastEvent.current = event // Get fresh intersects const isPointerMove = name === 'onPointerMove' + const isDragOver = name === 'onDragOver' + const isDrop = name === 'onDrop' const isClickEvent = name === 'onClick' || name === 'onContextMenu' || name === 'onDoubleClick' const filter = isPointerMove ? filterPointerEvents : undefined //const hits = patchIntersects(intersect(filter), event) @@ -429,8 +455,17 @@ export function createEvents(store: UseBoundStore) { if (onPointerMissed) onPointerMissed(event) } } + if (isDragOver && !hits.length) { + dragOverMissed(event as DragEvent, internal.interaction) + if (onDragOverMissed) onDragOverMissed(event as DragEvent) + } + if (isDrop && !hits.length) { + dropMissed(event as DragEvent, internal.interaction) + if (onDropMissed) onDropMissed(event as DragEvent) + } + // Take care of unhover - if (isPointerMove) cancelPointer(hits) + if (isPointerMove || isDragOver) cancelPointer(hits) handleIntersects(hits, event, delta, (data: ThreeEvent) => { const eventObject = data.eventObject @@ -457,6 +492,23 @@ export function createEvents(store: UseBoundStore) { } // Call mouse move handlers.onPointerMove?.(data as ThreeEvent) + } else if (isDragOver) { + // When enter or out is present take care of hover-state + const id = makeId(data) + const hoveredItem = internal.hovered.get(id) + if (!hoveredItem) { + // If the object wasn't previously hovered, book it and call its handler + internal.hovered.set(id, data) + handlers.onDragOverEnter?.(data as ThreeEvent) + } else if (hoveredItem.stopped) { + // If the object was previously hovered and stopped, we shouldn't allow other items to proceed + data.stopPropagation() + } else if (internal.initialHits.includes(eventObject)) { + dragOverMissed( + event as DragEvent, + internal.interaction.filter((object) => !internal.initialHits.includes(object)), + ) + } } else { // All other events ... const handler = handlers[name as keyof EventHandlers] as (event: ThreeEvent) => void @@ -492,5 +544,15 @@ export function createEvents(store: UseBoundStore) { ) } + function dragOverMissed(event: DragEvent, objects: THREE.Object3D[]) { + objects.forEach((object: THREE.Object3D) => + (object as unknown as Instance).__r3f?.handlers.onDragOverMissed?.(event), + ) + } + + function dropMissed(event: DragEvent, objects: THREE.Object3D[]) { + objects.forEach((object: THREE.Object3D) => (object as unknown as Instance).__r3f?.handlers.onDropMissed?.(event)) + } + return { handlePointer } } diff --git a/packages/fiber/src/core/index.tsx b/packages/fiber/src/core/index.tsx index 749d15a762..d2974dced0 100644 --- a/packages/fiber/src/core/index.tsx +++ b/packages/fiber/src/core/index.tsx @@ -98,6 +98,10 @@ export type RenderProps = { onCreated?: (state: RootState) => void /** Response for pointer clicks that have missed any target */ onPointerMissed?: (event: MouseEvent) => void + /** Response for dragover events that have missed any target */ + onDragOverMissed?: (event: DragEvent) => void + /** Response for drop events that have missed any target */ + onDropMissed?: (event: DragEvent) => void } const createRendererInstance = (gl: GLProps, canvas: TElement): THREE.WebGLRenderer => { @@ -169,6 +173,8 @@ function createRoot(canvas: TCanvas): ReconcilerRoot(canvas: TCanvas): ReconcilerRoot ({ performance: { ...state.performance, ...performance } })) diff --git a/packages/fiber/src/core/store.ts b/packages/fiber/src/core/store.ts index a32ea1e492..b4d37aa238 100644 --- a/packages/fiber/src/core/store.ts +++ b/packages/fiber/src/core/store.ts @@ -141,6 +141,10 @@ export type RootState = { setFrameloop: (frameloop?: 'always' | 'demand' | 'never') => void /** When the canvas was clicked but nothing was hit */ onPointerMissed?: (event: MouseEvent) => void + /** When the canvas was dragover but nothing was hit */ + onDragOverMissed?: (event: DragEvent) => void + /** When the canvas was dropped but nothing was hit */ + onDropMissed?: (event: DragEvent) => void /** If this state model is layerd (via createPortal) then this contains the previous layer */ previousRoot?: UseBoundStore> /** Internals */ @@ -209,6 +213,8 @@ const createStore = ( frameloop: 'always', onPointerMissed: undefined, + onDragOverMissed: undefined, + onDropMissed: undefined, performance: { current: 1, diff --git a/packages/fiber/src/core/utils.ts b/packages/fiber/src/core/utils.ts index 6784c10eae..0ae4dcca97 100644 --- a/packages/fiber/src/core/utils.ts +++ b/packages/fiber/src/core/utils.ts @@ -219,7 +219,8 @@ export function diffProps( // When props match bail out if (is.equ(value, previous[key])) return // Collect handlers and bail out - if (/^on(Pointer|Click|DoubleClick|ContextMenu|Wheel)/.test(key)) return changes.push([key, value, true, []]) + if (/^on(Pointer|DragOver|Drop|Click|DoubleClick|ContextMenu|Wheel)/.test(key)) + return changes.push([key, value, true, []]) // Split dashed props let entries: string[] = [] if (key.includes('-')) entries = key.split('-') diff --git a/packages/fiber/src/web/Canvas.tsx b/packages/fiber/src/web/Canvas.tsx index 33551104c4..cdb5587dfd 100644 --- a/packages/fiber/src/web/Canvas.tsx +++ b/packages/fiber/src/web/Canvas.tsx @@ -40,6 +40,8 @@ export const Canvas = /*#__PURE__*/ React.forwardRef(f raycaster, camera, onPointerMissed, + onDragOverMissed, + onDropMissed, onCreated, ...props }, @@ -57,6 +59,8 @@ export const Canvas = /*#__PURE__*/ React.forwardRef(f React.useImperativeHandle(forwardedRef, () => canvasRef.current) const handlePointerMissed = useMutableCallback(onPointerMissed) + const handleDragOverMissed = useMutableCallback(onDragOverMissed) + const handleDropMissed = useMutableCallback(onDropMissed) const [block, setBlock] = React.useState(false) const [error, setError] = React.useState(false) @@ -85,6 +89,8 @@ export const Canvas = /*#__PURE__*/ React.forwardRef(f size: { width, height }, // Pass mutable reference to onPointerMissed so it's free to update onPointerMissed: (...args) => handlePointerMissed.current?.(...args), + onDragOverMissed: (...args) => handleDragOverMissed.current?.(...args), + onDropMissed: (...args) => handleDropMissed.current?.(...args), onCreated: (state) => { state.events.connect?.(divRef.current) onCreated?.(state) diff --git a/packages/fiber/src/web/events.ts b/packages/fiber/src/web/events.ts index 7dbfeb8e8c..5e7da7cc5d 100644 --- a/packages/fiber/src/web/events.ts +++ b/packages/fiber/src/web/events.ts @@ -6,6 +6,10 @@ const DOM_EVENTS = { onClick: ['click', false], onContextMenu: ['contextmenu', false], onDoubleClick: ['dblclick', false], + onDragEnter: ['dragenter', false], + onDragLeave: ['dragleave', false], + onDragOver: ['dragover', false], + onDrop: ['drop', false], onWheel: ['wheel', true], onPointerDown: ['pointerdown', true], onPointerUp: ['pointerup', true], diff --git a/packages/fiber/tests/core/events.test.tsx b/packages/fiber/tests/core/events.test.tsx index f9efebcdde..ccb4c4eb25 100644 --- a/packages/fiber/tests/core/events.test.tsx +++ b/packages/fiber/tests/core/events.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { render, fireEvent, RenderResult } from '@testing-library/react' +import { render, fireEvent, createEvent, RenderResult } from '@testing-library/react' import { Canvas, act } from '../../src' @@ -210,6 +210,149 @@ describe('events', () => { expect(handlePointerOut).toHaveBeenCalled() }) + it('can handle dragover events via onDragOverEnter & onDragOverLeave', async () => { + const handleDragOverEnter = jest.fn() + const handleDragOverLeave = jest.fn() + + await act(async () => { + render( + + + + + + , + ) + }) + + // Note: DragEvent is not implemented in jsdom yet: https://github.com/jsdom/jsdom/issues/2913 + // however, @react-testing/library does simulate it + let evt = createEvent.dragOver(getContainer()) + //@ts-ignore + evt.offsetX = 577 + //@ts-ignore + evt.offsetY = 480 + + fireEvent(getContainer(), evt) + + expect(handleDragOverEnter).toHaveBeenCalled() + + // pretend we moved out over from the target + //@ts-ignore + evt.offsetX = 1 + //@ts-ignore + evt.offsetY = 1 + fireEvent(getContainer(), evt) + + expect(handleDragOverLeave).toHaveBeenCalled() + }) + + it('can handle onDragOverMissed', async () => { + const handleDragOverMissed = jest.fn() + + await act(async () => { + render( + + + + + + , + ) + }) + + // Note: DragEvent is not implemented in jsdom yet: https://github.com/jsdom/jsdom/issues/2913 + // https://developer.mozilla.org/en-US/docs/Web/API/DragEvent + // however, @react-testing/library does simulate it + let evt = createEvent.dragOver(getContainer()) + //@ts-ignore + evt.offsetX = 1 + //@ts-ignore + evt.offsetY = 1 + + fireEvent(getContainer(), evt) + + expect(handleDragOverMissed).toHaveBeenCalled() + }) + + it('can handle onDragEnter & onDragLeave', async () => { + const handleDragEnter = jest.fn() + const handleDragLeave = jest.fn() + + await act(async () => { + render( + + + + + + , + ) + }) + + // Note: DragEvent is not implemented in jsdom yet: https://github.com/jsdom/jsdom/issues/2913 + // https://developer.mozilla.org/en-US/docs/Web/API/DragEvent + // however, @react-testing/library does simulate it + let evt = createEvent.dragEnter(getContainer()) + //@ts-ignore + evt.offsetX = 10 + //@ts-ignore + evt.offsetY = 10 + + fireEvent(getContainer(), evt) + + expect(handleDragEnter).toHaveBeenCalled() + + evt = createEvent.dragLeave(getContainer()) + //@ts-ignore + evt.offsetX = 0 + //@ts-ignore + evt.offsetY = 0 + + fireEvent(getContainer(), evt) + + expect(handleDragLeave).toHaveBeenCalled() + }) + + it('can handle onDrop & onDropMissed', async () => { + const handleOnDrop = jest.fn() + const handleOnDropMissed = jest.fn() + + await act(async () => { + render( + + + + + + , + ) + }) + + // Note: DragEvent is not implemented in jsdom yet: https://github.com/jsdom/jsdom/issues/2913 + // however, @react-testing/library does simulate it + let evt = createEvent.drop(getContainer()) + //@ts-ignore + evt.offsetX = 577 + //@ts-ignore + evt.offsetY = 480 + + fireEvent(getContainer(), evt) + + expect(handleOnDrop).toHaveBeenCalled() + + // pretend we moved out over from the target + //@ts-ignore + evt.offsetX = 1 + //@ts-ignore + evt.offsetY = 1 + fireEvent(getContainer(), evt) + + // second event shouldn't register + expect(handleOnDrop).toHaveBeenCalledTimes(1) + expect(handleOnDropMissed).toHaveBeenCalled() + }) + it('should handle stopPropagation', async () => { const handlePointerEnter = jest.fn().mockImplementation((e) => { expect(() => e.stopPropagation()).not.toThrow() From 8f9cec582d9b059c33e6edf93e7febde54598c36 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Tue, 4 Oct 2022 20:36:03 -0500 Subject: [PATCH 3/4] fix: prefer imperative loop for profilers, cleanup TS cast --- packages/fiber/src/core/events.ts | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/packages/fiber/src/core/events.ts b/packages/fiber/src/core/events.ts index a66ea3c90b..198f4a15b4 100644 --- a/packages/fiber/src/core/events.ts +++ b/packages/fiber/src/core/events.ts @@ -403,8 +403,7 @@ export function createEvents(store: UseBoundStore) { const data = { ...hoveredObj, intersections } handlers.onPointerOut?.(data as ThreeEvent) handlers.onPointerLeave?.(data as ThreeEvent) - // @ts-ignore - handlers.onDragOverLeave?.(data) + handlers.onDragOverLeave?.(data as ThreeEvent) } } } @@ -417,6 +416,20 @@ export function createEvents(store: UseBoundStore) { } } + function dragOverMissed(event: DragEvent, objects: THREE.Object3D[]) { + for (let i = 0; i < objects.length; i++) { + const instance = (objects[i] as unknown as Instance).__r3f + instance?.handlers.onDragOverMissed?.(event) + } + } + + function dropMissed(event: DragEvent, objects: THREE.Object3D[]) { + for (let i = 0; i < objects.length; i++) { + const instance = (objects[i] as unknown as Instance).__r3f + instance?.handlers.onDropMissed?.(event) + } + } + function handlePointer(name: string) { // Deal with cancelation switch (name) { @@ -552,15 +565,5 @@ export function createEvents(store: UseBoundStore) { } } - function dragOverMissed(event: DragEvent, objects: THREE.Object3D[]) { - objects.forEach((object: THREE.Object3D) => - (object as unknown as Instance).__r3f?.handlers.onDragOverMissed?.(event), - ) - } - - function dropMissed(event: DragEvent, objects: THREE.Object3D[]) { - objects.forEach((object: THREE.Object3D) => (object as unknown as Instance).__r3f?.handlers.onDropMissed?.(event)) - } - return { handlePointer } } From 8f13a9a5f4e9833c04be3459f48ce64d566ca0a4 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Tue, 4 Oct 2022 20:39:08 -0500 Subject: [PATCH 4/4] chore: name component with example --- example/src/demos/FileDragDrop.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/src/demos/FileDragDrop.tsx b/example/src/demos/FileDragDrop.tsx index bb8407f422..082dc29298 100644 --- a/example/src/demos/FileDragDrop.tsx +++ b/example/src/demos/FileDragDrop.tsx @@ -3,7 +3,7 @@ import { Canvas } from '@react-three/fiber' import { a, useSpring } from '@react-spring/three' import { OrbitControls } from '@react-three/drei' -export default function Box() { +export default function FileDragDrop() { const [active, setActive] = useState(0) const [activeBg, setActiveBg] = useState(0) // create a common spring that will be used later to interpolate other values