From ec958d98e70ca37e53917925896bffb07b097b30 Mon Sep 17 00:00:00 2001 From: haywirez Date: Fri, 1 Jul 2022 11:04:22 +0200 Subject: [PATCH] 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()