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..082dc29298 --- /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 FileDragDrop() { + 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 9253864bab..198f4a15b4 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 @@ -110,10 +123,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': @@ -174,10 +191,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], + ), ) } @@ -382,6 +403,7 @@ export function createEvents(store: UseBoundStore) { const data = { ...hoveredObj, intersections } handlers.onPointerOut?.(data as ThreeEvent) handlers.onPointerLeave?.(data as ThreeEvent) + handlers.onDragOverLeave?.(data as ThreeEvent) } } } @@ -394,11 +416,26 @@ 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) { case 'onPointerLeave': case 'onPointerCancel': + case 'onDragLeave': return () => cancelPointer([]) case 'onLostPointerCapture': return (event: DomEvent) => { @@ -414,13 +451,15 @@ export function createEvents(store: UseBoundStore) { // Any other pointer goes here ... return function handleEvent(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) @@ -441,8 +480,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) function onIntersect(data: ThreeEvent) { const eventObject = data.eventObject @@ -469,6 +517,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 diff --git a/packages/fiber/src/core/index.tsx b/packages/fiber/src/core/index.tsx index c5922aa32c..354a6cdcaa 100644 --- a/packages/fiber/src/core/index.tsx +++ b/packages/fiber/src/core/index.tsx @@ -99,6 +99,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 => { @@ -188,6 +192,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 ba5ef7629e..6a801645f7 100644 --- a/packages/fiber/src/core/store.ts +++ b/packages/fiber/src/core/store.ts @@ -145,6 +145,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 */ @@ -213,6 +217,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 fd9606e0da..9a9583f54d 100644 --- a/packages/fiber/src/core/utils.ts +++ b/packages/fiber/src/core/utils.ts @@ -228,7 +228,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 1afaccb8be..a1260ae620 100644 --- a/packages/fiber/src/web/Canvas.tsx +++ b/packages/fiber/src/web/Canvas.tsx @@ -44,6 +44,8 @@ const CanvasImpl = /*#__PURE__*/ React.forwardRef(func raycaster, camera, onPointerMissed, + onDragOverMissed, + onDropMissed, onCreated, ...props }, @@ -63,6 +65,8 @@ const CanvasImpl = /*#__PURE__*/ React.forwardRef(func 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) @@ -91,6 +95,8 @@ const CanvasImpl = /*#__PURE__*/ React.forwardRef(func size: containerRect, // 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) => { // Connect to event source state.events.connect?.(eventSource ? (isRef(eventSource) ? eventSource.current : eventSource) : divRef.current) 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 5a753acb5c..70418e2313 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' @@ -188,7 +188,150 @@ describe('events', () => { expect(handlePointerOut).toHaveBeenCalled() }) - it('should handle stopPropogation', async () => { + 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() })