├── .gitattributes ├── .github ├── FUNDING.yml ├── workflows │ ├── lint-gha.yml │ └── build.yml └── dependabot.yaml ├── babel.config.json ├── .jest-setup.ts ├── packages ├── dnd-multi-backend │ ├── tsconfig.json │ ├── .npmignore │ ├── src │ │ ├── createTransition.ts │ │ ├── MultiFactory.ts │ │ ├── index.ts │ │ ├── __tests__ │ │ │ ├── createTransition.test.ts │ │ │ ├── index.test.ts │ │ │ ├── MultiFactory.test.ts │ │ │ ├── PreviewListImpl.test.ts │ │ │ ├── transitions.test.ts │ │ │ └── MultiBackendImpl.test.ts │ │ ├── PreviewListImpl.ts │ │ ├── types.ts │ │ ├── transitions.ts │ │ └── MultiBackendImpl.ts │ ├── package.json │ ├── examples │ │ ├── index.ts │ │ ├── DnD.ts │ │ └── Backends.ts │ └── README.md ├── react-dnd-preview │ ├── tsconfig.json │ ├── .npmignore │ ├── src │ │ ├── Context.ts │ │ ├── __mocks__ │ │ │ └── usePreview.ts │ │ ├── index.ts │ │ ├── __tests__ │ │ │ ├── index.test.ts │ │ │ ├── Preview.test.tsx │ │ │ └── usePreview.test.ts │ │ ├── Preview.tsx │ │ ├── usePreview.ts │ │ └── offsets.ts │ ├── examples │ │ ├── main │ │ │ ├── index.tsx │ │ │ ├── App.tsx │ │ │ └── methods │ │ │ │ ├── Hooks.tsx │ │ │ │ ├── Components.tsx │ │ │ │ └── common.tsx │ │ ├── offset │ │ │ ├── index.tsx │ │ │ └── App.tsx │ │ └── shared.tsx │ ├── package.json │ └── README.md ├── react-dnd-multi-backend │ ├── src │ │ ├── hooks │ │ │ ├── index.ts │ │ │ ├── usePreview.ts │ │ │ ├── useMultiDrop.ts │ │ │ ├── useMultiDrag.ts │ │ │ ├── useObservePreviews.ts │ │ │ ├── useMultiCommon.ts │ │ │ └── __tests__ │ │ │ │ ├── useMultiDrag.test.tsx │ │ │ │ ├── useMultiDrop.test.tsx │ │ │ │ └── usePreview.test.tsx │ │ ├── index.ts │ │ ├── components │ │ │ ├── Preview.tsx │ │ │ ├── DndProvider.tsx │ │ │ └── __tests__ │ │ │ │ ├── DndProvider.test.tsx │ │ │ │ └── Preview.test.tsx │ │ └── __tests__ │ │ │ └── index.test.ts │ ├── .npmignore │ ├── tsconfig.json │ ├── examples │ │ ├── index.tsx │ │ ├── common.ts │ │ ├── Card.tsx │ │ ├── Basket.tsx │ │ ├── MultiCard.tsx │ │ ├── MultiBasket.tsx │ │ └── App.tsx │ ├── package.json │ └── README.md └── rdndmb-html5-to-touch │ ├── .npmignore │ ├── tsconfig.json │ ├── src │ ├── index.ts │ └── __tests__ │ │ └── index.test.ts │ ├── package.json │ └── README.md ├── esbuild ├── examples.js ├── dev.js ├── build.js ├── config.js ├── run-all.js ├── common.js └── publish.js ├── .gitignore ├── tsconfig.build.json ├── .npmignore ├── examples ├── react-dnd-preview.html ├── react-dnd-preview_offset.html ├── index.html ├── dnd-multi-backend.html └── react-dnd-multi-backend.html ├── __mocks__ ├── react-dnd.ts ├── react-dnd-preview.ts ├── pipeline.ts └── mocks.ts ├── biome.json ├── jest.config.js ├── tsconfig.json ├── LICENSE ├── README.md └── package.json /.gitattributes: -------------------------------------------------------------------------------- 1 | package-lock.json -diff 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [LouisBrunner] 2 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-typescript"] 3 | } 4 | -------------------------------------------------------------------------------- /.jest-setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom' 2 | import React from 'react' 3 | global.React = React 4 | -------------------------------------------------------------------------------- /packages/dnd-multi-backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.build.json", 3 | "include": ["src/**/*.ts*"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/react-dnd-preview/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.build.json", 3 | "include": ["src/**/*.ts*"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/react-dnd-multi-backend/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useMultiDrag.js' 2 | export * from './useMultiDrop.js' 3 | export * from './usePreview.js' 4 | -------------------------------------------------------------------------------- /esbuild/examples.js: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild' 2 | import {examples} from './config.js' 3 | 4 | esbuild.build({ 5 | ...examples, 6 | outdir: 'examples/', 7 | }) 8 | -------------------------------------------------------------------------------- /packages/dnd-multi-backend/.npmignore: -------------------------------------------------------------------------------- 1 | # Input 2 | examples 3 | __mocks__ 4 | __tests__ 5 | src 6 | tsconfig.json 7 | 8 | # npm 9 | node_modules 10 | npm-debug.log 11 | -------------------------------------------------------------------------------- /packages/react-dnd-preview/.npmignore: -------------------------------------------------------------------------------- 1 | # Input 2 | examples 3 | __mocks__ 4 | __tests__ 5 | src 6 | tsconfig.json 7 | 8 | # npm 9 | node_modules 10 | npm-debug.log 11 | -------------------------------------------------------------------------------- /packages/rdndmb-html5-to-touch/.npmignore: -------------------------------------------------------------------------------- 1 | # Input 2 | examples 3 | __mocks__ 4 | __tests__ 5 | src 6 | tsconfig.json 7 | 8 | # npm 9 | node_modules 10 | npm-debug.log 11 | -------------------------------------------------------------------------------- /packages/react-dnd-multi-backend/.npmignore: -------------------------------------------------------------------------------- 1 | # Input 2 | examples 3 | __mocks__ 4 | __tests__ 5 | src 6 | tsconfig.json 7 | 8 | # npm 9 | node_modules 10 | npm-debug.log 11 | -------------------------------------------------------------------------------- /packages/rdndmb-html5-to-touch/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.build.json", 3 | "compilerOptions": { 4 | "baseUrl": "." 5 | }, 6 | "include": ["src/**/*.ts*"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/react-dnd-multi-backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.build.json", 3 | "compilerOptions": { 4 | "baseUrl": "." 5 | }, 6 | "include": ["src/**/*.ts*"] 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS files 2 | .DS_Store 3 | 4 | # npm 5 | node_modules 6 | *-debug.log 7 | 8 | # Code coverage 9 | /coverage 10 | 11 | # Output 12 | packages/*/dist 13 | webpack-stats.json 14 | 15 | # IDE 16 | .vscode 17 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "emitDeclarationOnly": true 6 | }, 7 | "exclude": ["node_modules", "**/__tests__/**/*", "**/__mocks__/**/*"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/dnd-multi-backend/src/createTransition.ts: -------------------------------------------------------------------------------- 1 | import type {Transition} from './types.js' 2 | 3 | export const createTransition = (event: Transition['event'], check: Transition['check']): Transition => { 4 | return { 5 | event, 6 | check, 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/react-dnd-multi-backend/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from 'dnd-multi-backend' 2 | export {DndProvider} from './components/DndProvider.js' 3 | export type {DndProviderProps} from './components/DndProvider.js' 4 | export * from './components/Preview.js' 5 | export * from './hooks/index.js' 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # OS files 2 | .DS_Store 3 | 4 | # esbuild 5 | /esbuild 6 | 7 | # Output 8 | /examples 9 | 10 | # npm 11 | node_modules 12 | npm-debug.log 13 | 14 | # Config 15 | biome.json 16 | .jest.config.js 17 | .github 18 | lerna.json 19 | tsconfig.*json 20 | 21 | # Code coverage 22 | coverage 23 | -------------------------------------------------------------------------------- /packages/react-dnd-preview/src/Context.ts: -------------------------------------------------------------------------------- 1 | import {createContext} from 'react' 2 | import type {usePreviewStateContent} from './usePreview.js' 3 | 4 | export type PreviewState = usePreviewStateContent 5 | 6 | export const Context = createContext(undefined) 7 | -------------------------------------------------------------------------------- /packages/react-dnd-multi-backend/examples/index.tsx: -------------------------------------------------------------------------------- 1 | import {createRoot} from 'react-dom/client' 2 | import {App} from './App.js' 3 | 4 | const rootElement = document.getElementById('root') 5 | if (!rootElement) { 6 | throw new Error('could not find root element') 7 | } 8 | const root = createRoot(rootElement) 9 | root.render() 10 | -------------------------------------------------------------------------------- /packages/react-dnd-preview/examples/main/index.tsx: -------------------------------------------------------------------------------- 1 | import {createRoot} from 'react-dom/client' 2 | import {App} from './App.js' 3 | 4 | const rootElement = document.getElementById('root') 5 | if (!rootElement) { 6 | throw new Error('could not find root element') 7 | } 8 | const root = createRoot(rootElement) 9 | root.render() 10 | -------------------------------------------------------------------------------- /packages/react-dnd-preview/examples/offset/index.tsx: -------------------------------------------------------------------------------- 1 | import {createRoot} from 'react-dom/client' 2 | import {App} from './App.js' 3 | 4 | const rootElement = document.getElementById('root') 5 | if (!rootElement) { 6 | throw new Error('could not find root element') 7 | } 8 | const root = createRoot(rootElement) 9 | root.render() 10 | -------------------------------------------------------------------------------- /packages/react-dnd-preview/src/__mocks__/usePreview.ts: -------------------------------------------------------------------------------- 1 | import type {usePreviewState} from '../usePreview.js' 2 | 3 | let mockReturn: usePreviewState 4 | 5 | export const __setMockReturn = (state: usePreviewState): void => { 6 | mockReturn = state 7 | } 8 | 9 | export const usePreview = (): usePreviewState => { 10 | return mockReturn 11 | } 12 | -------------------------------------------------------------------------------- /esbuild/dev.js: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild' 2 | import {examples} from './config.js' 3 | 4 | const baseDir = 'examples/' 5 | 6 | const context = await esbuild.context({ 7 | ...examples, 8 | outdir: baseDir, 9 | }) 10 | 11 | const server = await context.serve({ 12 | servedir: baseDir, 13 | port: 4001, 14 | }) 15 | console.log(`Server available at http://${server.host}:${server.port}`) 16 | -------------------------------------------------------------------------------- /esbuild/build.js: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild' 2 | import {nodeExternalsPlugin} from 'esbuild-node-externals' 3 | import tsConfig from '../tsconfig.json' with {type: 'json'} 4 | 5 | esbuild.build({ 6 | entryPoints: ['./src/index.ts'], 7 | format: 'esm', 8 | bundle: true, 9 | outfile: 'dist/index.js', 10 | minify: true, 11 | plugins: [nodeExternalsPlugin()], 12 | target: tsConfig.compilerOptions.target, 13 | }) 14 | -------------------------------------------------------------------------------- /examples/react-dnd-preview.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | react-dnd-preview Examples 5 | 6 | 7 |

Dragging the square will display previews using different methods (see source for details) in order to showcase all the different APIs

8 |
React is not working
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /packages/react-dnd-preview/src/index.ts: -------------------------------------------------------------------------------- 1 | export {Preview} from './Preview.js' 2 | export type {PreviewProps, PreviewGenerator} from './Preview.js' 3 | export {usePreview} from './usePreview.js' 4 | export type {PreviewPlacement, Point} from './offsets.js' 5 | export type {usePreviewState, usePreviewStateContent, usePreviewOptions} from './usePreview.js' 6 | export {Context} from './Context.js' 7 | export type {PreviewState} from './Context.js' 8 | -------------------------------------------------------------------------------- /examples/react-dnd-preview_offset.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | react-dnd-preview (offset testing) Examples 5 | 6 | 7 |

Dragging the square will display previews using different offsets (see source for details) in order to showcase all the different APIs

8 |
React is not working
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /packages/react-dnd-preview/src/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import * as Module from '../index.js' 2 | 3 | import {Context} from '../Context.js' 4 | import {Preview} from '../Preview.js' 5 | import {usePreview} from '../usePreview.js' 6 | 7 | describe('react-dnd-preview module', () => { 8 | test('exports correctly', () => { 9 | expect(Module.Preview).toBe(Preview) 10 | expect(Module.Context).toBe(Context) 11 | expect(Module.usePreview).toBe(usePreview) 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /packages/dnd-multi-backend/src/MultiFactory.ts: -------------------------------------------------------------------------------- 1 | import type {BackendFactory, DragDropManager} from 'dnd-core' 2 | import {type MultiBackendContext, MultiBackendImpl, type MultiBackendOptions} from './MultiBackendImpl.js' 3 | import type {MultiBackendSwitcher} from './types.js' 4 | 5 | export const MultiFactory: BackendFactory = (manager: DragDropManager, context: MultiBackendContext, options: MultiBackendOptions): MultiBackendSwitcher => { 6 | return new MultiBackendImpl(manager, context, options) 7 | } 8 | -------------------------------------------------------------------------------- /packages/react-dnd-multi-backend/examples/common.ts: -------------------------------------------------------------------------------- 1 | import {useCallback} from 'react' 2 | import type {ConnectDragSource, ConnectDropTarget} from 'react-dnd' 3 | 4 | export type DragContent = { 5 | color: string 6 | } 7 | 8 | // FIXME: issue with react-dnd when using React v19 9 | export const useFixRDnDRef = (ref: ConnectDropTarget | ConnectDragSource) => { 10 | return useCallback( 11 | (node: T | null) => { 12 | ref(node) 13 | }, 14 | [ref], 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /esbuild/config.js: -------------------------------------------------------------------------------- 1 | export const examples = { 2 | entryPoints: { 3 | 'examples_dnd-multi-backend.min': './packages/dnd-multi-backend/examples/index.ts', 4 | 'examples_react-dnd-multi-backend.min': './packages/react-dnd-multi-backend/examples/index.tsx', 5 | 'examples_react-dnd-preview_main.min': './packages/react-dnd-preview/examples/main/index.tsx', 6 | 'examples_react-dnd-preview_offset.min': './packages/react-dnd-preview/examples/offset/index.tsx', 7 | }, 8 | bundle: true, 9 | minify: true, 10 | } 11 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Examples 5 | 6 | 7 |

Here are examples for each package:

8 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /esbuild/run-all.js: -------------------------------------------------------------------------------- 1 | import {executeCommand, getWorkspaces} from './common.js' 2 | 3 | // Note: this is not a technically a esbuild-related script. 4 | const main = async () => { 5 | if (process.argv.length < 3) { 6 | console.error('Usage: node run-all.js ') 7 | process.exit(1) 8 | } 9 | const workspaces = getWorkspaces() 10 | const args = process.argv.slice(2) 11 | for (const workspace of workspaces) { 12 | console.log(`🔨 Running in ${workspace.name}: ${args.join(' ')}`) 13 | executeCommand(args[0], args.slice(1), { 14 | cwd: workspace.path, 15 | }) 16 | } 17 | } 18 | 19 | main() 20 | -------------------------------------------------------------------------------- /packages/react-dnd-multi-backend/src/hooks/usePreview.ts: -------------------------------------------------------------------------------- 1 | import {usePreview as usePreviewDnd, type usePreviewOptions, type usePreviewState, type usePreviewStateContent} from 'react-dnd-preview' 2 | 3 | import {useObservePreviews} from './useObservePreviews.js' 4 | 5 | export type {usePreviewState, usePreviewStateContent} 6 | 7 | export const usePreview = (props?: usePreviewOptions): usePreviewState => { 8 | const enabled = useObservePreviews() 9 | const result = usePreviewDnd(props) 10 | if (!enabled) { 11 | return {display: false} 12 | } 13 | return result 14 | } 15 | -------------------------------------------------------------------------------- /packages/dnd-multi-backend/src/index.ts: -------------------------------------------------------------------------------- 1 | export {MultiFactory as MultiBackend} from './MultiFactory.js' 2 | 3 | export {createTransition} from './createTransition.js' 4 | export { 5 | HTML5DragTransition, 6 | TouchTransition, 7 | MouseTransition, 8 | PointerTransition, 9 | } from './transitions.js' 10 | 11 | export type { 12 | MultiBackendOptions, 13 | MultiBackendPipeline, 14 | MultiBackendPipelineStep, 15 | MultiBackendContext, 16 | } from './MultiBackendImpl.js' 17 | 18 | export type { 19 | MultiBackendSwitcher, 20 | PreviewList, 21 | PreviewListener, 22 | BackendEntry, 23 | Transition, 24 | } from './types.js' 25 | -------------------------------------------------------------------------------- /packages/react-dnd-multi-backend/src/hooks/useMultiDrop.ts: -------------------------------------------------------------------------------- 1 | import {type ConnectDropTarget, type DropTargetHookSpec, useDrop} from 'react-dnd' 2 | import {useMultiCommon} from './useMultiCommon.js' 3 | 4 | export type useMultiDropOneState = [Props, ConnectDropTarget] 5 | 6 | export type useMultiDropState = [useMultiDropOneState, Record>] 7 | 8 | export const useMultiDrop = (spec: DropTargetHookSpec): useMultiDropState => { 9 | return useMultiCommon, useMultiDropOneState>(spec, useDrop) 10 | } 11 | -------------------------------------------------------------------------------- /__mocks__/react-dnd.ts: -------------------------------------------------------------------------------- 1 | import type {DragLayerMonitor} from 'react-dnd' 2 | 3 | const dnd = jest.requireActual>('react-dnd') 4 | 5 | let mockMonitor: DragLayerMonitor 6 | export const __setMockMonitor = (monitor: DragLayerMonitor): void => { 7 | mockMonitor = monitor 8 | } 9 | 10 | export const useDragLayer = (collect: (monitor: DragLayerMonitor) => CollectedProps): CollectedProps => { 11 | return collect(mockMonitor) 12 | } 13 | 14 | export const DndProvider = dnd.DndProvider 15 | export const useDrag = dnd.useDrag 16 | export const useDrop = dnd.useDrop 17 | export const DndContext = dnd.DndContext 18 | -------------------------------------------------------------------------------- /packages/rdndmb-html5-to-touch/src/index.ts: -------------------------------------------------------------------------------- 1 | import {HTML5Backend} from 'react-dnd-html5-backend' 2 | import {TouchBackend} from 'react-dnd-touch-backend' 3 | 4 | import {type MultiBackendOptions, PointerTransition, TouchTransition} from 'dnd-multi-backend' 5 | 6 | export const HTML5toTouch: MultiBackendOptions = { 7 | backends: [ 8 | { 9 | id: 'html5', 10 | backend: HTML5Backend, 11 | transition: PointerTransition, 12 | }, 13 | { 14 | id: 'touch', 15 | backend: TouchBackend, 16 | options: {enableMouseEvents: true}, 17 | preview: true, 18 | transition: TouchTransition, 19 | }, 20 | ], 21 | } 22 | -------------------------------------------------------------------------------- /examples/dnd-multi-backend.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | dnd-multi-backend Examples 5 | 6 | 7 |

This example demonstrates the basic logic of dnd-multi-backend which can be useful if you are implementing your own layer directly on top of dnd-core (if you use React or Angular you can already use react-dnd-multi-backend or @angular-skyhook/core, respectively)

8 |

Check your browser dev console for more details

9 |
Example is not working
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/dnd-multi-backend/src/__tests__/createTransition.test.ts: -------------------------------------------------------------------------------- 1 | import {createTransition} from '../createTransition.js' 2 | 3 | describe('createTransition function', () => { 4 | test('creates a valid transition', () => { 5 | const eventName = 'test_event' 6 | const func = jest.fn() 7 | 8 | const transition = createTransition(eventName, func) 9 | 10 | expect(transition.event).toBe(eventName) 11 | 12 | const fakeEvent = document.createEvent('Event') 13 | expect(func).not.toHaveBeenCalled() 14 | transition.check(fakeEvent) 15 | expect(func).toHaveBeenCalledTimes(1) 16 | expect(func).toHaveBeenCalledWith(fakeEvent) 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /packages/react-dnd-multi-backend/src/hooks/useMultiDrag.ts: -------------------------------------------------------------------------------- 1 | import {type ConnectDragPreview, type ConnectDragSource, type DragSourceHookSpec, useDrag} from 'react-dnd' 2 | import {useMultiCommon} from './useMultiCommon.js' 3 | 4 | export type useMultiDragOneState = [Props, ConnectDragSource, ConnectDragPreview] 5 | 6 | export type useMultiDragState = [useMultiDragOneState, Record>] 7 | 8 | export const useMultiDrag = (spec: DragSourceHookSpec): useMultiDragState => { 9 | return useMultiCommon, useMultiDragOneState>(spec, useDrag) 10 | } 11 | -------------------------------------------------------------------------------- /packages/react-dnd-preview/examples/main/App.tsx: -------------------------------------------------------------------------------- 1 | import {type JSX, StrictMode} from 'react' 2 | import {DndProvider} from 'react-dnd' 3 | import {TouchBackend} from 'react-dnd-touch-backend' 4 | import {Draggable} from '../shared.js' 5 | import {Components} from './methods/Components.js' 6 | import {Hooks} from './methods/Hooks.js' 7 | 8 | export const App = (): JSX.Element => { 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.8.1/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "linter": { 7 | "enabled": true, 8 | "rules": { 9 | "recommended": true 10 | } 11 | }, 12 | "formatter": { 13 | "enabled": true, 14 | "indentStyle": "space", 15 | "indentWidth": 2, 16 | "lineWidth": 200 17 | }, 18 | "javascript": { 19 | "formatter": { 20 | "trailingCommas": "all", 21 | "semicolons": "asNeeded", 22 | "quoteStyle": "single", 23 | "bracketSpacing": false 24 | } 25 | }, 26 | "files": { 27 | "ignore": ["examples/*.js", "coverage", "packages/*/dist"] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/lint-gha.yml: -------------------------------------------------------------------------------- 1 | name: Lint Github Actions 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: 7 | - ".github/**" 8 | pull_request: 9 | paths: 10 | - ".github/**" 11 | 12 | jobs: 13 | lint: 14 | name: Lint YAML files 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v6 18 | - uses: ibiqlik/action-yamllint@v3 19 | with: 20 | file_or_dir: .github 21 | config_data: | 22 | extends: default 23 | rules: 24 | line-length: 25 | max: 120 26 | level: warning 27 | document-start: disable 28 | truthy: disable 29 | -------------------------------------------------------------------------------- /packages/dnd-multi-backend/src/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import * as Module from '../index.js' 2 | 3 | import {MultiFactory} from '../MultiFactory.js' 4 | import {createTransition} from '../createTransition.js' 5 | import {HTML5DragTransition, MouseTransition, TouchTransition} from '../transitions.js' 6 | 7 | describe('dnd-multi-backend module', () => { 8 | test('exports correctly', () => { 9 | expect(Module.MultiBackend).toBe(MultiFactory) 10 | expect(Module.HTML5DragTransition).toBe(HTML5DragTransition) 11 | expect(Module.TouchTransition).toBe(TouchTransition) 12 | expect(Module.MouseTransition).toBe(MouseTransition) 13 | expect(Module.createTransition).toBe(createTransition) 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | groups: 8 | actions-deps: 9 | patterns: 10 | - "*" 11 | 12 | - package-ecosystem: npm 13 | directory: / 14 | schedule: 15 | interval: monthly 16 | groups: 17 | security-updates: 18 | applies-to: security-updates 19 | patterns: 20 | - "*" 21 | major-updates: 22 | applies-to: version-updates 23 | update-types: 24 | - "major" 25 | minor-updates: 26 | applies-to: version-updates 27 | update-types: 28 | - "minor" 29 | - "patch" 30 | -------------------------------------------------------------------------------- /packages/react-dnd-preview/examples/main/methods/Hooks.tsx: -------------------------------------------------------------------------------- 1 | import {usePreview} from '../../../src/index.js' 2 | import type {DragContent} from '../../shared.js' 3 | import {type GenPreviewLiteProps, type GenPreviewProps, generatePreview} from './common.js' 4 | 5 | import type {JSX} from 'react' 6 | 7 | const WithHook = (props: GenPreviewProps): JSX.Element | null => { 8 | const preview = usePreview() 9 | if (!preview.display) { 10 | return null 11 | } 12 | return generatePreview(preview, props) 13 | } 14 | 15 | export const Hooks = (props: GenPreviewLiteProps): JSX.Element => { 16 | return ( 17 | <> 18 | 19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /examples/react-dnd-multi-backend.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | react-dnd-multi-backend Examples 5 | 6 | 7 |

This example demonstrates the backend switching mechanism between HTML5 and Touch, the useMulti* hooks and the DndProvider replacement.

8 |

You can drag different colored squares in the gray ones, the bottom ones are restricted to a particular backend and can only be used when that backend is in use.

9 |

You can use your browser Dev Tools to switch to the Touch backend (e.g. simulate a mobile device).

10 |
React is not working
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /packages/dnd-multi-backend/src/PreviewListImpl.ts: -------------------------------------------------------------------------------- 1 | import type {MultiBackendSwitcher, PreviewList, PreviewListener} from './types.js' 2 | 3 | export class PreviewListImpl implements PreviewList { 4 | /*private*/ #previews: PreviewListener[] 5 | 6 | constructor() { 7 | this.#previews = [] 8 | } 9 | 10 | register = (preview: PreviewListener): void => { 11 | this.#previews.push(preview) 12 | } 13 | 14 | unregister = (preview: PreviewListener): void => { 15 | while (this.#previews.indexOf(preview) !== -1) { 16 | this.#previews.splice(this.#previews.indexOf(preview), 1) 17 | } 18 | } 19 | 20 | backendChanged = (backend: MultiBackendSwitcher): void => { 21 | for (const preview of this.#previews) { 22 | preview.backendChanged(backend) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/dnd-multi-backend/src/__tests__/MultiFactory.test.ts: -------------------------------------------------------------------------------- 1 | import {TestPipeline} from '@mocks/pipeline.js' 2 | import type {DragDropManager} from 'dnd-core' 3 | import {MultiBackendImpl} from '../MultiBackendImpl.js' 4 | import {MultiFactory} from '../MultiFactory.js' 5 | 6 | describe('MultiFactory function', () => { 7 | test('exports a function to create a MultiBackend', () => { 8 | const fakeManager: DragDropManager = { 9 | getMonitor: jest.fn(), 10 | getActions: jest.fn(), 11 | getRegistry: jest.fn(), 12 | getBackend: jest.fn(), 13 | dispatch: jest.fn(), 14 | } 15 | expect(MultiFactory(fakeManager, {}, TestPipeline)).toBeInstanceOf(MultiBackendImpl) 16 | expect(() => { 17 | MultiFactory(fakeManager, {}) 18 | }).toThrow(Error) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /packages/dnd-multi-backend/src/types.ts: -------------------------------------------------------------------------------- 1 | import type {Backend} from 'dnd-core' 2 | 3 | export type Transition = { 4 | event: string 5 | check: (e: Event) => boolean 6 | } 7 | 8 | export type BackendEntry = { 9 | id: string 10 | instance: Backend 11 | preview: boolean 12 | transition?: Transition 13 | skipDispatchOnTransition: boolean 14 | } 15 | 16 | export interface MultiBackendSwitcher extends Backend { 17 | backendsList(): BackendEntry[] 18 | previewsList(): PreviewList 19 | previewEnabled(): boolean 20 | } 21 | 22 | export interface PreviewListener { 23 | backendChanged(backend: MultiBackendSwitcher): void 24 | } 25 | 26 | export interface PreviewList { 27 | register(listener: PreviewListener): void 28 | unregister(listener: PreviewListener): void 29 | backendChanged(backend: MultiBackendSwitcher): void 30 | } 31 | -------------------------------------------------------------------------------- /packages/dnd-multi-backend/src/transitions.ts: -------------------------------------------------------------------------------- 1 | import {createTransition} from './createTransition.js' 2 | 3 | export const TouchTransition = createTransition('touchstart', (rawEvent: Event) => { 4 | const event = rawEvent as TouchEvent 5 | return event.touches !== null && event.touches !== undefined 6 | }) 7 | 8 | export const HTML5DragTransition = createTransition('dragstart', (event) => { 9 | return event.type.indexOf('drag') !== -1 || event.type.indexOf('drop') !== -1 10 | }) 11 | 12 | export const MouseTransition = createTransition('mousedown', (event) => { 13 | return event.type.indexOf('touch') === -1 && event.type.indexOf('mouse') !== -1 14 | }) 15 | 16 | export const PointerTransition = createTransition('pointerdown', (rawEvent: Event) => { 17 | const event = rawEvent as PointerEvent 18 | return event.pointerType === 'mouse' 19 | }) 20 | -------------------------------------------------------------------------------- /packages/react-dnd-multi-backend/examples/Card.tsx: -------------------------------------------------------------------------------- 1 | import type {CSSProperties, JSX} from 'react' 2 | import {useDrag} from 'react-dnd' 3 | import {useFixRDnDRef} from './common.js' 4 | 5 | export const Card = (props: {color: string}): JSX.Element => { 6 | const [collectedProps, drag] = useDrag({ 7 | type: 'card', 8 | item: {color: props.color}, 9 | collect: (monitor) => { 10 | return { 11 | isDragging: monitor.isDragging(), 12 | } 13 | }, 14 | }) 15 | const isDragging = collectedProps.isDragging 16 | const style: CSSProperties = { 17 | backgroundColor: props.color, 18 | opacity: isDragging ? 0.5 : 1, 19 | display: 'inline-block', 20 | width: '100px', 21 | height: '100px', 22 | margin: '10px', 23 | } 24 | 25 | const dragRef = useFixRDnDRef(drag) 26 | return
27 | } 28 | -------------------------------------------------------------------------------- /__mocks__/react-dnd-preview.ts: -------------------------------------------------------------------------------- 1 | import {MockDragMonitor} from '@mocks/mocks.js' 2 | import type {PreviewProps, PreviewState, usePreviewState} from 'react-dnd-preview' 3 | 4 | import type {JSX} from 'react' 5 | 6 | const preview = jest.createMockFromModule>('react-dnd-preview') 7 | 8 | const state: PreviewState = { 9 | ref: {current: null}, 10 | itemType: 'abc', 11 | item: {}, 12 | style: {}, 13 | monitor: MockDragMonitor(null), 14 | } 15 | 16 | const Preview = (props: PreviewProps): JSX.Element | null => { 17 | if ('children' in props) { 18 | return null 19 | } 20 | return props.generator(state) 21 | } 22 | 23 | export const usePreview = (): usePreviewState => { 24 | return { 25 | display: true, 26 | ...state, 27 | } 28 | } 29 | 30 | module.exports = { 31 | ...preview, 32 | Preview, 33 | usePreview, 34 | } 35 | -------------------------------------------------------------------------------- /esbuild/common.js: -------------------------------------------------------------------------------- 1 | // Note: this is not a technically a esbuild-related script. 2 | import {execSync, spawnSync} from 'node:child_process' 3 | 4 | export const getCommandOutput = (...args) => { 5 | const stdout = execSync(args.join(' '), { 6 | encoding: 'utf8', 7 | }) 8 | return stdout.toString() 9 | } 10 | 11 | export const getWorkspaces = () => { 12 | const workspacesJSON = getCommandOutput('npm', 'query', '.workspace') 13 | const workspaces = JSON.parse(workspacesJSON) 14 | return workspaces.toSorted((a, b) => { 15 | if (Object.keys(a.dependencies ?? {}).includes(b.name)) { 16 | return 1 17 | } 18 | if (Object.keys(b.dependencies ?? {}).includes(a.name)) { 19 | return -1 20 | } 21 | return 0 22 | }) 23 | } 24 | 25 | export const executeCommand = (command, args, options) => { 26 | spawnSync(command, args, { 27 | stdio: 'inherit', 28 | ...options, 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | import {defaults} from 'jest-config' 2 | 3 | export default { 4 | notify: true, 5 | notifyMode: 'failure-success', 6 | 7 | collectCoverage: true, 8 | coverageReporters: process.env.CI ? ['lcov'] : ['text', 'text-summary', 'html'], 9 | collectCoverageFrom: ['packages/*/src/**/*.{js,jsx,ts,tsx}'], 10 | 11 | projects: [ 12 | { 13 | displayName: 'test', 14 | 15 | testEnvironment: 'jsdom', 16 | 17 | setupFilesAfterEnv: ['/.jest-setup.ts'], 18 | 19 | transform: { 20 | '^.+\\.[jt]sx?$': ['esbuild-jest', {sourcemap: true}], 21 | }, 22 | transformIgnorePatterns: ['/node_modules/(?!(dnd-core|@?react-dnd.*)/)'], 23 | moduleNameMapper: { 24 | '^@mocks/(.*)\\.js$': '/__mocks__/$1', 25 | '^(\\.{1,2}/.*)\\.js$': '$1', 26 | }, 27 | 28 | moduleFileExtensions: [...defaults.moduleFileExtensions, 'ts', 'tsx'], 29 | }, 30 | ], 31 | } 32 | -------------------------------------------------------------------------------- /packages/react-dnd-multi-backend/src/components/Preview.tsx: -------------------------------------------------------------------------------- 1 | import {type JSX, useContext} from 'react' 2 | import {Preview as DnDPreview, Context as PreviewContext, type PreviewProps, type PreviewState} from 'react-dnd-preview' 3 | import {createPortal} from 'react-dom' 4 | 5 | import {useObservePreviews} from '../hooks/useObservePreviews.js' 6 | import {PreviewPortalContext} from './DndProvider.js' 7 | 8 | export const Preview = (props: PreviewProps): JSX.Element | null => { 9 | const enabled = useObservePreviews() 10 | const portal = useContext(PreviewPortalContext) 11 | if (!enabled) { 12 | return null 13 | } 14 | 15 | const result = {...props} /> 16 | if (portal !== null) { 17 | return createPortal(result, portal) 18 | } 19 | return result 20 | } 21 | 22 | Preview.Context = PreviewContext 23 | export {PreviewContext} 24 | export type {PreviewState} 25 | -------------------------------------------------------------------------------- /packages/react-dnd-multi-backend/src/hooks/useObservePreviews.ts: -------------------------------------------------------------------------------- 1 | import type {MultiBackendSwitcher} from 'dnd-multi-backend' 2 | import {useContext, useEffect, useState} from 'react' 3 | import {DndContext, type DndContextType} from 'react-dnd' 4 | 5 | export const useObservePreviews = (): boolean => { 6 | const [enabled, setEnabled] = useState(false) 7 | const dndContext = useContext(DndContext) 8 | 9 | useEffect(() => { 10 | const backend = dndContext?.dragDropManager?.getBackend() as MultiBackendSwitcher 11 | 12 | const observer = { 13 | backendChanged: (cbackend: MultiBackendSwitcher) => { 14 | setEnabled(cbackend.previewEnabled()) 15 | }, 16 | } 17 | 18 | setEnabled(backend.previewEnabled()) 19 | 20 | backend.previewsList().register(observer) 21 | return () => { 22 | backend.previewsList().unregister(observer) 23 | } 24 | }, [dndContext, dndContext.dragDropManager]) 25 | 26 | return enabled 27 | } 28 | -------------------------------------------------------------------------------- /packages/dnd-multi-backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dnd-multi-backend", 3 | "version": "9.0.1-rc0001", 4 | "sideEffects": false, 5 | "description": "Multi Backend system compatible with DnD Core / React DnD", 6 | "author": "Louis Brunner (https://github.com/LouisBrunner)", 7 | "license": "MIT", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/LouisBrunner/dnd-multi-backend.git", 11 | "directory": "packages/dnd-multi-backend" 12 | }, 13 | "homepage": "https://louisbrunner.github.io/dnd-multi-backend/packages/dnd-multi-backend/", 14 | "keywords": ["agnostic", "dnd", "drag", "drop", "backend", "multi"], 15 | "funding": { 16 | "type": "individual", 17 | "url": "https://github.com/sponsors/LouisBrunner" 18 | }, 19 | "type": "module", 20 | "main": "dist/index.js", 21 | "types": "dist/index.d.ts", 22 | "module": "dist/index.js", 23 | "peerDependencies": { 24 | "dnd-core": "^16.0.1" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/react-dnd-multi-backend/src/components/DndProvider.tsx: -------------------------------------------------------------------------------- 1 | import {MultiBackend, type MultiBackendOptions} from 'dnd-multi-backend' 2 | import {type JSX, type ReactNode, createContext, useState} from 'react' 3 | import {DndProvider as ReactDndProvider} from 'react-dnd' 4 | 5 | export const PreviewPortalContext = createContext(null) 6 | 7 | export type DndProviderProps = { 8 | // biome-ignore lint/suspicious/noExplicitAny: not sure why 9 | context?: any 10 | options: MultiBackendOptions 11 | children?: ReactNode 12 | debugMode?: boolean 13 | portal?: Element 14 | } 15 | 16 | export const DndProvider = ({portal, ...props}: DndProviderProps): JSX.Element => { 17 | const [previewPortal, setPreviewPortal] = useState(null) 18 | 19 | return ( 20 | 21 | 22 | {portal ? null :
} 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /packages/react-dnd-preview/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-dnd-preview", 3 | "version": "9.0.1-rc0001", 4 | "sideEffects": false, 5 | "description": "Preview component for React DnD", 6 | "author": "Louis Brunner (https://github.com/LouisBrunner)", 7 | "license": "MIT", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/LouisBrunner/dnd-multi-backend.git", 11 | "directory": "packages/react-dnd-preview" 12 | }, 13 | "homepage": "https://louisbrunner.github.io/dnd-multi-backend/packages/react-dnd-preview/", 14 | "keywords": ["react", "dnd", "drag", "drop", "react-dnd", "preview"], 15 | "funding": { 16 | "type": "individual", 17 | "url": "https://github.com/sponsors/LouisBrunner" 18 | }, 19 | "type": "module", 20 | "main": "dist/index.js", 21 | "types": "dist/index.d.ts", 22 | "module": "dist/index.js", 23 | "peerDependencies": { 24 | "react": "^16.14.0 || ^17.0.2 || ^18.0.0 || ^19.0.0", 25 | "react-dnd": "^16.0.1" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "target": "es2020", 5 | "module": "esnext", 6 | "moduleResolution": "bundler", 7 | "lib": ["dom", "dom.iterable", "esnext"], 8 | "jsx": "react-jsx", 9 | "strict": true, 10 | "allowJs": false, 11 | "allowSyntheticDefaultImports": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noImplicitAny": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "preserveConstEnums": true, 20 | "paths": { 21 | "@mocks/*": ["__mocks__/*"], 22 | "dnd-multi-backend": ["packages/dnd-multi-backend/src"], 23 | "rdndmb-html5-to-touch": ["packages/rdndmb-html5-to-touch/src"], 24 | "react-dnd-preview": ["packages/react-dnd-preview/src"] 25 | }, 26 | "skipLibCheck": true 27 | }, 28 | "include": [".jest-setup.ts", "__mocks__/**/*.ts", "packages/*/examples/**/*.ts*", "packages/*/src/**/*.ts*"], 29 | "exclude": ["node_modules"] 30 | } 31 | -------------------------------------------------------------------------------- /packages/react-dnd-preview/examples/main/methods/Components.tsx: -------------------------------------------------------------------------------- 1 | import {Context, Preview, type PreviewState} from '../../../src/index.js' 2 | import type {DragContent} from '../../shared.js' 3 | import {type GenPreviewLiteProps, WithChildComponent, WithChildFunction, WithChildFunctionContext, WithPropFunction} from './common.js' 4 | 5 | import type {JSX} from 'react' 6 | 7 | export const Components = ({title, col}: GenPreviewLiteProps): JSX.Element => { 8 | return ( 9 | <> 10 | 11 | 12 | {WithChildFunction({title, col})} 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | {(props) => { 21 | if (!props) { 22 | throw new Error('missing preview context') 23 | } 24 | return WithChildFunctionContext({title, col})(props as PreviewState) 25 | }} 26 | 27 | 28 | 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016-2022 Louis Brunner 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/rdndmb-html5-to-touch/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rdndmb-html5-to-touch", 3 | "version": "9.0.1-rc0001", 4 | "sideEffects": false, 5 | "description": "Multi Backend pipeline for react-dnd-multi-backend (HTML5 <-> Touch)", 6 | "author": "Louis Brunner (https://github.com/LouisBrunner)", 7 | "license": "MIT", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/LouisBrunner/dnd-multi-backend.git", 11 | "directory": "packages/rdndmb-html5-to-touch" 12 | }, 13 | "homepage": "https://louisbrunner.github.io/dnd-multi-backend/packages/rdndmb-html5-to-touch/", 14 | "keywords": ["react", "dnd", "drag", "drop", "html5", "touch", "react-dnd", "react-dnd-multi-backend"], 15 | "funding": { 16 | "type": "individual", 17 | "url": "https://github.com/sponsors/LouisBrunner" 18 | }, 19 | "type": "module", 20 | "main": "dist/index.js", 21 | "types": "dist/index.d.ts", 22 | "module": "dist/index.js", 23 | "dependencies": { 24 | "dnd-multi-backend": "^9.0.1-rc0001", 25 | "react-dnd-html5-backend": "^16.0.1", 26 | "react-dnd-touch-backend": "^16.0.1" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/react-dnd-multi-backend/src/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import * as Module from '../index.js' 2 | 3 | import {HTML5DragTransition, MouseTransition, MultiBackend, TouchTransition, createTransition} from 'dnd-multi-backend' 4 | import {DndProvider} from '../components/DndProvider.js' 5 | import {Preview, PreviewContext} from '../components/Preview.js' 6 | import {useMultiDrag, useMultiDrop, usePreview} from '../hooks/index.js' 7 | 8 | describe('react-dnd-multi-backend module', () => { 9 | test('exports correctly', () => { 10 | expect(Module.DndProvider).toBe(DndProvider) 11 | 12 | expect(Module.Preview).toBe(Preview) 13 | expect(Module.PreviewContext).toBe(PreviewContext) 14 | 15 | expect(Module.usePreview).toBe(usePreview) 16 | expect(Module.useMultiDrag).toBe(useMultiDrag) 17 | expect(Module.useMultiDrop).toBe(useMultiDrop) 18 | 19 | expect(Module.MultiBackend).toBe(MultiBackend) 20 | expect(Module.HTML5DragTransition).toBe(HTML5DragTransition) 21 | expect(Module.TouchTransition).toBe(TouchTransition) 22 | expect(Module.MouseTransition).toBe(MouseTransition) 23 | expect(Module.createTransition).toBe(createTransition) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /packages/rdndmb-html5-to-touch/src/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import {PointerTransition, TouchTransition} from 'dnd-multi-backend' 2 | import {HTML5toTouch} from '../index.js' 3 | 4 | describe('HTML5toTouch pipeline', () => { 5 | test('has the HTML5 and Touch backends', () => { 6 | expect(HTML5toTouch).toBeInstanceOf(Object) 7 | expect(HTML5toTouch.backends).toBeInstanceOf(Array) 8 | expect(HTML5toTouch.backends).toHaveLength(2) 9 | 10 | expect(HTML5toTouch.backends[0]).toBeInstanceOf(Object) 11 | expect(HTML5toTouch.backends[0].id).toBe('html5') 12 | expect(HTML5toTouch.backends[0].backend).not.toBeUndefined() 13 | expect(HTML5toTouch.backends[0].preview).toBeUndefined() 14 | expect(HTML5toTouch.backends[0].transition).toBe(PointerTransition) 15 | 16 | expect(HTML5toTouch.backends[1]).toBeInstanceOf(Object) 17 | expect(HTML5toTouch.backends[1].id).toBe('touch') 18 | expect(HTML5toTouch.backends[1].backend).not.toBeUndefined() 19 | expect(HTML5toTouch.backends[1].options).toBeInstanceOf(Object) 20 | expect(HTML5toTouch.backends[1].preview).toBe(true) 21 | expect(HTML5toTouch.backends[1].transition).toBe(TouchTransition) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /packages/react-dnd-multi-backend/examples/Basket.tsx: -------------------------------------------------------------------------------- 1 | import type {CSSProperties, JSX, RefObject} from 'react' 2 | import {useDrop} from 'react-dnd' 3 | import {type DragContent, useFixRDnDRef} from './common.js' 4 | 5 | export const Basket = ({logs}: {logs: RefObject}): JSX.Element => { 6 | const [collectedProps, drop] = useDrop({ 7 | accept: 'card', 8 | drop: (item) => { 9 | const message = `Dropped: ${item.color}` 10 | if (logs.current) { 11 | logs.current.innerHTML += `${message}
` 12 | } 13 | }, 14 | collect: (monitor) => { 15 | return { 16 | isOver: monitor.isOver(), 17 | canDrop: monitor.canDrop(), 18 | } 19 | }, 20 | }) 21 | 22 | const isOver = collectedProps.isOver 23 | const canDrop = collectedProps.canDrop 24 | const style: CSSProperties = { 25 | backgroundColor: isOver && canDrop ? '#f3f3f3' : '#cccccc', 26 | border: '1px dashed black', 27 | display: 'inline-block', 28 | width: '100px', 29 | height: '100px', 30 | margin: '10px', 31 | } 32 | 33 | const dropRef = useFixRDnDRef(drop) 34 | return
35 | } 36 | -------------------------------------------------------------------------------- /packages/react-dnd-preview/examples/shared.tsx: -------------------------------------------------------------------------------- 1 | import {type CSSProperties, type JSX, type ReactNode, type Ref, useCallback} from 'react' 2 | import {useDrag} from 'react-dnd' 3 | 4 | export type DragContent = { 5 | type: string 6 | color: string 7 | } 8 | 9 | export type ShapeProps = { 10 | style: CSSProperties 11 | size: number 12 | color: string 13 | children?: ReactNode 14 | ref?: Ref 15 | } 16 | 17 | export const Shape = ({style, size, color, children, ref}: ShapeProps) => { 18 | return ( 19 |
28 | {children} 29 |
30 | ) 31 | } 32 | 33 | export const Draggable = (): JSX.Element => { 34 | const [_, drag] = useDrag({ 35 | type: 'thing', 36 | item: { 37 | color: '#eedd00', 38 | }, 39 | }) 40 | // FIXME: issue with react-dnd when using React v19 41 | const refTrampoline = useCallback( 42 | (node: HTMLDivElement | null) => { 43 | drag(node) 44 | }, 45 | [drag], 46 | ) 47 | return 48 | } 49 | -------------------------------------------------------------------------------- /packages/react-dnd-multi-backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-dnd-multi-backend", 3 | "version": "9.0.1-rc0001", 4 | "sideEffects": false, 5 | "description": "Multi Backend system compatible with React DnD", 6 | "author": "Louis Brunner (https://github.com/LouisBrunner)", 7 | "license": "MIT", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/LouisBrunner/dnd-multi-backend.git", 11 | "directory": "packages/react-dnd-multi-backend" 12 | }, 13 | "homepage": "https://louisbrunner.github.io/dnd-multi-backend/packages/react-dnd-multi-backend/", 14 | "keywords": ["react", "dnd", "drag", "drop", "html5", "touch", "react-dnd"], 15 | "funding": { 16 | "type": "individual", 17 | "url": "https://github.com/sponsors/LouisBrunner" 18 | }, 19 | "type": "module", 20 | "main": "dist/index.js", 21 | "types": "dist/index.d.ts", 22 | "module": "dist/index.js", 23 | "dependencies": { 24 | "dnd-multi-backend": "^9.0.1-rc0001", 25 | "react-dnd-preview": "^9.0.1-rc0001" 26 | }, 27 | "peerDependencies": { 28 | "dnd-core": "^16.0.1", 29 | "react": "^16.14.0 || ^17.0.2 || ^18.0.0 || ^19.0.0", 30 | "react-dnd": "^16.0.1", 31 | "react-dom": "^16.14.0 || ^17.0.2 || ^18.0.0 || ^19.0.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /__mocks__/pipeline.ts: -------------------------------------------------------------------------------- 1 | import type {Backend} from 'dnd-core' 2 | import {type MultiBackendOptions, createTransition} from 'dnd-multi-backend' 3 | 4 | const createBackend = () => { 5 | return { 6 | setup: jest.fn(), 7 | teardown: jest.fn(), 8 | connectDragSource: jest.fn(), 9 | connectDragPreview: jest.fn(), 10 | connectDropTarget: jest.fn(), 11 | profile: jest.fn(), 12 | } 13 | } 14 | 15 | export const TestBackends = [createBackend(), createBackend()] 16 | 17 | export const TestPipeline: MultiBackendOptions = { 18 | backends: [ 19 | { 20 | id: 'back1', 21 | backend: jest.fn((): Backend => { 22 | return TestBackends[0] 23 | }), 24 | }, 25 | { 26 | id: 'back2', 27 | backend: jest.fn((): Backend => { 28 | return TestBackends[1] 29 | }), 30 | options: {abc: 123}, 31 | preview: true, 32 | transition: createTransition( 33 | 'touchstart', 34 | jest.fn((event) => { 35 | return event.type === 'touchstart' 36 | }), 37 | ), 38 | }, 39 | ], 40 | } 41 | 42 | export const TestPipelineWithSkip: MultiBackendOptions = { 43 | backends: [ 44 | TestPipeline.backends[0], 45 | { 46 | ...TestPipeline.backends[1], 47 | skipDispatchOnTransition: true, 48 | }, 49 | ], 50 | } 51 | -------------------------------------------------------------------------------- /packages/react-dnd-preview/src/Preview.tsx: -------------------------------------------------------------------------------- 1 | import type {JSX, ReactNode} from 'react' 2 | import {Context, type PreviewState} from './Context.js' 3 | import type {Point, PreviewPlacement} from './offsets.js' 4 | import {usePreview} from './usePreview.js' 5 | 6 | export type PreviewGenerator = (state: PreviewState) => JSX.Element 7 | 8 | export type PreviewProps = ( 9 | | { 10 | children: PreviewGenerator | ReactNode 11 | } 12 | | { 13 | generator: PreviewGenerator 14 | } 15 | ) & { 16 | placement?: PreviewPlacement 17 | padding?: Point 18 | } 19 | 20 | export const Preview = (props: PreviewProps): JSX.Element | null => { 21 | const result = usePreview({ 22 | placement: props.placement, 23 | padding: props.padding, 24 | }) 25 | 26 | if (!result.display) { 27 | return null 28 | } 29 | const {display: _display, ...data} = result 30 | 31 | let child: ReactNode 32 | if ('children' in props) { 33 | if (typeof props.children === 'function') { 34 | child = props.children(data) 35 | } else { 36 | child = props.children 37 | } 38 | } else { 39 | child = props.generator(data) 40 | } 41 | return {child} 42 | } 43 | -------------------------------------------------------------------------------- /packages/dnd-multi-backend/examples/index.ts: -------------------------------------------------------------------------------- 1 | import {createDragDropManager} from 'dnd-core' 2 | import {MouseTransition, MultiBackend, TouchTransition} from '../src/index.js' 3 | import {HTML5Backend, TouchBackend} from './Backends.js' 4 | import {DragSource, DropTarget} from './DnD.js' 5 | 6 | // Setup pipeline 7 | const pipeline = { 8 | backends: [ 9 | { 10 | id: 'html5', 11 | backend: HTML5Backend, 12 | transition: MouseTransition, 13 | }, 14 | { 15 | id: 'touch', 16 | backend: TouchBackend, 17 | preview: true, 18 | transition: TouchTransition, 19 | }, 20 | ], 21 | } 22 | 23 | // Create manager 24 | const manager = createDragDropManager(MultiBackend, {}, pipeline) 25 | const registry = manager.getRegistry() 26 | 27 | // Create logic 28 | const src = new DragSource({text: 'Source', color: 'red'}) 29 | const dst = new DropTarget<{color: string}>({ 30 | text: 'Target', 31 | color: 'orange', 32 | onDrop: (item) => { 33 | console.log(`Dropped: ${item.color}`) 34 | }, 35 | }) 36 | 37 | const Item = 'item' 38 | 39 | const srcId = registry.addSource(Item, src) 40 | manager.getBackend().connectDragSource(srcId, src.node()) 41 | 42 | const dstId = registry.addTarget(Item, dst) 43 | manager.getBackend().connectDropTarget(dstId, dst.node()) 44 | 45 | // Link to the DOM 46 | document.body.appendChild(src.node()) 47 | document.body.appendChild(dst.node()) 48 | document.getElementById('placeholder')?.remove() 49 | -------------------------------------------------------------------------------- /packages/react-dnd-multi-backend/src/hooks/useMultiCommon.ts: -------------------------------------------------------------------------------- 1 | import type {Backend, DragDropManager} from 'dnd-core' 2 | import type {MultiBackendSwitcher} from 'dnd-multi-backend' 3 | import {useContext} from 'react' 4 | import {DndContext, type DndContextType} from 'react-dnd' 5 | 6 | type Fn = (spec: Spec) => Res 7 | 8 | interface DragDropManagerImpl extends DragDropManager { 9 | receiveBackend(backend: Backend): void 10 | } 11 | 12 | const useForBackend = (spec: Spec, fn: Fn, manager: DragDropManagerImpl, backend: Backend): Res => { 13 | const previous = manager.getBackend() 14 | manager.receiveBackend(backend) 15 | const result = fn(spec) 16 | manager.receiveBackend(previous) 17 | return result 18 | } 19 | 20 | export const useMultiCommon = (spec: Spec, fn: Fn): [Res, Record] => { 21 | const dndContext = useContext(DndContext) 22 | const dndBackend = dndContext?.dragDropManager?.getBackend() 23 | if (dndBackend === undefined) { 24 | throw new Error('could not find backend, make sure you are using a ') 25 | } 26 | 27 | const result = fn(spec) 28 | const multiResult: Record = {} 29 | const backends = (dndBackend as MultiBackendSwitcher).backendsList() 30 | for (const backend of backends) { 31 | multiResult[backend.id] = useForBackend(spec, fn, dndContext.dragDropManager as DragDropManagerImpl, backend.instance) 32 | } 33 | 34 | return [result, multiResult] 35 | } 36 | -------------------------------------------------------------------------------- /packages/react-dnd-multi-backend/src/hooks/__tests__/useMultiDrag.test.tsx: -------------------------------------------------------------------------------- 1 | import {TestPipeline} from '@mocks/pipeline.js' 2 | import {renderHook} from '@testing-library/react' 3 | import type {ReactNode} from 'react' 4 | import {DndProvider} from '../../index.js' 5 | import {useMultiDrag} from '../useMultiDrag.js' 6 | 7 | describe('useMultiDrag component', () => { 8 | const MultiAction = () => { 9 | return useMultiDrag({ 10 | type: 'card', 11 | item: {}, 12 | collect: (monitor) => { 13 | return { 14 | isDragging: monitor.isDragging(), 15 | } 16 | }, 17 | }) 18 | } 19 | 20 | test('fails without a context', () => { 21 | const spy = jest.spyOn(console, 'error') 22 | spy.mockImplementation(() => {}) 23 | expect(() => renderHook(MultiAction)).toThrow() 24 | spy.mockRestore() 25 | }) 26 | 27 | test('it works', () => { 28 | const wrapper = ({children}: {children?: ReactNode}) => { 29 | return {children} 30 | } 31 | const {result} = renderHook(MultiAction, {wrapper}) 32 | 33 | const [props, backends] = result.current 34 | expect(props).toHaveLength(3) 35 | expect(props[0]).toHaveProperty('isDragging', false) 36 | expect(backends).toHaveProperty('back1') 37 | expect(backends).toHaveProperty('back2') 38 | expect(backends).not.toHaveProperty('back3') 39 | expect(backends.back1).toHaveLength(3) 40 | expect(backends.back1[0]).toHaveProperty('isDragging', false) 41 | expect(backends.back2).toHaveLength(3) 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /packages/react-dnd-multi-backend/src/hooks/__tests__/useMultiDrop.test.tsx: -------------------------------------------------------------------------------- 1 | import {TestPipeline} from '@mocks/pipeline.js' 2 | import {renderHook} from '@testing-library/react' 3 | import type {ReactNode} from 'react' 4 | 5 | import {DndProvider} from '../../index.js' 6 | import {useMultiDrop} from '../useMultiDrop.js' 7 | 8 | describe('useMultiDrop component', () => { 9 | const MultiAction = () => { 10 | return useMultiDrop({ 11 | accept: 'card', 12 | collect: (monitor) => { 13 | return { 14 | isOver: monitor.isOver(), 15 | canDrop: monitor.canDrop(), 16 | } 17 | }, 18 | }) 19 | } 20 | 21 | test('fails without a context', () => { 22 | const spy = jest.spyOn(console, 'error') 23 | spy.mockImplementation(() => {}) 24 | expect(() => renderHook(MultiAction)).toThrow() 25 | spy.mockRestore() 26 | }) 27 | 28 | test('it works', () => { 29 | const wrapper = ({children}: {children?: ReactNode}) => { 30 | return {children} 31 | } 32 | const {result} = renderHook(MultiAction, {wrapper}) 33 | 34 | const [props, backends] = result.current 35 | expect(props).toHaveLength(2) 36 | expect(props[0]).toHaveProperty('isOver', false) 37 | expect(props[0]).toHaveProperty('canDrop', false) 38 | expect(backends).toHaveProperty('back1') 39 | expect(backends).toHaveProperty('back2') 40 | expect(backends).not.toHaveProperty('back3') 41 | expect(backends.back1).toHaveLength(2) 42 | expect(backends.back1[0]).toHaveProperty('isOver', false) 43 | expect(backends.back1[0]).toHaveProperty('canDrop', false) 44 | expect(backends.back2).toHaveLength(2) 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DnD Multi Backend [![NPM Version][npm-image]][npm-url] ![Build Status][ci-image] [![Coverage Status][coveralls-image]][coveralls-url] 2 | 3 | This project is a Drag'n'Drop backend compatible with [DnD Core](https://github.com/react-dnd/react-dnd). 4 | 5 | It enables your application to use different DnD backends depending on the situation. Different packages are available depending on your front-end framework: 6 | 7 | - React: [`react-dnd-multi-backend`](packages/react-dnd-multi-backend) 8 | - Angular: [`angular-skyhook`](https://github.com/cormacrelf/angular-skyhook) (see [documentation](https://cormacrelf.github.io/angular-skyhook/angular-skyhook-multi-backend/) for more information) 9 | - Any: [`dnd-multi-backend`](packages/dnd-multi-backend) 10 | 11 | This project also contains some helpers (available standalone or included in other packages): 12 | 13 | - React DnD Preview: [`react-dnd-preview`](packages/react-dnd-preview) (included in `react-dnd-multi-backend`) 14 | 15 | [Try them here!](https://louisbrunner.github.io/dnd-multi-backend/examples) 16 | 17 | 18 | ## Thanks 19 | 20 | Thanks to the [React DnD HTML5 Backend](https://github.com/react-dnd/react-dnd) maintainers which obviously greatly inspired this project. 21 | 22 | 23 | ## License 24 | 25 | MIT, Copyright (c) 2016-2022 Louis Brunner 26 | 27 | 28 | 29 | [npm-image]: https://img.shields.io/npm/v/dnd-multi-backend.svg 30 | [npm-url]: https://npmjs.org/package/dnd-multi-backend 31 | [ci-image]: https://github.com/LouisBrunner/dnd-multi-backend/workflows/Build/badge.svg 32 | [coveralls-image]: https://coveralls.io/repos/github/LouisBrunner/dnd-multi-backend/badge.svg?branch=master 33 | [coveralls-url]: https://coveralls.io/github/LouisBrunner/dnd-multi-backend?branch=master 34 | -------------------------------------------------------------------------------- /packages/react-dnd-multi-backend/examples/MultiCard.tsx: -------------------------------------------------------------------------------- 1 | import type {CSSProperties, JSX} from 'react' 2 | import {useMultiDrag} from '../src/index.js' 3 | import {type DragContent, useFixRDnDRef} from './common.js' 4 | 5 | export const MultiCard = (props: {color: string}): JSX.Element => { 6 | const [ 7 | _, 8 | { 9 | html5: [html5Props, html5Drag], 10 | touch: [touchProps, touchDrag], 11 | }, 12 | ] = useMultiDrag({ 13 | type: 'card', 14 | item: {color: props.color}, 15 | collect: (monitor) => { 16 | return { 17 | isDragging: monitor.isDragging(), 18 | } 19 | }, 20 | }) 21 | 22 | const containerStyle: CSSProperties = { 23 | display: 'inline-block', 24 | margin: '10px', 25 | } 26 | const html5DragStyle: CSSProperties = { 27 | backgroundColor: props.color, 28 | opacity: html5Props.isDragging ? 0.5 : 1, 29 | display: 'inline-block', 30 | margin: '5px', 31 | width: '90px', 32 | height: '90px', 33 | textAlign: 'center', 34 | userSelect: 'none', 35 | } 36 | const touchDragStyle: CSSProperties = { 37 | backgroundColor: props.color, 38 | opacity: touchProps.isDragging ? 0.5 : 1, 39 | display: 'inline-block', 40 | margin: '5px', 41 | width: '90px', 42 | height: '90px', 43 | textAlign: 'center', 44 | userSelect: 'none', 45 | } 46 | const html5DragRef = useFixRDnDRef(html5Drag) 47 | const touchDragRef = useFixRDnDRef(touchDrag) 48 | return ( 49 |
50 |
51 | HTML5 52 |
53 |
54 | Touch 55 |
56 |
57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /__mocks__/mocks.ts: -------------------------------------------------------------------------------- 1 | import type {MultiBackendSwitcher, PreviewList} from 'dnd-multi-backend' 2 | import type {DragLayerMonitor} from 'react-dnd' 3 | 4 | export const MockDragMonitor = (item: DragObject): jest.Mocked> => { 5 | return { 6 | isDragging: jest.fn(() => { 7 | return false 8 | }), 9 | getItemType: jest.fn(() => { 10 | return null 11 | }), 12 | // FIXME: why is it failing to get the type correct? 13 | getItem: jest.fn(() => { 14 | return item 15 | }) as jest.MockInstance & (() => T), 16 | getClientOffset: jest.fn(() => { 17 | return null 18 | }), 19 | getInitialClientOffset: jest.fn(() => { 20 | return null 21 | }), 22 | getInitialSourceClientOffset: jest.fn(() => { 23 | return null 24 | }), 25 | getDifferenceFromInitialOffset: jest.fn(() => { 26 | return null 27 | }), 28 | getSourceClientOffset: jest.fn(() => { 29 | return null 30 | }), 31 | } 32 | } 33 | 34 | type OmitThisParameters = { 35 | [P in keyof T]: OmitThisParameter 36 | } 37 | 38 | export type MockedPreviewList = OmitThisParameters> 39 | 40 | export const MockPreviewList = (): MockedPreviewList => { 41 | return { 42 | register: jest.fn(), 43 | unregister: jest.fn(), 44 | backendChanged: jest.fn(), 45 | } 46 | } 47 | 48 | export type MockedMultiBackend = jest.Mocked 49 | 50 | export const MockMultiBackend = (): MockedMultiBackend => { 51 | return { 52 | backendsList: jest.fn(), 53 | previewsList: jest.fn(), 54 | previewEnabled: jest.fn(), 55 | setup: jest.fn(), 56 | teardown: jest.fn(), 57 | connectDragSource: jest.fn(), 58 | connectDragPreview: jest.fn(), 59 | connectDropTarget: jest.fn(), 60 | profile: jest.fn(), 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /packages/react-dnd-multi-backend/src/components/__tests__/DndProvider.test.tsx: -------------------------------------------------------------------------------- 1 | import {TestPipeline} from '@mocks/pipeline.js' 2 | import {render} from '@testing-library/react' 3 | import {type ReactNode, useContext} from 'react' 4 | 5 | import {DndProvider, PreviewPortalContext} from '../DndProvider.js' 6 | 7 | describe('DndProvider component', () => { 8 | const createComponent = (child: ReactNode, element?: Element) => { 9 | return render( 10 | 11 | {child} 12 | , 13 | ) 14 | } 15 | 16 | test('contexts have sensible defaults', () => { 17 | const Child = () => { 18 | const portal = useContext(PreviewPortalContext) 19 | expect(portal).toBeNull() 20 | return null 21 | } 22 | render() 23 | }) 24 | 25 | test('can access both contexts', () => { 26 | const spy = jest.fn() 27 | const Child = () => { 28 | const portal = useContext(PreviewPortalContext) 29 | spy(portal) 30 | return null 31 | } 32 | createComponent() 33 | expect(spy).toHaveBeenCalledTimes(2) 34 | expect(spy).toHaveBeenNthCalledWith(1, null) 35 | expect(spy).toHaveBeenNthCalledWith(2, expect.any(HTMLElement)) 36 | }) 37 | 38 | test('can pass an external reference', () => { 39 | document.body.innerHTML = ` 40 |
41 | 42 |
{ 52 | spy(useContext(PreviewPortalContext)) 53 | return null 54 | } 55 | createComponent(, portal) 56 | expect(spy).toHaveBeenCalledTimes(1) 57 | expect(spy).toHaveBeenNthCalledWith(1, portal) 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /packages/dnd-multi-backend/src/__tests__/PreviewListImpl.test.ts: -------------------------------------------------------------------------------- 1 | import {TestPipeline} from '@mocks/pipeline.js' 2 | import type {DragDropManager} from 'dnd-core' 3 | import {MultiBackendImpl} from '../MultiBackendImpl.js' 4 | import {PreviewListImpl} from '../PreviewListImpl.js' 5 | import type {MultiBackendSwitcher, PreviewList} from '../types.js' 6 | 7 | describe('PreviewListImpl class', () => { 8 | let list: PreviewList 9 | let mb: MultiBackendSwitcher 10 | 11 | beforeEach(() => { 12 | list = new PreviewListImpl() 13 | mb = new MultiBackendImpl(undefined as unknown as DragDropManager, undefined, TestPipeline) 14 | }) 15 | 16 | const createPreview = () => { 17 | return {backendChanged: jest.fn()} 18 | } 19 | 20 | test('does nothing when empty', () => { 21 | expect(() => { 22 | list.backendChanged(mb) 23 | }).not.toThrow() 24 | }) 25 | 26 | test('notifies registered previews', () => { 27 | const preview1 = createPreview() 28 | const preview2 = createPreview() 29 | list.register(preview1) 30 | list.register(preview2) 31 | list.backendChanged(mb) 32 | expect(preview1.backendChanged).toHaveBeenCalledWith(mb) 33 | expect(preview2.backendChanged).toHaveBeenCalledWith(mb) 34 | list.unregister(preview1) 35 | list.unregister(preview2) 36 | }) 37 | 38 | test('stops notifying after unregistering', () => { 39 | const preview1 = createPreview() 40 | const preview2 = createPreview() 41 | list.register(preview1) 42 | list.register(preview2) 43 | list.backendChanged(mb) 44 | expect(preview1.backendChanged).toHaveBeenCalledTimes(1) 45 | expect(preview2.backendChanged).toHaveBeenCalledTimes(1) 46 | list.unregister(preview2) 47 | list.backendChanged(mb) 48 | expect(preview1.backendChanged).toHaveBeenCalledTimes(2) 49 | expect(preview2.backendChanged).toHaveBeenCalledTimes(1) 50 | list.unregister(preview1) 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /packages/react-dnd-multi-backend/examples/MultiBasket.tsx: -------------------------------------------------------------------------------- 1 | import type {CSSProperties, JSX, RefObject} from 'react' 2 | import {useMultiDrop} from '../src/index.js' 3 | import {type DragContent, useFixRDnDRef} from './common.js' 4 | 5 | export const MultiBasket = ({logs}: {logs: RefObject}): JSX.Element => { 6 | const [ 7 | _, 8 | { 9 | html5: [html5Props, html5Drop], 10 | touch: [touchProps, touchDrop], 11 | }, 12 | ] = useMultiDrop({ 13 | accept: 'card', 14 | drop: (item) => { 15 | const message = `Dropped: ${item.color}` 16 | if (logs.current) { 17 | logs.current.innerHTML += `${message}
` 18 | } 19 | }, 20 | collect: (monitor) => { 21 | return { 22 | isOver: monitor.isOver(), 23 | canDrop: monitor.canDrop(), 24 | } 25 | }, 26 | }) 27 | 28 | // TODO: replace with autoprefixer+astroturf 29 | const containerStyle: CSSProperties = { 30 | border: '1px dashed black', 31 | display: 'inline-block', 32 | margin: '10px', 33 | } 34 | const html5DropStyle: CSSProperties = { 35 | backgroundColor: html5Props.isOver && html5Props.canDrop ? '#f3f3f3' : '#bbbbbb', 36 | display: 'inline-block', 37 | margin: '5px', 38 | width: '90px', 39 | height: '90px', 40 | textAlign: 'center', 41 | userSelect: 'none', 42 | } 43 | const touchDropStyle: CSSProperties = { 44 | backgroundColor: touchProps.isOver && touchProps.canDrop ? '#f3f3f3' : '#bbbbbb', 45 | display: 'inline-block', 46 | margin: '5px', 47 | width: '90px', 48 | height: '90px', 49 | textAlign: 'center', 50 | userSelect: 'none', 51 | } 52 | const html5DropRef = useFixRDnDRef(html5Drop) 53 | const touchDropRef = useFixRDnDRef(touchDrop) 54 | return ( 55 |
56 |
57 | HTML5 58 |
59 |
60 | Touch 61 |
62 |
63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /packages/dnd-multi-backend/examples/DnD.ts: -------------------------------------------------------------------------------- 1 | import type {DragDropMonitor, DragSource as IDragSource, DropTarget as IDropTarget, Identifier} from 'dnd-core' 2 | 3 | type createElementProps = { 4 | text: string 5 | color: string 6 | } 7 | 8 | const createElement = ({text, color}: createElementProps): Element => { 9 | const p = document.createElement('p') 10 | const textNode = document.createTextNode(text) 11 | p.style.cssText = `width: 100px; height: 100px; margin: 10px; padding: 5px; background: ${color}` 12 | p.appendChild(textNode) 13 | return p 14 | } 15 | 16 | export class DragSource implements IDragSource { 17 | #node: Element 18 | #color: string 19 | 20 | constructor({text, color}: createElementProps) { 21 | this.#node = createElement({text, color}) 22 | this.#color = color 23 | } 24 | 25 | node(): Element { 26 | return this.#node 27 | } 28 | 29 | beginDrag(_monitor: DragDropMonitor, _targetId: Identifier): void { 30 | // FIXME: the interface is actually wrong 31 | // biome-ignore lint/correctness/noVoidTypeReturn: interface is wrong 32 | return {color: this.#color} as unknown as undefined 33 | } 34 | 35 | endDrag(_monitor: DragDropMonitor, _targetId: Identifier): void {} 36 | 37 | canDrag(_monitor: DragDropMonitor, _targetId: Identifier): boolean { 38 | return true 39 | } 40 | 41 | isDragging(_monitor: DragDropMonitor, _targetId: Identifier): boolean { 42 | return false 43 | } 44 | } 45 | 46 | export class DropTarget implements IDropTarget { 47 | #node: Element 48 | #onDrop?: (r: T) => void 49 | 50 | constructor({text, color, onDrop}: createElementProps & {onDrop?: (r: T) => void}) { 51 | this.#node = createElement({text, color}) 52 | this.#onDrop = onDrop 53 | } 54 | 55 | node(): Element { 56 | return this.#node 57 | } 58 | 59 | canDrop(_monitor: DragDropMonitor, _targetId: Identifier): boolean { 60 | return true 61 | } 62 | 63 | hover(_monitor: DragDropMonitor, _targetId: Identifier): void {} 64 | 65 | drop(monitor: DragDropMonitor, _targetId: Identifier): void { 66 | this.#onDrop?.(monitor.getItem()) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | name: Build (Node ${{ matrix.node-version }}) 8 | 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | matrix: 13 | node-version: [20, 22] 14 | 15 | steps: 16 | - uses: actions/checkout@v6 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v6 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | cache: "npm" 22 | - name: Install dependencies 23 | run: npm install 24 | - run: npm run vet:ci 25 | - run: npm run test:ci 26 | - name: Coveralls 27 | uses: coverallsapp/github-action@master 28 | with: 29 | github-token: ${{ secrets.GITHUB_TOKEN }} 30 | flag-name: node-${{ matrix.node }} 31 | parallel: true 32 | - run: npm run build:examples 33 | - name: Upload examples build 34 | uses: actions/upload-artifact@v6 35 | if: ${{ matrix.node-version == '22' }} 36 | with: 37 | name: Examples 38 | path: examples 39 | 40 | finish: 41 | name: Coverage 42 | 43 | runs-on: ubuntu-latest 44 | needs: build 45 | 46 | steps: 47 | - name: Coveralls Finished 48 | uses: coverallsapp/github-action@master 49 | with: 50 | github-token: ${{ secrets.GITHUB_TOKEN }} 51 | parallel-finished: true 52 | 53 | deploy: 54 | name: Deploy 55 | 56 | runs-on: ubuntu-latest 57 | needs: build 58 | if: github.ref == 'refs/heads/main' 59 | 60 | steps: 61 | - name: Checkout 62 | uses: actions/checkout@v6 63 | with: 64 | persist-credentials: false 65 | - name: Download examples build 66 | uses: actions/download-artifact@v7 67 | with: 68 | name: Examples 69 | path: examples 70 | - name: Deploy 71 | uses: peaceiris/actions-gh-pages@v4 72 | with: 73 | github_token: ${{ secrets.GITHUB_TOKEN }} 74 | enable_jekyll: true 75 | publish_dir: ./ 76 | force_orphan: true 77 | -------------------------------------------------------------------------------- /packages/react-dnd-preview/src/usePreview.ts: -------------------------------------------------------------------------------- 1 | import type {Identifier} from 'dnd-core' 2 | import {type CSSProperties, type MutableRefObject, useRef} from 'react' 3 | import {type DragLayerMonitor, useDragLayer} from 'react-dnd' 4 | import {type Point, type PreviewPlacement, calculatePointerPosition} from './offsets.js' 5 | 6 | const getStyle = (currentOffset: Point): CSSProperties => { 7 | const transform = `translate(${currentOffset.x.toFixed(1)}px, ${currentOffset.y.toFixed(1)}px)` 8 | return { 9 | pointerEvents: 'none', 10 | position: 'fixed', 11 | top: 0, 12 | left: 0, 13 | transform, 14 | WebkitTransform: transform, 15 | } 16 | } 17 | 18 | export type usePreviewState = {display: false} | usePreviewStateFull 19 | 20 | export type usePreviewStateFull = {display: true} & usePreviewStateContent 21 | 22 | export type usePreviewStateContent = { 23 | ref: MutableRefObject 24 | itemType: Identifier | null 25 | item: T 26 | style: CSSProperties 27 | monitor: DragLayerMonitor 28 | } 29 | 30 | export type usePreviewOptions = { 31 | placement?: PreviewPlacement 32 | padding?: Point 33 | } 34 | 35 | export const usePreview = (options?: usePreviewOptions): usePreviewState => { 36 | const child = useRef(null) 37 | const collectedProps = useDragLayer((monitor: DragLayerMonitor) => { 38 | return { 39 | currentOffset: calculatePointerPosition(monitor, child, options?.placement, options?.padding), 40 | isDragging: monitor.isDragging(), 41 | itemType: monitor.getItemType(), 42 | item: monitor.getItem(), 43 | monitor, 44 | } 45 | }) 46 | 47 | if (!collectedProps.isDragging || collectedProps.currentOffset === null) { 48 | return {display: false} 49 | } 50 | 51 | return { 52 | display: true, 53 | itemType: collectedProps.itemType, 54 | item: collectedProps.item, 55 | style: getStyle(collectedProps.currentOffset), 56 | monitor: collectedProps.monitor, 57 | ref: child, 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /packages/react-dnd-preview/examples/main/methods/common.tsx: -------------------------------------------------------------------------------- 1 | import {type JSX, useContext} from 'react' 2 | import {Context, type PreviewState, type usePreviewStateContent} from '../../../src/index.js' 3 | import {type DragContent, Shape} from '../../shared.js' 4 | 5 | export type PreviewProps = usePreviewStateContent 6 | 7 | export type GenPreviewProps = { 8 | row: number 9 | col: number 10 | title: string 11 | method: string 12 | } 13 | 14 | export const generatePreview = ({itemType, item, style, ref}: PreviewProps, {row, col, title, method}: GenPreviewProps): JSX.Element => { 15 | return ( 16 | 27 | {title} 28 |
29 | Generated {itemType?.toString()} 30 |
31 | {method} 32 |
33 | ) 34 | } 35 | 36 | export type WithPreviewState = (props: PreviewProps) => JSX.Element 37 | 38 | export type GenPreviewLiteProps = Pick 39 | 40 | export const WithPropFunction = ({col, title}: GenPreviewLiteProps): WithPreviewState => { 41 | return (props) => { 42 | return generatePreview(props, {row: 0, col, title, method: 'with prop function'}) 43 | } 44 | } 45 | export const WithChildFunction = ({col, title}: GenPreviewLiteProps): WithPreviewState => { 46 | return (props) => { 47 | return generatePreview(props, {row: 1, col, title, method: 'with child function'}) 48 | } 49 | } 50 | 51 | export const WithChildComponent = ({col, title}: GenPreviewLiteProps): JSX.Element => { 52 | const props = useContext(Context) 53 | if (!props) { 54 | throw new Error('missing preview context') 55 | } 56 | // FIXME: any better way? 57 | return generatePreview(props as PreviewState, {row: 2, col, title, method: 'with child component (using context)'}) 58 | } 59 | 60 | export const WithChildFunctionContext = ({col, title}: GenPreviewLiteProps): WithPreviewState => { 61 | return (props) => { 62 | return generatePreview(props, {row: 3, col, title, method: 'with child function (using context)'}) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "meta", 3 | "type": "module", 4 | "license": "MIT", 5 | "private": true, 6 | "browserslist": ["defaults"], 7 | "engines": { 8 | "npm": ">=7.0.0", 9 | "node": ">=20.0.0" 10 | }, 11 | "funding": { 12 | "type": "individual", 13 | "url": "https://github.com/sponsors/LouisBrunner" 14 | }, 15 | "scripts": { 16 | "clean": "rm -rf packages/*/dist examples/*.js coverage", 17 | "build:types": "node esbuild/run-all.js tsc -p ./tsconfig.json --declarationDir dist", 18 | "build:esm": "node esbuild/run-all.js node ../../esbuild/build.js", 19 | "build:examples": "node esbuild/examples.js", 20 | "build:all": "npm run build && npm run build:examples", 21 | "build": "npm run build:esm && npm run build:types", 22 | "typecheck": "tsc --noEmit", 23 | "format:fix:internal": "npx @biomejs/biome format --write", 24 | "format:fix": "npm run format:fix:internal -- .", 25 | "vet": "npm run typecheck && npx @biomejs/biome check", 26 | "vet:ci": "npm run typecheck && npx @biomejs/biome ci", 27 | "test": "jest --config jest.config.js", 28 | "test:watch": "npm run test -- --watch", 29 | "test:ci": "CI=yes npm run test", 30 | "start": "node esbuild/dev.js", 31 | "start:dev": "NODE_ENV=development npm run start", 32 | "publish": "npm run prepare && node esbuild/publish.js", 33 | "prepare": "npm run clean && npm run build && npm run vet && npm run test" 34 | }, 35 | "devDependencies": { 36 | "@babel/preset-typescript": "^7.28.5", 37 | "@biomejs/biome": "^1.9.4", 38 | "@testing-library/jest-dom": "^6.6.3", 39 | "@testing-library/react": "^16.3.0", 40 | "@types/jest": "^29.5.14", 41 | "@types/jsdom": "^21.1.7", 42 | "@types/react": "^19.2.7", 43 | "@types/react-dom": "^19.2.3", 44 | "esbuild": "^0.25.5", 45 | "esbuild-jest": "^0.5.0", 46 | "esbuild-node-externals": "^1.20.1", 47 | "jest": "^29.7.0", 48 | "jest-environment-jsdom": "^29.7.0", 49 | "node-notifier": "^10.0.1", 50 | "react": "^19.2.0", 51 | "react-dnd": "^16.0.1", 52 | "react-dnd-html5-backend": "^16.0.1", 53 | "react-dnd-test-backend": "^16.0.1", 54 | "react-dnd-test-utils": "^16.0.1", 55 | "react-dnd-touch-backend": "^16.0.1", 56 | "react-dom": "^19.2.0", 57 | "typescript": "^5.9.3" 58 | }, 59 | "workspaces": ["./packages/*"] 60 | } 61 | -------------------------------------------------------------------------------- /packages/rdndmb-html5-to-touch/README.md: -------------------------------------------------------------------------------- 1 | # React DnD: HTML5 to Touch Pipeline [![NPM Version][npm-image]][npm-url] [![dependencies Status][deps-image]][deps-url] [![devDependencies Status][deps-dev-image]][deps-dev-url] 2 | 3 | [Try it here!](https://louisbrunner.github.io/dnd-multi-backend/examples/react-dnd-multi-backend.html) 4 | 5 | This project is a Drag'n'Drop backend pipeline compatible with [React DnD Multi Backend](../react-dnd-multi-backend/). **It cannot be used standalone.** 6 | 7 | This pipeline starts by using the [React DnD HTML5 Backend](https://react-dnd.github.io/react-dnd/docs/backends/html5), but switches to the [React DnD Touch Backend](https://react-dnd.github.io/react-dnd/docs/backends/touch) if a touch event is triggered. 8 | You application can smoothly use the nice HTML5 compatible backend and fallback on the Touch one on mobile devices! 9 | 10 | See the [migration section](#migrating) for instructions when switching from `6.x.x`. 11 | 12 | ## Installation 13 | 14 | ```sh 15 | npm install -S rdndmb-html5-to-touch 16 | ``` 17 | 18 | You can then import the pipeline using `import { HTML5toTouch } from 'rdndmb-html5-to-touch'`. 19 | 20 | ## Usage 21 | 22 | This package should be used with [`react-dnd-multi-backend`](../react-dnd-multi-backend). 23 | 24 | ```js 25 | import { DndProvider } from 'react-dnd-multi-backend' 26 | import { HTML5toTouch } from 'rdndmb-html5-to-touch' 27 | 28 | const App = () => { 29 | return ( 30 | 31 | 32 | 33 | ) 34 | } 35 | ``` 36 | 37 | ## Examples 38 | 39 | You can see an example [here](examples/). 40 | 41 | ## Migrating 42 | 43 | ### Migrating from 6.x.x and earlier 44 | 45 | `HTML5toTouch` used to be provided as part of `react-dnd-multi-backend` which made importing different builds (commonjs vs esm) more difficult. It also used to be a default export. 46 | 47 | Previously: 48 | ```js 49 | import HTML5toTouch from 'react-dnd-multi-backend/dist/esm/HTML5toTouch' 50 | // or 51 | import HTML5toTouch from 'react-dnd-multi-backend/dist/cjs/HTML5toTouch' 52 | ``` 53 | 54 | Now: 55 | ```js 56 | import { HTML5toTouch } from 'rdndmb-html5-to-touch' 57 | ``` 58 | 59 | ## License 60 | 61 | MIT, Copyright (c) 2021-2022 Louis Brunner 62 | 63 | 64 | [npm-image]: https://img.shields.io/npm/v/rdndmb-html5-to-touch.svg 65 | [npm-url]: https://npmjs.org/package/rdndmb-html5-to-touch 66 | [deps-image]: https://david-dm.org/louisbrunner/rdndmb-html5-to-touch/status.svg 67 | [deps-url]: https://david-dm.org/louisbrunner/rdndmb-html5-to-touch 68 | [deps-dev-image]: https://david-dm.org/louisbrunner/rdndmb-html5-to-touch/dev-status.svg 69 | [deps-dev-url]: https://david-dm.org/louisbrunner/rdndmb-html5-to-touch?type=dev 70 | -------------------------------------------------------------------------------- /esbuild/publish.js: -------------------------------------------------------------------------------- 1 | // Note: this is not a technically a esbuild-related script. 2 | 3 | import {readFileSync, writeFileSync} from 'node:fs' 4 | import semver from 'semver' 5 | import {executeCommand, getCommandOutput, getWorkspaces} from './common.js' 6 | 7 | const main = async () => { 8 | if (process.argv.length < 3) { 9 | console.error('Usage: node publish.js semver [tag]') 10 | process.exit(1) 11 | } 12 | const ver = process.argv[2] 13 | if (!semver.valid(ver)) { 14 | console.error(`❌ Invalid semver version: ${ver}`) 15 | process.exit(1) 16 | } 17 | const tag = process.argv.length > 3 ? process.argv[3] : undefined 18 | console.log(`🚀 Publishing version: ${ver}${tag !== undefined ? ` with tag ${tag}` : ''}`) 19 | 20 | console.log('🔍 Checking if all changes are committed') 21 | const changes = getCommandOutput('git', 'status', '--porcelain') 22 | if (changes !== '') { 23 | console.error('❌ Please commit all changes before publishing') 24 | process.exit(1) 25 | } 26 | 27 | console.log('🔍 Checking we are on main') 28 | const branch = getCommandOutput('git', 'branch', '--show-current') 29 | if (branch.trim() !== 'main') { 30 | console.error('❌ Please switch to main branch before publishing') 31 | process.exit(1) 32 | } 33 | 34 | const pkgs = [] 35 | const workspaces = getWorkspaces() 36 | const workspaceNames = workspaces.map((w) => w.name) 37 | for (const workspace of workspaces) { 38 | console.log(`✏️ Updating ${workspace.name} version to: ${ver}`) 39 | const pkgFile = `${workspace.location}/package.json` 40 | const pkg = JSON.parse(readFileSync(pkgFile)) 41 | pkg.version = ver 42 | for (const dep of Object.keys(pkg.dependencies ?? {})) { 43 | if (workspaceNames.includes(dep)) { 44 | pkg.dependencies[dep] = `^${ver}` 45 | } 46 | } 47 | console.log(`📝 Writing ${workspace.name} package.json`) 48 | const content = JSON.stringify(pkg, null, 2) 49 | writeFileSync(pkgFile, content) 50 | pkgs.push(pkgFile) 51 | } 52 | 53 | console.log('✅ Make sure the package.json are formatted correctly') 54 | executeCommand('npm', ['run', 'format:fix:internal', '--', ...pkgs]) 55 | 56 | for (const workspace of workspaces) { 57 | console.log(`🔨 Publishing ${workspace.name}`) 58 | let tagOptions = [] 59 | if (tag !== undefined) { 60 | tagOptions = ['--tag', tag] 61 | } 62 | executeCommand('npm', ['publish', ...tagOptions], { 63 | cwd: workspace.location, 64 | }) 65 | } 66 | 67 | console.log('↩️ Committing changes') 68 | executeCommand('git', ['commit', '-am', `release: v${ver}`]) 69 | executeCommand('git', ['push']) 70 | 71 | console.log('🏷️ Tagging') 72 | executeCommand('git', ['tag', `v${ver}`]) 73 | executeCommand('git', ['push', '--tags']) 74 | executeCommand('gh', ['release', 'create', `v${ver}`, '--generate-notes']) 75 | } 76 | 77 | main() 78 | -------------------------------------------------------------------------------- /packages/react-dnd-preview/src/offsets.ts: -------------------------------------------------------------------------------- 1 | import type {RefObject} from 'react' 2 | import type {DragLayerMonitor} from 'react-dnd' 3 | 4 | // Reminder: 5 | // getInitialClientOffset: clientX/clientY when drag started 6 | // getInitialSourceClientOffset: parent top/left bounding box when drag started 7 | // getClientOffset: current clientX/clientY 8 | // getSourceClientOffset: difference between parent top/left and current clientX/clientY 9 | // = (getClientOffset + getInitialSourceClientOffset) - getInitialClientOffset 10 | // getDifferenceFromInitialOffset: difference between clientX/clientY when drag started and current one 11 | 12 | export type Point = { 13 | x: number 14 | y: number 15 | } 16 | 17 | export type PreviewPlacement = 'center' | 'top' | 'top-start' | 'top-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'left' | 'right' 18 | 19 | const subtract = (a: Point, b: Point): Point => { 20 | return { 21 | x: a.x - b.x, 22 | y: a.y - b.y, 23 | } 24 | } 25 | 26 | const add = (a: Point, b: Point): Point => { 27 | return { 28 | x: a.x + b.x, 29 | y: a.y + b.y, 30 | } 31 | } 32 | 33 | const calculateParentOffset = (monitor: DragLayerMonitor): Point => { 34 | const client = monitor.getInitialClientOffset() 35 | const source = monitor.getInitialSourceClientOffset() 36 | if (client === null || source === null) { 37 | return {x: 0, y: 0} 38 | } 39 | return subtract(client, source) 40 | } 41 | 42 | const calculateXOffset = (placement: PreviewPlacement, bb: DOMRect): number => { 43 | switch (placement) { 44 | case 'left': 45 | case 'top-start': 46 | case 'bottom-start': 47 | return 0 48 | case 'right': 49 | case 'top-end': 50 | case 'bottom-end': 51 | return bb.width 52 | default: 53 | return bb.width / 2 54 | } 55 | } 56 | 57 | const calculateYOffset = (placement: PreviewPlacement, bb: DOMRect): number => { 58 | switch (placement) { 59 | case 'top': 60 | case 'top-start': 61 | case 'top-end': 62 | return 0 63 | case 'bottom': 64 | case 'bottom-start': 65 | case 'bottom-end': 66 | return bb.height 67 | default: 68 | return bb.height / 2 69 | } 70 | } 71 | 72 | export const calculatePointerPosition = (monitor: DragLayerMonitor, childRef: RefObject, placement: PreviewPlacement = 'center', padding: Point = {x: 0, y: 0}): Point | null => { 73 | const offset = monitor.getClientOffset() 74 | if (offset === null) { 75 | return null 76 | } 77 | 78 | // If we don't have a reference to a valid child, use the default offset: 79 | // current cursor - initial parent/drag source offset 80 | if (!childRef.current || !childRef.current.getBoundingClientRect) { 81 | return subtract(offset, calculateParentOffset(monitor)) 82 | } 83 | 84 | const bb = childRef.current.getBoundingClientRect() 85 | const previewOffset = {x: calculateXOffset(placement, bb), y: calculateYOffset(placement, bb)} 86 | 87 | return add(subtract(offset, previewOffset), padding) 88 | } 89 | -------------------------------------------------------------------------------- /packages/react-dnd-multi-backend/src/hooks/__tests__/usePreview.test.tsx: -------------------------------------------------------------------------------- 1 | import {MockMultiBackend, MockPreviewList, type MockedMultiBackend, type MockedPreviewList} from '@mocks/mocks.js' 2 | import {act, renderHook} from '@testing-library/react' 3 | import type {ReactNode} from 'react' 4 | import {DndContext, type DndContextType} from 'react-dnd' 5 | import {usePreview} from '../usePreview.js' 6 | 7 | describe('usePreview component', () => { 8 | let list: MockedPreviewList 9 | let backend: MockedMultiBackend 10 | let context: DndContextType 11 | 12 | beforeEach(() => { 13 | list = MockPreviewList() 14 | backend = MockMultiBackend() 15 | backend.previewsList.mockReturnValue(list) 16 | context = { 17 | dragDropManager: { 18 | getBackend: () => { 19 | return backend 20 | }, 21 | getMonitor: jest.fn(), 22 | getRegistry: jest.fn(), 23 | getActions: jest.fn(), 24 | dispatch: jest.fn(), 25 | }, 26 | } 27 | }) 28 | 29 | const getLastRegister = () => { 30 | return list.register.mock.calls[list.register.mock.calls.length - 1][0] 31 | } 32 | 33 | const createComponent = () => { 34 | const wrapper = ({children}: {children?: ReactNode}) => { 35 | return {children} 36 | } 37 | return renderHook( 38 | () => { 39 | return usePreview() 40 | }, 41 | {wrapper}, 42 | ) 43 | } 44 | 45 | test('registers with the backend', () => { 46 | backend.previewEnabled.mockReturnValue(false) 47 | expect(list.register).not.toHaveBeenCalled() 48 | const {unmount} = createComponent() 49 | expect(list.register).toHaveBeenCalled() 50 | expect(list.unregister).not.toHaveBeenCalled() 51 | unmount() 52 | expect(list.unregister).toHaveBeenCalled() 53 | }) 54 | 55 | describe('it renders correctly', () => { 56 | const testRender = async ({init}: {init: boolean}) => { 57 | backend.previewEnabled.mockReturnValue(init) 58 | 59 | const {result} = createComponent() 60 | 61 | const expectNull = () => { 62 | expect(result.current.display).toBe(false) 63 | } 64 | 65 | const expectNotNull = () => { 66 | expect(result.current.display).toBe(true) 67 | } 68 | 69 | if (init) { 70 | expectNotNull() 71 | } else { 72 | expectNull() 73 | } 74 | 75 | await act(() => { 76 | backend.previewEnabled.mockReturnValue(true) 77 | getLastRegister().backendChanged(backend) 78 | }) 79 | expectNotNull() 80 | 81 | // No notification, no change 82 | await act(() => { 83 | backend.previewEnabled.mockReturnValue(false) 84 | }) 85 | expectNotNull() 86 | 87 | await act(() => { 88 | getLastRegister().backendChanged(backend) 89 | }) 90 | expectNull() 91 | } 92 | 93 | test('not showing at first', async () => { 94 | await testRender({init: false}) 95 | }) 96 | 97 | test('showing at first', async () => { 98 | await testRender({init: true}) 99 | }) 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /packages/react-dnd-multi-backend/examples/App.tsx: -------------------------------------------------------------------------------- 1 | import {type CSSProperties, type JSX, type RefObject, StrictMode, useContext, useRef, useState} from 'react' 2 | import {DndProvider as ReactDndProvider} from 'react-dnd' 3 | 4 | import {HTML5toTouch} from 'rdndmb-html5-to-touch' 5 | import {DndProvider, MultiBackend, Preview, PreviewContext, type PreviewState, usePreview} from '../src/index.js' 6 | 7 | import {Basket} from './Basket.js' 8 | import {Card} from './Card.js' 9 | import {MultiBasket} from './MultiBasket.js' 10 | import {MultiCard} from './MultiCard.js' 11 | import type {DragContent} from './common.js' 12 | 13 | const Block = ({row, text, item, style}: {row: number; text: string; item: DragContent; style: CSSProperties}): JSX.Element => { 14 | return ( 15 |
25 | Generated {text} 26 |
27 | ) 28 | } 29 | 30 | const ContextPreview = ({text}: {text: string}): JSX.Element => { 31 | const preview = useContext(PreviewContext) 32 | if (!preview) { 33 | throw new Error('missing preview context') 34 | } 35 | const {style, item} = preview as PreviewState 36 | return 37 | } 38 | 39 | const HookPreview = ({text}: {text: string}): JSX.Element | null => { 40 | const preview = usePreview() 41 | if (!preview.display) { 42 | return null 43 | } 44 | const {style, item} = preview 45 | return 46 | } 47 | 48 | const ComponentPreview = ({text}: {text: string}): JSX.Element => { 49 | return ( 50 | ): JSX.Element => { 52 | return 53 | }} 54 | /> 55 | ) 56 | } 57 | 58 | const Content = ({title, fref}: {title: string; fref: RefObject}) => { 59 | return ( 60 | <> 61 |

{title} API

62 | 63 | 64 | 65 | 66 | 67 |
68 | 69 | 70 | 71 | 72 |
73 | 74 |
75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | ) 83 | } 84 | 85 | export const App = (): JSX.Element => { 86 | const [useNew, setAPI] = useState(true) 87 | 88 | const refOld = useRef(null) 89 | const refNew = useRef(null) 90 | 91 | const oldAPI = ( 92 | 93 | 94 | 95 | ) 96 | 97 | const newAPI = ( 98 | 99 | 100 | 101 | ) 102 | 103 | return ( 104 | 105 |
106 | { 111 | setAPI(e.target.checked) 112 | }} 113 | /> 114 | 115 |
116 | {useNew ? newAPI : oldAPI} 117 |
118 | ) 119 | } 120 | -------------------------------------------------------------------------------- /packages/dnd-multi-backend/src/__tests__/transitions.test.ts: -------------------------------------------------------------------------------- 1 | // FIXME: jsdom still doesn't support pointer events... 2 | class PointerEventFake extends MouseEvent { 3 | readonly height: number 4 | readonly isPrimary: boolean 5 | readonly pointerId: number 6 | readonly pointerType: string 7 | readonly pressure: number 8 | readonly tangentialPressure: number 9 | readonly tiltX: number 10 | readonly tiltY: number 11 | readonly twist: number 12 | readonly width: number 13 | readonly altitudeAngle: number 14 | readonly azimuthAngle: number 15 | 16 | private coalescedEvents: PointerEvent[] 17 | private predictedEvents: PointerEvent[] 18 | 19 | constructor(type: string, props?: PointerEventInit) { 20 | super(type, props) 21 | 22 | const rprops = props ?? {} 23 | 24 | this.height = rprops.height ?? 1 25 | this.isPrimary = rprops.isPrimary ?? false 26 | this.pointerId = rprops.pointerId ?? 1 27 | this.pointerType = rprops.pointerType ?? 'mouse' 28 | this.pressure = rprops.pressure ?? 0.5 29 | this.tangentialPressure = rprops.tangentialPressure ?? 0 30 | this.tiltX = rprops.tiltX ?? 0 31 | this.tiltY = rprops.tiltY ?? 0 32 | this.twist = rprops.twist ?? 0 33 | this.width = rprops.width ?? 1 34 | this.altitudeAngle = 0 35 | this.azimuthAngle = 0 36 | 37 | this.coalescedEvents = rprops.coalescedEvents ?? [] 38 | this.predictedEvents = rprops.predictedEvents ?? [] 39 | } 40 | 41 | getCoalescedEvents(): PointerEvent[] { 42 | return this.coalescedEvents 43 | } 44 | 45 | getPredictedEvents(): PointerEvent[] { 46 | return this.predictedEvents 47 | } 48 | } 49 | global.PointerEvent = PointerEventFake 50 | 51 | import {HTML5DragTransition, MouseTransition, PointerTransition, TouchTransition} from '../transitions.js' 52 | 53 | describe('Transitions collection', () => { 54 | const fakeDragEvent = (type: string): Event => { 55 | const event = document.createEvent('Event') 56 | event.initEvent(type, true, true) 57 | return event 58 | } 59 | 60 | describe('HTML5DragTransition', () => { 61 | test('calls createTransition correctly', () => { 62 | expect(HTML5DragTransition.event).toBe('dragstart') 63 | 64 | expect(HTML5DragTransition.check(fakeDragEvent('dragenter'))).toBe(true) 65 | expect(HTML5DragTransition.check(fakeDragEvent('drop'))).toBe(true) 66 | expect(HTML5DragTransition.check(new TouchEvent('touchstart'))).toBe(false) 67 | }) 68 | }) 69 | 70 | describe('TouchTransition', () => { 71 | test('calls createTransition correctly', () => { 72 | expect(TouchTransition.event).toBe('touchstart') 73 | 74 | expect(TouchTransition.check(new TouchEvent('touchstart'))).toBe(true) 75 | expect(TouchTransition.check(new MouseEvent('mousemove'))).toBe(false) 76 | }) 77 | }) 78 | 79 | describe('MouseTransition', () => { 80 | test('calls createTransition correctly', () => { 81 | expect(MouseTransition.event).toBe('mousedown') 82 | 83 | expect(MouseTransition.check(new TouchEvent('touchstart'))).toBe(false) 84 | expect(MouseTransition.check(fakeDragEvent('dragenter'))).toBe(false) 85 | expect(MouseTransition.check(new MouseEvent('mousemove'))).toBe(true) 86 | }) 87 | }) 88 | 89 | describe('PointerTransition', () => { 90 | test('calls createTransition correctly', () => { 91 | expect(PointerTransition.event).toBe('pointerdown') 92 | 93 | expect(PointerTransition.check(new TouchEvent('touchstart'))).toBe(false) 94 | expect(PointerTransition.check(fakeDragEvent('dragenter'))).toBe(false) 95 | expect(PointerTransition.check(new MouseEvent('mousemove'))).toBe(false) 96 | expect(PointerTransition.check(new PointerEvent('pointerdown', {pointerType: 'mouse'}))).toBe(true) 97 | }) 98 | }) 99 | }) 100 | -------------------------------------------------------------------------------- /packages/react-dnd-preview/src/__tests__/Preview.test.tsx: -------------------------------------------------------------------------------- 1 | import {render, screen} from '@testing-library/react' 2 | // FIXME: esbuild-jest is struggling in this file because of jest.mock (I think), so we need: 3 | // - a special React import 4 | // - @babel/preset-typescript installed (and set in a tiny babel.config.json) 5 | import {useContext} from 'react' 6 | 7 | import {MockDragMonitor} from '@mocks/mocks.js' 8 | import {Context, type PreviewState} from '../Context.js' 9 | import {Preview, type PreviewProps} from '../Preview.js' 10 | import type {usePreviewState} from '../usePreview.js' 11 | 12 | jest.mock('../usePreview') 13 | 14 | type DragContent = { 15 | coucou: string 16 | } 17 | 18 | type GeneratorProps = PreviewState 19 | 20 | const {__setMockReturn} = require('../usePreview') as { 21 | __setMockReturn: (state: usePreviewState) => void 22 | } 23 | 24 | describe('Preview subcomponent', () => { 25 | const createComponent = (props: PreviewProps) => { 26 | return render() 27 | } 28 | 29 | const generator = ({itemType, item, style}: GeneratorProps) => { 30 | return ( 31 |
32 | {item.coucou}: {itemType?.toString()} 33 |
34 | ) 35 | } 36 | 37 | const setupTest = (props: PreviewProps): void => { 38 | test('is null when DnD is not in progress', () => { 39 | __setMockReturn({display: false}) 40 | createComponent(props) 41 | expect(screen.queryByText('dauphin: toto')).not.toBeInTheDocument() 42 | }) 43 | 44 | test('is valid when DnD is in progress', () => { 45 | __setMockReturn({ 46 | display: true, 47 | style: { 48 | pointerEvents: 'none', 49 | position: 'fixed', 50 | top: 0, 51 | left: 0, 52 | transform: 'translate(1000px, 2000px)', 53 | WebkitTransform: 'translate(1000px, 2000px)', 54 | }, 55 | item: {coucou: 'dauphin'}, 56 | itemType: 'toto', 57 | monitor: MockDragMonitor<{coucou: string}>({coucou: 'dauphin'}), 58 | ref: {current: null}, 59 | }) 60 | createComponent(props) 61 | const node = screen.queryByText('dauphin: toto') 62 | expect(node).toBeInTheDocument() 63 | // FIXME: toHaveStyle ignores pointer-events and WebkitTransform 64 | // expect(node).toHaveStyle({ 65 | // pointerEvents: 'none', 66 | // position: 'fixed', 67 | // top: 0, 68 | // left: 0, 69 | // transform: 'translate(1000px, 2000px)', 70 | // WebkitTransform: 'translate(1000px, 2000px)', 71 | // }) 72 | expect(node).toHaveAttribute('style', ['pointer-events: none', 'position: fixed', 'top: 0px', 'left: 0px', 'transform: translate(1000px, 2000px);'].join('; ')) 73 | }) 74 | } 75 | 76 | describe('using generator prop', () => { 77 | setupTest({generator}) 78 | }) 79 | 80 | describe('using generator child', () => { 81 | setupTest({children: generator}) 82 | }) 83 | 84 | describe('using component child', () => { 85 | const Child = () => { 86 | const props = useContext(Context) 87 | if (props === undefined) { 88 | return null 89 | } 90 | // FIXME: gross 91 | return generator(props as GeneratorProps) 92 | } 93 | 94 | setupTest({ 95 | children: , 96 | }) 97 | }) 98 | 99 | describe('using child context', () => { 100 | setupTest({ 101 | children: ( 102 | 103 | {(props?: PreviewState) => { 104 | if (props === undefined) { 105 | return null 106 | } 107 | return generator(props as GeneratorProps) 108 | }} 109 | 110 | ), 111 | }) 112 | }) 113 | }) 114 | -------------------------------------------------------------------------------- /packages/react-dnd-multi-backend/src/components/__tests__/Preview.test.tsx: -------------------------------------------------------------------------------- 1 | import {MockMultiBackend, MockPreviewList, type MockedMultiBackend, type MockedPreviewList} from '@mocks/mocks.js' 2 | import {act, render, screen} from '@testing-library/react' 3 | import {type JSX, useState} from 'react' 4 | import {DndContext, type DndContextType} from 'react-dnd' 5 | import type {PreviewGenerator} from 'react-dnd-preview' 6 | import {PreviewPortalContext} from '../DndProvider.js' 7 | import {Preview, PreviewContext} from '../Preview.js' 8 | 9 | type TestProps = { 10 | generator: PreviewGenerator 11 | } 12 | 13 | describe('Preview component', () => { 14 | let list: MockedPreviewList 15 | let backend: MockedMultiBackend 16 | let context: DndContextType 17 | 18 | beforeEach(() => { 19 | list = MockPreviewList() 20 | backend = MockMultiBackend() 21 | backend.previewsList.mockReturnValue(list) 22 | context = { 23 | dragDropManager: { 24 | getBackend: () => { 25 | return backend 26 | }, 27 | getMonitor: jest.fn(), 28 | getRegistry: jest.fn(), 29 | getActions: jest.fn(), 30 | dispatch: jest.fn(), 31 | }, 32 | } 33 | }) 34 | 35 | const Simple = () => { 36 | return
abc
37 | } 38 | 39 | const getLastRegister = () => { 40 | return list.register.mock.calls[list.register.mock.calls.length - 1][0] 41 | } 42 | 43 | test('exports a context', () => { 44 | expect(Preview.Context).toBe(PreviewContext) 45 | }) 46 | 47 | describe('using previews context', () => { 48 | const createComponent = ({generator}: TestProps) => { 49 | return render( 50 | 51 | 52 | , 53 | ) 54 | } 55 | 56 | test('registers with the backend', () => { 57 | expect(list.register).not.toHaveBeenCalled() 58 | const {unmount} = createComponent({generator: jest.fn()}) 59 | expect(list.register).toHaveBeenCalled() 60 | expect(list.unregister).not.toHaveBeenCalled() 61 | unmount() 62 | expect(list.unregister).toHaveBeenCalled() 63 | }) 64 | 65 | describe('it renders correctly', () => { 66 | const testRender = async ({init}: {init: boolean}) => { 67 | backend.previewEnabled.mockReturnValue(init) 68 | 69 | createComponent({generator: Simple}) 70 | 71 | const expectNull = () => { 72 | expect(screen.queryByText('abc')).not.toBeInTheDocument() 73 | } 74 | 75 | const expectNotNull = () => { 76 | expect(screen.getByText('abc')).toBeInTheDocument() 77 | } 78 | 79 | if (init) { 80 | expectNotNull() 81 | } else { 82 | expectNull() 83 | } 84 | 85 | await act(() => { 86 | backend.previewEnabled.mockReturnValue(true) 87 | getLastRegister().backendChanged(backend) 88 | }) 89 | expectNotNull() 90 | 91 | // No notification, no change 92 | await act(() => { 93 | backend.previewEnabled.mockReturnValue(false) 94 | }) 95 | expectNotNull() 96 | 97 | await act(() => { 98 | getLastRegister().backendChanged(backend) 99 | }) 100 | expectNull() 101 | } 102 | 103 | test('not showing at first', async () => { 104 | await testRender({init: false}) 105 | }) 106 | 107 | test('showing at first', async () => { 108 | await testRender({init: true}) 109 | }) 110 | }) 111 | }) 112 | 113 | describe('using previews and portal context', () => { 114 | const Component = ({generator}: TestProps): JSX.Element => { 115 | const [ref, setRef] = useState(null) 116 | 117 | return ( 118 | <> 119 | 120 | 121 | 122 | 123 | 124 |
125 | 126 | ) 127 | } 128 | 129 | test('portal is in detached div', async () => { 130 | render() 131 | await act(() => { 132 | backend.previewEnabled.mockReturnValue(true) 133 | getLastRegister().backendChanged(backend) 134 | }) 135 | expect(screen.getByText('abc')).toBeInTheDocument() 136 | }) 137 | }) 138 | }) 139 | -------------------------------------------------------------------------------- /packages/react-dnd-preview/examples/offset/App.tsx: -------------------------------------------------------------------------------- 1 | import {type CSSProperties, type JSX, type Ref, StrictMode, useState} from 'react' 2 | import {DndProvider} from 'react-dnd' 3 | import {TouchBackend} from 'react-dnd-touch-backend' 4 | import {type Point, type PreviewPlacement, usePreview} from '../../src/index.js' 5 | import {type DragContent, Draggable, Shape} from '../shared.js' 6 | 7 | type Kinds = 'default' | 'ref' | 'custom_client' | 'custom_source_client' 8 | 9 | type PreviewProps = { 10 | kind: Kinds 11 | text: string 12 | placement?: PreviewPlacement 13 | padding?: Point 14 | } 15 | 16 | export const Preview = ({kind, text, placement, padding}: PreviewProps): JSX.Element | null => { 17 | const preview = usePreview({placement, padding}) 18 | if (!preview.display) { 19 | return null 20 | } 21 | const {style, ref, monitor} = preview 22 | 23 | let finalRef: Ref | undefined 24 | let finalStyle: CSSProperties = {...style, opacity: 0.5, whiteSpace: 'nowrap'} 25 | if (kind === 'default') { 26 | // Keep as-is 27 | } else if (kind === 'ref') { 28 | finalRef = ref 29 | } else { 30 | let x: number 31 | let y: number 32 | if (kind === 'custom_client') { 33 | x = monitor.getClientOffset()?.x ?? 0 34 | y = monitor.getClientOffset()?.y ?? 0 35 | } else if (kind === 'custom_source_client') { 36 | x = monitor.getSourceClientOffset()?.x ?? 0 37 | y = monitor.getSourceClientOffset()?.y ?? 0 38 | } else { 39 | throw new Error('unknown kind') 40 | } 41 | const transform = `translate(${x}px, ${y}px)` 42 | finalStyle = { 43 | ...finalStyle, 44 | transform, 45 | WebkitTransform: transform, 46 | } 47 | } 48 | 49 | return ( 50 | 51 | {text} 52 | 53 | ) 54 | } 55 | 56 | export const App = (): JSX.Element => { 57 | const [debug, setDebug] = useState(false) 58 | const [previewPlacement, setPreviewPlacement] = useState('center') 59 | 60 | const [paddingX, setPaddingX] = useState('0') 61 | const [paddingY, setPaddingY] = useState('0') 62 | 63 | const handlePlacementChange = (e: React.ChangeEvent) => { 64 | setPreviewPlacement(e.target.value as PreviewPlacement) 65 | } 66 | 67 | const handlePaddingXChange = (e: React.ChangeEvent) => { 68 | setPaddingX(e.target.value) 69 | } 70 | 71 | const handlePaddingYChange = (e: React.ChangeEvent) => { 72 | setPaddingY(e.target.value) 73 | } 74 | 75 | return ( 76 | 77 |

78 | 79 | 90 |

91 |

92 | 93 | 94 |

95 |

96 | 97 | 98 |

99 |

100 | { 104 | setDebug(e.target.checked) 105 | }} 106 | id="debug_mode" 107 | /> 108 | 109 |

110 | 111 | 112 | 113 | 114 | {debug ? ( 115 | <> 116 | 117 | 118 | 119 | ) : null} 120 | 121 |
122 | ) 123 | } 124 | -------------------------------------------------------------------------------- /packages/dnd-multi-backend/README.md: -------------------------------------------------------------------------------- 1 | # DnD Multi Backend [![NPM Version][npm-image]][npm-url] [![dependencies Status][deps-image]][deps-url] [![devDependencies Status][deps-dev-image]][deps-dev-url] 2 | 3 | [Try it here!](https://louisbrunner.github.io/dnd-multi-backend/examples/dnd-multi-backend.html) 4 | 5 | This project is a Drag'n'Drop backend compatible with [DnD Core](https://github.com/react-dnd/react-dnd/tree/master/packages/dnd-core). 6 | It enables your application to use different DnD backends depending on the situation. This package is completely frontend-agnostic, you can refer to [this page](https://github.com/LouisBrunner/dnd-multi-backend) for frontend-specific packages. This means if your front-end is not yet supported, you'll have to roll out your own. 7 | 8 | See the [migration section](#migrating) for instructions when switching from `5.0.x` or `6.x.x`. 9 | 10 | ## Installation 11 | 12 | ```sh 13 | npm install -S dnd-multi-backend 14 | ``` 15 | 16 | ## Usage & Example 17 | 18 | You should only use this package if your framework is not in the supported list: 19 | - [React](../react-dnd-multi-backend) 20 | - [Angular](https://github.com/cormacrelf/angular-skyhook) 21 | 22 | In this case, you will need to write a [custom pipeline](../react-dnd-multi-backend#create-a-custom-pipeline) including as many `dnd-core` backends as you wish. See also the [examples](examples/) for more information. 23 | 24 | ```js 25 | import { createDragDropManager } from 'dnd-core' 26 | import { MultiBackend } from 'dnd-multi-backend' 27 | 28 | // Define the backend and pipeline 29 | class HTML5Backend { 30 | constructor(manager) { 31 | this.manager = manager 32 | } 33 | 34 | setup() {} 35 | teardown() {} 36 | 37 | connectDragSource(sourceId, node, options) { 38 | ... 39 | 40 | return () => {} 41 | } 42 | 43 | connectDragPreview(previewId, node, options) { 44 | ... 45 | 46 | return () => {} 47 | } 48 | 49 | connectDropTarget(targetId, node, options) { 50 | ... 51 | 52 | return () => {} 53 | } 54 | } 55 | 56 | ... 57 | 58 | const pipeline = { 59 | backends: [ 60 | { 61 | id: 'html5', 62 | backend: HTML5Backend, 63 | transition: MouseTransition, 64 | }, 65 | { 66 | id: 'touch', 67 | backend: TouchBackend, 68 | preview: true, 69 | transition: TouchTransition, 70 | }, 71 | ], 72 | } 73 | 74 | // Setup the manager 75 | const manager = createDragDropManager(MultiBackend, {}, pipeline) 76 | const registry = manager.getRegistry() 77 | 78 | // Setup your DnD logic 79 | class Source { 80 | ... 81 | 82 | canDrag() {} 83 | beginDrag() {} 84 | isDragging() {} 85 | endDrag() {} 86 | } 87 | 88 | class Target { 89 | ... 90 | 91 | canDrop() {} 92 | hover() {} 93 | drop() {} 94 | } 95 | 96 | // Define the DnD logic on the manager 97 | const Item = 'item' 98 | const src = new Source() 99 | const dst = new Target() 100 | 101 | const srcId = registry.addSource(Item, src) 102 | const dstId = registry.addTarget(Item, dst) 103 | 104 | // Link the DOM with the logic 105 | const srcP = document.createElement('p') 106 | const srcTxt = document.createTextNode('Source') 107 | srcP.appendChild(srcTxt) 108 | document.body.appendChild(srcP) 109 | manager.getBackend().connectDragSource(srcId, srcP) 110 | 111 | const dstP = document.createElement('p') 112 | const dstTxt = document.createTextNode('Target') 113 | dstP.appendChild(dstTxt) 114 | document.body.appendChild(dstP) 115 | manager.getBackend().connectDropTarget(dstId, dstP) 116 | ``` 117 | 118 | ## Migrating 119 | 120 | ### Migrating from 6.x.x 121 | 122 | Starting with `7.0.0`, `dnd-multi-backend` doesn't have a default export anymore. 123 | 124 | Previously: 125 | ```js 126 | import MultiBackend from 'dnd-multi-backend' 127 | ``` 128 | 129 | Now: 130 | ```js 131 | import { MultiBackend } from 'dnd-multi-backend' 132 | ``` 133 | 134 | ### Migrating from 5.0.x 135 | 136 | Starting with `5.1.0`, every backend in a pipeline will now need a new property called `id` and the library will warn if it isn't specified. The `MultiBackend` will try to guess it if possible, but that might fail and you will need to define them explicitly. 137 | 138 | ## License 139 | 140 | MIT, Copyright (c) 2016-2022 Louis Brunner 141 | 142 | 143 | 144 | [npm-image]: https://img.shields.io/npm/v/dnd-multi-backend.svg 145 | [npm-url]: https://npmjs.org/package/dnd-multi-backend 146 | [deps-image]: https://david-dm.org/louisbrunner/dnd-multi-backend/status.svg 147 | [deps-url]: https://david-dm.org/louisbrunner/dnd-multi-backend 148 | [deps-dev-image]: https://david-dm.org/louisbrunner/dnd-multi-backend/dev-status.svg 149 | [deps-dev-url]: https://david-dm.org/louisbrunner/dnd-multi-backend?type=dev 150 | -------------------------------------------------------------------------------- /packages/dnd-multi-backend/examples/Backends.ts: -------------------------------------------------------------------------------- 1 | import type {Backend, BackendFactory, DragDropActions, DragDropManager, Identifier, Unsubscribe} from 'dnd-core' 2 | 3 | type Options = { 4 | draggable?: boolean 5 | } 6 | 7 | class DnDBackend implements Backend { 8 | #manager: DragDropManager 9 | #actions: DragDropActions 10 | #label: string 11 | #startEvents: string[] 12 | #hoverEvents: string[] 13 | #stopEvents: string[] 14 | #draggable: boolean 15 | 16 | constructor(manager: DragDropManager, label: string, startEvents: string[], hoverEvents: string[], stopEvents: string[], {draggable = false}: Options = {}) { 17 | this.#manager = manager 18 | this.#actions = manager.getActions() 19 | this.#label = label 20 | this.#startEvents = startEvents 21 | this.#hoverEvents = hoverEvents 22 | this.#stopEvents = stopEvents 23 | this.#draggable = draggable 24 | } 25 | 26 | setup(): void { 27 | console.log(`${this.#label}: setup`) 28 | for (const event of this.#stopEvents) { 29 | window.addEventListener(event, this.#drop) 30 | } 31 | } 32 | 33 | teardown(): void { 34 | console.log(`${this.#label}: teardown`) 35 | for (const event of this.#stopEvents) { 36 | window.removeEventListener(event, this.#drop) 37 | } 38 | } 39 | 40 | // biome-ignore lint/suspicious/noExplicitAny: interface is like that 41 | connectDragSource(sourceId: Identifier, nodeRaw?: any, _options?: any): Unsubscribe { 42 | const node = nodeRaw as Element 43 | 44 | const drag = () => { 45 | if (this.#manager.getMonitor().isDragging()) { 46 | console.log(`${this.#label}: end drag`) 47 | this.#actions.endDrag() 48 | } 49 | 50 | console.log(`${this.#label}: drag`) 51 | this.#actions.beginDrag([sourceId], { 52 | clientOffset: this.#getXY(node), 53 | getSourceClientOffset: (_id: unknown): {x: number; y: number} => { 54 | return this.#getXY(node) 55 | }, 56 | }) 57 | } 58 | 59 | console.log(`${this.#label}: add drag source`) 60 | if (this.#draggable) { 61 | node.setAttribute('draggable', 'true') 62 | } 63 | for (const event of this.#startEvents) { 64 | window.addEventListener(event, drag) 65 | } 66 | 67 | return () => { 68 | console.log(`${this.#label}: remove drag source`) 69 | for (const event of this.#startEvents) { 70 | window.removeEventListener(event, drag) 71 | } 72 | if (this.#draggable) { 73 | node.setAttribute('draggable', 'false') 74 | } 75 | } 76 | } 77 | 78 | // biome-ignore lint/suspicious/noExplicitAny: interface is like that 79 | connectDragPreview(_sourceId: any, _node?: any, _options?: any): Unsubscribe { 80 | return () => {} 81 | } 82 | 83 | // biome-ignore lint/suspicious/noExplicitAny: interface is like that 84 | connectDropTarget(targetId: Identifier, node?: Element, _options?: any): Unsubscribe { 85 | if (node === undefined) { 86 | return () => {} 87 | } 88 | 89 | const hover = (e: Event) => { 90 | if (!this.#manager.getMonitor().isDragging()) { 91 | return 92 | } 93 | 94 | console.log(`${this.#label}: hover`) 95 | this.#actions.hover([targetId], { 96 | clientOffset: this.#getXY(node), 97 | }) 98 | if (this.#draggable && this.#manager.getMonitor().canDropOnTarget(targetId)) { 99 | e.preventDefault() 100 | } 101 | } 102 | 103 | console.log(`${this.#label}: add drop target`) 104 | for (const event of this.#hoverEvents) { 105 | window.addEventListener(event, hover) 106 | } 107 | 108 | return () => { 109 | console.log(`${this.#label}: remove drop target`) 110 | for (const event of this.#hoverEvents) { 111 | window.removeEventListener(event, hover) 112 | } 113 | } 114 | } 115 | 116 | profile(): Record { 117 | return {} 118 | } 119 | 120 | #drop = () => { 121 | console.log(`${this.#label}: drop`) 122 | this.#actions.drop() 123 | this.#actions.endDrag() 124 | } 125 | 126 | #getXY = (node: Element): {x: number; y: number} => { 127 | const {top: x, left: y} = node.getBoundingClientRect() 128 | return {x, y} 129 | } 130 | } 131 | 132 | class HTML5BackendImpl extends DnDBackend { 133 | constructor(manager: DragDropManager) { 134 | super(manager, 'HTML5', ['dragstart'], ['dragover', 'dragenter'], ['drop'], {draggable: true}) 135 | } 136 | } 137 | 138 | export const HTML5Backend: BackendFactory = (manager: DragDropManager) => { 139 | return new HTML5BackendImpl(manager) 140 | } 141 | 142 | class TouchBackendImpl extends DnDBackend { 143 | constructor(manager: DragDropManager) { 144 | super(manager, 'Touch', ['touchstart', 'mousedown'], ['touchmove', 'mousemove'], ['touchend', 'mouseup']) 145 | } 146 | } 147 | 148 | export const TouchBackend: BackendFactory = (manager: DragDropManager) => { 149 | return new TouchBackendImpl(manager) 150 | } 151 | -------------------------------------------------------------------------------- /packages/react-dnd-preview/README.md: -------------------------------------------------------------------------------- 1 | # React DnD Preview [![NPM Version][npm-image]][npm-url] [![dependencies Status][deps-image]][deps-url] [![devDependencies Status][deps-dev-image]][deps-dev-url] 2 | 3 | [Try it here!](https://louisbrunner.github.io/dnd-multi-backend/examples/react-dnd-preview.html) 4 | 5 | This project is a React component compatible with [React DnD](https://github.com/react-dnd/react-dnd) that can be used to emulate a Drag'n'Drop "ghost" when a Backend system doesn't have one (e.g. `react-dnd-touch-backend`). 6 | 7 | See the [migration section](#migrating) for instructions when switching from `4.x.x` or `6.x.x`. 8 | 9 | ## Installation 10 | 11 | ```sh 12 | npm install -S react-dnd-preview 13 | ``` 14 | 15 | ## Usage & Example 16 | 17 | Just include the `Preview` component close to the top component of your application (it places itself absolutely). 18 | 19 | It is usable in different ways: hook-based, function-based and context-based. 20 | All of them receive the same data formatted the same way, an object containing the following properties: 21 | 22 | - `display`: only with `usePreview`, boolean indicating if you should render your preview 23 | - `itemType`: the type of the item (`monitor.getItemType()`) 24 | - `item`: the item (`monitor.getItem()`) 25 | - `style`: an object representing the style (used for positioning), it should be passed to the `style` property of your preview component 26 | - `ref`: a reference which can be passed to the final component that will use `style`, it will allow `Preview` to position the previewed component correctly (closer to what HTML5 DnD can do) 27 | - `monitor`: the actual [`DragLayerMonitor`](https://react-dnd.github.io/react-dnd/docs/api/drag-layer-monitor) from `react-dnd` 28 | 29 | The function needs to return something that React can render (React component, `null`, etc). 30 | 31 | See also the [examples](examples/) for more information. 32 | 33 | ### Hook-based 34 | 35 | ```js 36 | import { usePreview } from 'react-dnd-preview' 37 | 38 | const MyPreview = () => { 39 | const preview = usePreview({ placement: 'top', padding: {x: -20, y: 0 }}) 40 | if (!preview.display) { 41 | return null 42 | } 43 | const {itemType, item, style, ref} = preview; 44 | return
{itemType}
45 | } 46 | 47 | const App = () => { 48 | return ( 49 | 50 | 51 | 52 | 53 | ) 54 | } 55 | ``` 56 | 57 | ### Function-based 58 | 59 | ```js 60 | import { Preview } from 'react-dnd-preview' 61 | 62 | const generatePreview = ({itemType, item, style}) => { 63 | return
{itemType}
64 | } 65 | 66 | class App extends React.Component { 67 | render() { 68 | return ( 69 | 70 | 71 | 72 | // or 73 | {generatePreview} 74 | 75 | ) 76 | } 77 | } 78 | ``` 79 | 80 | ### Context-based 81 | 82 | ```js 83 | import { Preview, Context } from 'react-dnd-preview' 84 | 85 | const MyPreview = () => { 86 | const {itemType, item, style} = useContext(Preview.Component) 87 | return
{itemType}
88 | } 89 | 90 | const App = () => { 91 | return ( 92 | 93 | 94 | 95 | 96 | // or 97 | 98 | {({itemType, item, style}) =>
{itemType}
} 99 |
100 |
101 |
102 | ) 103 | } 104 | ``` 105 | 106 | ## Migrating 107 | 108 | ### Migrating from 6.x.x 109 | 110 | Starting with `7.0.0`, `react-dnd-preview` doesn't have a default export anymore. 111 | 112 | Previously: 113 | ```js 114 | import Preview from 'react-dnd-preview' 115 | ``` 116 | 117 | Now: 118 | ```js 119 | import { Preview } from 'react-dnd-preview' 120 | ``` 121 | 122 | ### Migrating from 4.x.x 123 | 124 | Starting with `5.0.0`, `react-dnd-preview` will start passing its arguments packed in one argument, an object `{itemType, item, style}`, instead of 3 different arguments (`itemType`, `item` and `style`). This means that will need to change your generator function to receive arguments correctly. 125 | 126 | ## License 127 | 128 | MIT, Copyright (c) 2016-2022 Louis Brunner 129 | 130 | 131 | 132 | [npm-image]: https://img.shields.io/npm/v/react-dnd-preview.svg 133 | [npm-url]: https://npmjs.org/package/react-dnd-preview 134 | [deps-image]: https://david-dm.org/louisbrunner/react-dnd-preview/status.svg 135 | [deps-url]: https://david-dm.org/louisbrunner/react-dnd-preview 136 | [deps-dev-image]: https://david-dm.org/louisbrunner/react-dnd-preview/dev-status.svg 137 | [deps-dev-url]: https://david-dm.org/louisbrunner/react-dnd-preview?type=dev 138 | -------------------------------------------------------------------------------- /packages/dnd-multi-backend/src/MultiBackendImpl.ts: -------------------------------------------------------------------------------- 1 | import type {BackendFactory, DragDropManager, Unsubscribe} from 'dnd-core' 2 | import {PreviewListImpl} from './PreviewListImpl.js' 3 | import type {BackendEntry, MultiBackendSwitcher, PreviewList, Transition} from './types.js' 4 | 5 | interface EventConstructor { 6 | new (type: string, eventInitDict?: EventInit): Event 7 | } 8 | 9 | type DnDNode = { 10 | func: ConnectFunction 11 | args: [unknown, unknown?, unknown?] 12 | unsubscribe: Unsubscribe 13 | } 14 | 15 | type ConnectFunction = 'connectDragSource' | 'connectDragPreview' | 'connectDropTarget' 16 | 17 | export type MultiBackendContext = unknown 18 | 19 | export type MultiBackendPipelineStep = { 20 | id: string 21 | backend: BackendFactory 22 | transition?: Transition 23 | preview?: boolean 24 | skipDispatchOnTransition?: boolean 25 | options?: unknown 26 | } 27 | 28 | export type MultiBackendPipeline = MultiBackendPipelineStep[] 29 | 30 | export type MultiBackendOptions = { 31 | backends: MultiBackendPipeline 32 | } 33 | 34 | export class MultiBackendImpl implements MultiBackendSwitcher { 35 | private static /*#*/ isSetUp = false 36 | 37 | /*private*/ #current: string 38 | /*private*/ #previews: PreviewList 39 | /*private*/ #backends: Record 40 | /*private*/ #backendsList: BackendEntry[] 41 | /*private*/ #nodes: Record 42 | 43 | constructor(manager: DragDropManager, context?: MultiBackendContext, options?: MultiBackendOptions) { 44 | if (!options || !options.backends || options.backends.length < 1) { 45 | throw new Error( 46 | `You must specify at least one Backend, if you are coming from 2.x.x (or don't understand this error) 47 | see this guide: https://github.com/louisbrunner/dnd-multi-backend/tree/master/packages/react-dnd-multi-backend#migrating-from-2xx`, 48 | ) 49 | } 50 | 51 | this.#previews = new PreviewListImpl() 52 | 53 | this.#backends = {} 54 | this.#backendsList = [] 55 | for (const backend of options.backends) { 56 | const backendRecord = this.#createBackend(manager, context, backend) 57 | this.#backends[backendRecord.id] = backendRecord 58 | this.#backendsList.push(backendRecord) 59 | } 60 | this.#current = this.#backendsList[0].id 61 | 62 | this.#nodes = {} 63 | } 64 | 65 | #createBackend = (manager: DragDropManager, context: MultiBackendContext, backend: MultiBackendPipelineStep): BackendEntry => { 66 | if (!backend.backend) { 67 | throw new Error(`You must specify a 'backend' property in your Backend entry: ${JSON.stringify(backend)}`) 68 | } 69 | 70 | const instance = backend.backend(manager, context, backend.options) 71 | 72 | let id = backend.id 73 | // Try to infer an `id` if one doesn't exist 74 | const inferName = !backend.id && instance && instance.constructor 75 | if (inferName) { 76 | id = instance.constructor.name 77 | } 78 | if (!id) { 79 | throw new Error( 80 | `You must specify an 'id' property in your Backend entry: ${JSON.stringify(backend)} 81 | see this guide: https://github.com/louisbrunner/dnd-multi-backend/tree/master/packages/react-dnd-multi-backend#migrating-from-5xx`, 82 | ) 83 | } 84 | if (inferName) { 85 | console.warn( 86 | `Deprecation notice: You are using a pipeline which doesn't include backends' 'id'. 87 | This might be unsupported in the future, please specify 'id' explicitely for every backend.`, 88 | ) 89 | } 90 | if (this.#backends[id]) { 91 | throw new Error( 92 | `You must specify a unique 'id' property in your Backend entry: 93 | ${JSON.stringify(backend)} (conflicts with: ${JSON.stringify(this.#backends[id])})`, 94 | ) 95 | } 96 | 97 | return { 98 | id, 99 | instance, 100 | preview: backend.preview ?? false, 101 | transition: backend.transition, 102 | skipDispatchOnTransition: backend.skipDispatchOnTransition ?? false, 103 | } 104 | } 105 | 106 | // DnD Backend API 107 | setup = (): void => { 108 | if (typeof window === 'undefined') { 109 | return 110 | } 111 | 112 | if (MultiBackendImpl.isSetUp) { 113 | throw new Error('Cannot have two MultiBackends at the same time.') 114 | } 115 | MultiBackendImpl.isSetUp = true 116 | this.#addEventListeners(window) 117 | this.#backends[this.#current].instance.setup() 118 | } 119 | 120 | teardown = (): void => { 121 | if (typeof window === 'undefined') { 122 | return 123 | } 124 | 125 | MultiBackendImpl.isSetUp = false 126 | this.#removeEventListeners(window) 127 | this.#backends[this.#current].instance.teardown() 128 | } 129 | 130 | connectDragSource = (sourceId: unknown, node?: unknown, options?: unknown): Unsubscribe => { 131 | return this.#connectBackend('connectDragSource', sourceId, node, options) 132 | } 133 | 134 | connectDragPreview = (sourceId: unknown, node?: unknown, options?: unknown): Unsubscribe => { 135 | return this.#connectBackend('connectDragPreview', sourceId, node, options) 136 | } 137 | 138 | connectDropTarget = (sourceId: unknown, node?: unknown, options?: unknown): Unsubscribe => { 139 | return this.#connectBackend('connectDropTarget', sourceId, node, options) 140 | } 141 | 142 | profile = (): Record => { 143 | return this.#backends[this.#current].instance.profile() 144 | } 145 | 146 | // Used by Preview component 147 | previewEnabled = (): boolean => { 148 | return this.#backends[this.#current].preview 149 | } 150 | 151 | previewsList = (): PreviewList => { 152 | return this.#previews 153 | } 154 | 155 | backendsList = (): BackendEntry[] => { 156 | return this.#backendsList 157 | } 158 | 159 | // Multi Backend Listeners 160 | #addEventListeners = (target: EventTarget): void => { 161 | for (const backend of this.#backendsList) { 162 | if (backend.transition) { 163 | target.addEventListener(backend.transition.event, this.#backendSwitcher) 164 | } 165 | } 166 | } 167 | 168 | #removeEventListeners = (target: EventTarget): void => { 169 | for (const backend of this.#backendsList) { 170 | if (backend.transition) { 171 | target.removeEventListener(backend.transition.event, this.#backendSwitcher) 172 | } 173 | } 174 | } 175 | 176 | // Switching logic 177 | #backendSwitcher = (event: Event): void => { 178 | const oldBackend = this.#current 179 | 180 | this.#backendsList.some((backend) => { 181 | if (backend.id !== this.#current && backend.transition && backend.transition.check(event)) { 182 | this.#current = backend.id 183 | return true 184 | } 185 | return false 186 | }) 187 | 188 | if (this.#current !== oldBackend) { 189 | this.#backends[oldBackend].instance.teardown() 190 | for (const [_, node] of Object.entries(this.#nodes)) { 191 | node.unsubscribe() 192 | node.unsubscribe = this.#callBackend(node.func, ...node.args) 193 | } 194 | this.#previews.backendChanged(this) 195 | 196 | const newBackend = this.#backends[this.#current] 197 | newBackend.instance.setup() 198 | 199 | if (newBackend.skipDispatchOnTransition) { 200 | return 201 | } 202 | 203 | const Class = event.constructor as EventConstructor 204 | const newEvent = new Class(event.type, event) 205 | event.target?.dispatchEvent(newEvent) 206 | } 207 | } 208 | 209 | #callBackend = (func: ConnectFunction, sourceId: unknown, node?: unknown, options?: unknown): Unsubscribe => { 210 | return this.#backends[this.#current].instance[func](sourceId, node, options) 211 | } 212 | 213 | #connectBackend = (func: ConnectFunction, sourceId: unknown, node?: unknown, options?: unknown): Unsubscribe => { 214 | const nodeId = `${func}_${sourceId as number}` 215 | const unsubscribe = this.#callBackend(func, sourceId, node, options) 216 | this.#nodes[nodeId] = { 217 | func, 218 | args: [sourceId, node, options], 219 | unsubscribe, 220 | } 221 | 222 | return (): void => { 223 | this.#nodes[nodeId].unsubscribe() 224 | delete this.#nodes[nodeId] 225 | } 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /packages/react-dnd-preview/src/__tests__/usePreview.test.ts: -------------------------------------------------------------------------------- 1 | import {MockDragMonitor} from '@mocks/mocks.js' 2 | import {__setMockMonitor} from '@mocks/react-dnd.js' 3 | import {act, renderHook} from '@testing-library/react' 4 | import type {MutableRefObject} from 'react' 5 | import type {PreviewPlacement} from '../offsets.js' 6 | import {usePreview, type usePreviewStateFull} from '../usePreview.js' 7 | 8 | const DraggingMonitor = { 9 | isDragging() { 10 | return true 11 | }, 12 | getItemType() { 13 | return 'no' 14 | }, 15 | getClientOffset() { 16 | return {x: 1, y: 2} 17 | }, 18 | } 19 | 20 | describe('usePreview hook', () => { 21 | beforeEach(() => { 22 | __setMockMonitor(MockDragMonitor(null)) 23 | }) 24 | 25 | test('return false when DnD is not in progress (neither dragging or offset)', () => { 26 | __setMockMonitor(MockDragMonitor(null)) 27 | const { 28 | result: { 29 | current: {display}, 30 | }, 31 | } = renderHook(() => { 32 | return usePreview() 33 | }) 34 | expect(display).toBe(false) 35 | }) 36 | 37 | test('return false when DnD is not in progress (no dragging)', () => { 38 | __setMockMonitor({ 39 | ...MockDragMonitor(null), 40 | getClientOffset() { 41 | return {x: 1, y: 2} 42 | }, 43 | }) 44 | const { 45 | result: { 46 | current: {display}, 47 | }, 48 | } = renderHook(() => { 49 | return usePreview() 50 | }) 51 | expect(display).toBe(false) 52 | }) 53 | 54 | test('return false when DnD is not in progress (no offset)', () => { 55 | __setMockMonitor({ 56 | ...MockDragMonitor(null), 57 | isDragging() { 58 | return true 59 | }, 60 | }) 61 | const { 62 | result: { 63 | current: {display}, 64 | }, 65 | } = renderHook(() => { 66 | return usePreview() 67 | }) 68 | expect(display).toBe(false) 69 | }) 70 | 71 | test('return true and data when DnD is in progress', () => { 72 | __setMockMonitor({ 73 | ...MockDragMonitor<{bluh: string}>({bluh: 'fake'}), 74 | ...DraggingMonitor, 75 | }) 76 | const {result} = renderHook(() => { 77 | return usePreview() as usePreviewStateFull 78 | }) 79 | const { 80 | current: {display, monitor: _monitor, ref, ...rest}, 81 | } = result 82 | expect(display).toBe(true) 83 | expect(ref).not.toBeNull() 84 | expect(rest).toEqual({ 85 | item: {bluh: 'fake'}, 86 | itemType: 'no', 87 | style: { 88 | pointerEvents: 'none', 89 | position: 'fixed', 90 | left: 0, 91 | top: 0, 92 | WebkitTransform: 'translate(1.0px, 2.0px)', 93 | transform: 'translate(1.0px, 2.0px)', 94 | }, 95 | }) 96 | }) 97 | 98 | test('return true and data when DnD is in progress (with parent offset)', () => { 99 | __setMockMonitor({ 100 | ...MockDragMonitor<{bluh: string}>({bluh: 'fake'}), 101 | ...DraggingMonitor, 102 | getInitialClientOffset() { 103 | return {x: 1, y: 2} 104 | }, 105 | getInitialSourceClientOffset() { 106 | return {x: 0, y: 1} 107 | }, 108 | }) 109 | const {result} = renderHook(() => { 110 | return usePreview() as usePreviewStateFull 111 | }) 112 | const { 113 | current: {display, monitor: _monitor, ref, ...rest}, 114 | } = result 115 | expect(display).toBe(true) 116 | expect(ref).not.toBeNull() 117 | expect(rest).toEqual({ 118 | item: {bluh: 'fake'}, 119 | itemType: 'no', 120 | style: { 121 | pointerEvents: 'none', 122 | position: 'fixed', 123 | left: 0, 124 | top: 0, 125 | WebkitTransform: 'translate(0.0px, 1.0px)', 126 | transform: 'translate(0.0px, 1.0px)', 127 | }, 128 | }) 129 | }) 130 | 131 | test('return true and data when DnD is in progress (with ref)', async () => { 132 | __setMockMonitor({ 133 | ...MockDragMonitor<{bluh: string}>({bluh: 'fake'}), 134 | ...DraggingMonitor, 135 | getInitialClientOffset() { 136 | return {x: 1, y: 2} 137 | }, 138 | }) 139 | const {result, rerender} = renderHook(() => { 140 | return usePreview() as usePreviewStateFull 141 | }) 142 | const { 143 | current: {display, monitor: _monitor, ref, ...rest}, 144 | } = result 145 | expect(display).toBe(true) 146 | expect(ref).not.toBeNull() 147 | expect(rest).toEqual({ 148 | item: {bluh: 'fake'}, 149 | itemType: 'no', 150 | style: { 151 | pointerEvents: 'none', 152 | position: 'fixed', 153 | left: 0, 154 | top: 0, 155 | WebkitTransform: 'translate(1.0px, 2.0px)', 156 | transform: 'translate(1.0px, 2.0px)', 157 | }, 158 | }) 159 | await act(() => { 160 | // FIXME: not great... 161 | ;(ref as MutableRefObject).current = { 162 | ...document.createElement('div'), 163 | getBoundingClientRect() { 164 | return { 165 | width: 100, 166 | height: 70, 167 | x: 0, 168 | y: 0, 169 | bottom: 0, 170 | left: 0, 171 | right: 0, 172 | top: 0, 173 | toJSON() {}, 174 | } 175 | }, 176 | } 177 | }) 178 | rerender() 179 | const { 180 | current: {display: _display, monitor: _monitor2, ref: _ref, ...rest2}, 181 | } = result 182 | expect(rest2).toEqual({ 183 | item: {bluh: 'fake'}, 184 | itemType: 'no', 185 | style: { 186 | pointerEvents: 'none', 187 | position: 'fixed', 188 | left: 0, 189 | top: 0, 190 | WebkitTransform: 'translate(-49.0px, -33.0px)', 191 | transform: 'translate(-49.0px, -33.0px)', 192 | }, 193 | }) 194 | }) 195 | 196 | const cases: {placement?: PreviewPlacement; expectedTransform: string}[] = [ 197 | { 198 | expectedTransform: 'translate(-49.0px, -33.0px)', 199 | }, 200 | { 201 | placement: 'center', 202 | expectedTransform: 'translate(-49.0px, -33.0px)', 203 | }, 204 | { 205 | placement: 'top', 206 | expectedTransform: 'translate(-49.0px, 2.0px)', 207 | }, 208 | { 209 | placement: 'top-start', 210 | expectedTransform: 'translate(1.0px, 2.0px)', 211 | }, 212 | { 213 | placement: 'top-end', 214 | expectedTransform: 'translate(-99.0px, 2.0px)', 215 | }, 216 | { 217 | placement: 'bottom', 218 | expectedTransform: 'translate(-49.0px, -68.0px)', 219 | }, 220 | { 221 | placement: 'bottom-start', 222 | expectedTransform: 'translate(1.0px, -68.0px)', 223 | }, 224 | { 225 | placement: 'bottom-end', 226 | expectedTransform: 'translate(-99.0px, -68.0px)', 227 | }, 228 | { 229 | placement: 'left', 230 | expectedTransform: 'translate(1.0px, -33.0px)', 231 | }, 232 | { 233 | placement: 'right', 234 | expectedTransform: 'translate(-99.0px, -33.0px)', 235 | }, 236 | ] 237 | 238 | test.each(cases)('return true and data when DnD is in progress (with ref, parent offset and placement $placement)', async ({placement, expectedTransform}) => { 239 | __setMockMonitor({ 240 | ...MockDragMonitor<{bluh: string}>({bluh: 'fake'}), 241 | ...DraggingMonitor, 242 | getInitialClientOffset() { 243 | return {x: 1, y: 2} 244 | }, 245 | getInitialSourceClientOffset() { 246 | return {x: 0, y: 1} 247 | }, 248 | }) 249 | const {result, rerender} = renderHook(() => { 250 | return usePreview({placement}) as usePreviewStateFull 251 | }) 252 | const { 253 | current: {display, monitor: _monitor, ref, ...rest}, 254 | } = result 255 | expect(display).toBe(true) 256 | expect(ref).not.toBeNull() 257 | expect(rest).toEqual({ 258 | item: {bluh: 'fake'}, 259 | itemType: 'no', 260 | style: { 261 | pointerEvents: 'none', 262 | position: 'fixed', 263 | left: 0, 264 | top: 0, 265 | WebkitTransform: 'translate(0.0px, 1.0px)', 266 | transform: 'translate(0.0px, 1.0px)', 267 | }, 268 | }) 269 | await act(() => { 270 | // FIXME: not great... 271 | ;(ref as MutableRefObject).current = { 272 | ...document.createElement('div'), 273 | getBoundingClientRect() { 274 | return { 275 | width: 100, 276 | height: 70, 277 | x: 0, 278 | y: 0, 279 | bottom: 0, 280 | left: 0, 281 | right: 0, 282 | top: 0, 283 | toJSON() {}, 284 | } 285 | }, 286 | } 287 | }) 288 | rerender({placement}) 289 | const { 290 | current: {display: _display, monitor: _monitor2, ref: _ref, ...rest2}, 291 | } = result 292 | expect(rest2).toEqual({ 293 | item: {bluh: 'fake'}, 294 | itemType: 'no', 295 | style: { 296 | pointerEvents: 'none', 297 | position: 'fixed', 298 | left: 0, 299 | top: 0, 300 | WebkitTransform: expectedTransform, 301 | transform: expectedTransform, 302 | }, 303 | }) 304 | }) 305 | }) 306 | -------------------------------------------------------------------------------- /packages/dnd-multi-backend/src/__tests__/MultiBackendImpl.test.ts: -------------------------------------------------------------------------------- 1 | import {TestBackends, TestPipeline, TestPipelineWithSkip} from '@mocks/pipeline.js' 2 | import type {DragDropManager} from 'dnd-core' 3 | import {type MultiBackendContext, MultiBackendImpl, type MultiBackendOptions} from '../MultiBackendImpl.js' 4 | 5 | describe('MultiBackendImpl class', () => { 6 | let _defaultManager: DragDropManager 7 | let _defaultContext: MultiBackendContext 8 | 9 | beforeEach(() => { 10 | jest.clearAllMocks() 11 | _defaultManager = { 12 | getMonitor: jest.fn(), 13 | getActions: jest.fn(), 14 | getRegistry: jest.fn(), 15 | getBackend: jest.fn(), 16 | dispatch: jest.fn(), 17 | } 18 | _defaultContext = {qrt: Math.random()} 19 | }) 20 | 21 | const createBackend = (pipeline: MultiBackendOptions = TestPipeline) => { 22 | return new MultiBackendImpl(_defaultManager, _defaultContext, pipeline) 23 | } 24 | 25 | const switchTouchBackend = (): void => { 26 | expect(TestBackends[0].teardown).not.toHaveBeenCalled() 27 | expect(TestBackends[1].setup).not.toHaveBeenCalled() 28 | const event = new TouchEvent('touchstart', {bubbles: true, touches: []}) 29 | window.dispatchEvent(event) 30 | expect(TestBackends[0].teardown).toHaveBeenCalled() 31 | expect(TestBackends[1].setup).toHaveBeenCalled() 32 | } 33 | 34 | describe('constructor', () => { 35 | let warn: jest.SpyInstance 36 | 37 | beforeEach(() => { 38 | warn = jest.spyOn(console, 'warn').mockImplementation() 39 | }) 40 | 41 | afterEach(() => { 42 | warn.mockRestore() 43 | }) 44 | 45 | test('fails if no backend are specified', () => { 46 | const pipeline = {backends: []} 47 | expect(() => { 48 | createBackend(pipeline as unknown as MultiBackendOptions) 49 | }).toThrow(Error) 50 | }) 51 | 52 | test('fails if no backend are specified (prototype trick)', () => { 53 | const pipeline = Object.create({backends: []}) as Record 54 | expect(() => { 55 | createBackend(pipeline as unknown as MultiBackendOptions) 56 | }).toThrow(Error) 57 | }) 58 | 59 | test('fails if a backend lacks the `backend` property', () => { 60 | const pipeline = {backends: [{}]} 61 | expect(() => { 62 | createBackend(pipeline as unknown as MultiBackendOptions) 63 | }).toThrow(Error) 64 | }) 65 | 66 | test('fails if a backend specifies an invalid `transition` property', () => { 67 | const pipeline = {backends: [{backend: () => {}, transition: {}}]} 68 | expect(() => { 69 | createBackend(pipeline as unknown as MultiBackendOptions) 70 | }).toThrow(Error) 71 | }) 72 | 73 | test('fails if a backend lacks an `id` property and one cannot be infered', () => { 74 | const pipeline = {backends: [{backend: () => {}}]} 75 | expect(() => { 76 | createBackend(pipeline as unknown as MultiBackendOptions) 77 | }).toThrow(Error) 78 | }) 79 | 80 | test('fails if a backend has a duplicate `id` property', () => { 81 | const pipeline = { 82 | backends: [ 83 | {id: 'abc', backend: () => {}}, 84 | {id: 'abc', backend: () => {}}, 85 | ], 86 | } 87 | expect(() => { 88 | createBackend(pipeline as unknown as MultiBackendOptions) 89 | }).toThrow(Error) 90 | }) 91 | 92 | test('warns if a backend lacks an `id` property but one can be infered', () => { 93 | const pipeline = { 94 | backends: [ 95 | { 96 | backend: () => { 97 | return {} 98 | }, 99 | }, 100 | ], 101 | } 102 | expect(() => { 103 | createBackend(pipeline as unknown as MultiBackendOptions) 104 | }).not.toThrow() 105 | expect(warn).toHaveBeenCalled() 106 | }) 107 | 108 | test('constructs correctly', () => { 109 | const pipeline = TestPipeline 110 | createBackend(pipeline) 111 | 112 | expect(pipeline.backends[0].backend).toHaveBeenCalledTimes(1) 113 | expect(pipeline.backends[0].backend).toHaveBeenCalledWith(_defaultManager, _defaultContext, undefined) 114 | 115 | expect(pipeline.backends[1].backend).toHaveBeenCalledTimes(1) 116 | expect(pipeline.backends[1].backend).toHaveBeenCalledWith(_defaultManager, _defaultContext, pipeline.backends[1].options) 117 | }) 118 | }) 119 | 120 | describe('setup function', () => { 121 | test('does nothing if it has no window', () => { 122 | const windowSpy = jest.spyOn(window, 'window', 'get') 123 | const spyAdd = jest.spyOn(window, 'addEventListener') 124 | // @ts-expect-error 125 | windowSpy.mockImplementation(() => undefined) 126 | 127 | const backend = createBackend() 128 | backend.setup() 129 | expect(() => { 130 | backend.setup() 131 | }).not.toThrow() 132 | expect(spyAdd).not.toHaveBeenCalled() 133 | expect(TestBackends[0].setup).not.toHaveBeenCalled() 134 | 135 | backend.teardown() 136 | 137 | spyAdd.mockRestore() 138 | windowSpy.mockRestore() 139 | }) 140 | 141 | test('fails if a backend already exist', () => { 142 | const spyAdd = jest.spyOn(window, 'addEventListener') 143 | 144 | const backend = createBackend() 145 | expect(TestBackends[0].setup).not.toHaveBeenCalled() 146 | backend.setup() 147 | expect(TestBackends[0].setup).toHaveBeenCalled() 148 | expect(spyAdd).toHaveBeenCalledWith('touchstart', expect.any(Function)) 149 | 150 | TestBackends[0].setup.mockClear() 151 | spyAdd.mockClear() 152 | 153 | const backend2 = createBackend() 154 | expect(TestBackends[0].setup).not.toHaveBeenCalled() 155 | expect(() => { 156 | backend2.setup() 157 | }).toThrow(Error) 158 | expect(TestBackends[0].setup).not.toHaveBeenCalled() 159 | expect(spyAdd).not.toHaveBeenCalled() 160 | 161 | backend.teardown() 162 | spyAdd.mockRestore() 163 | }) 164 | 165 | test('sets up the events and sub-backends', () => { 166 | const spyAdd = jest.spyOn(window, 'addEventListener') 167 | 168 | const backend = createBackend() 169 | expect(TestBackends[0].setup).not.toHaveBeenCalled() 170 | backend.setup() 171 | expect(TestBackends[0].setup).toHaveBeenCalled() 172 | expect(spyAdd).toHaveBeenCalledWith('touchstart', expect.any(Function)) 173 | 174 | backend.teardown() 175 | spyAdd.mockRestore() 176 | }) 177 | }) 178 | 179 | describe('teardown function', () => { 180 | test('does nothing if it has no window', () => { 181 | const windowSpy = jest.spyOn(window, 'window', 'get') 182 | const spyRemove = jest.spyOn(window, 'removeEventListener') 183 | // @ts-expect-error 184 | windowSpy.mockImplementation(() => undefined) 185 | 186 | const backend = createBackend() 187 | backend.setup() 188 | backend.teardown() 189 | expect(spyRemove).not.toHaveBeenCalled() 190 | expect(TestBackends[0].teardown).not.toHaveBeenCalled() 191 | 192 | spyRemove.mockRestore() 193 | windowSpy.mockRestore() 194 | }) 195 | 196 | test('cleans up the events and sub-backends', () => { 197 | const spyRemove = jest.spyOn(window, 'removeEventListener') 198 | 199 | const backend = createBackend() 200 | backend.setup() 201 | expect(TestBackends[0].teardown).not.toHaveBeenCalled() 202 | backend.teardown() 203 | expect(TestBackends[0].teardown).toHaveBeenCalled() 204 | expect(spyRemove).toHaveBeenCalledWith('touchstart', expect.any(Function)) 205 | 206 | spyRemove.mockRestore() 207 | }) 208 | 209 | test('can recreate a second backend', () => { 210 | const spyRemove = jest.spyOn(window, 'removeEventListener') 211 | 212 | const backend = createBackend() 213 | backend.setup() 214 | expect(TestBackends[0].teardown).not.toHaveBeenCalled() 215 | backend.teardown() 216 | expect(TestBackends[0].teardown).toHaveBeenCalled() 217 | expect(spyRemove).toHaveBeenCalledWith('touchstart', expect.any(Function)) 218 | 219 | TestBackends[0].teardown.mockClear() 220 | spyRemove.mockClear() 221 | 222 | const backend2 = createBackend() 223 | backend2.setup() 224 | expect(TestBackends[0].teardown).not.toHaveBeenCalled() 225 | backend2.teardown() 226 | expect(TestBackends[0].teardown).toHaveBeenCalled() 227 | expect(spyRemove).toHaveBeenCalledWith('touchstart', expect.any(Function)) 228 | 229 | spyRemove.mockRestore() 230 | }) 231 | }) 232 | 233 | describe('connectDragSource function', () => { 234 | test('pass data correctly', () => { 235 | const unsub = jest.fn() 236 | const backend = createBackend() 237 | TestBackends[0].connectDragSource.mockReturnValue(unsub) 238 | expect(TestBackends[0].connectDragSource).not.toHaveBeenCalled() 239 | const wrappedUnsub = backend.connectDragSource(1, 2, 3) 240 | expect(TestBackends[0].connectDragSource).toHaveBeenCalledWith(1, 2, 3) 241 | expect(unsub).not.toHaveBeenCalled() 242 | wrappedUnsub() 243 | expect(unsub).toHaveBeenCalled() 244 | }) 245 | 246 | test('handles backend switch correctly', () => { 247 | const unsub1 = jest.fn() 248 | TestBackends[0].connectDragSource.mockReturnValue(unsub1) 249 | 250 | const unsub2 = jest.fn() 251 | TestBackends[1].connectDragSource.mockReturnValue(unsub2) 252 | 253 | const backend = createBackend() 254 | backend.setup() 255 | 256 | expect(TestBackends[0].connectDragSource).not.toHaveBeenCalled() 257 | const wrappedUnsub = backend.connectDragSource(1, 2, 3) 258 | expect(TestBackends[0].connectDragSource).toHaveBeenCalledWith(1, 2, 3) 259 | 260 | expect(TestBackends[1].connectDragSource).not.toHaveBeenCalled() 261 | switchTouchBackend() 262 | expect(unsub1).toHaveBeenCalled() 263 | expect(TestBackends[1].connectDragSource).toHaveBeenCalledWith(1, 2, 3) 264 | 265 | expect(unsub2).not.toHaveBeenCalled() 266 | wrappedUnsub() 267 | expect(unsub2).toHaveBeenCalled() 268 | 269 | backend.teardown() 270 | }) 271 | }) 272 | 273 | describe('connectDragPreview function', () => { 274 | test('pass data correctly', () => { 275 | const unsub = jest.fn() 276 | const backend = createBackend() 277 | TestBackends[0].connectDragPreview.mockReturnValue(unsub) 278 | expect(TestBackends[0].connectDragPreview).not.toHaveBeenCalled() 279 | const wrappedUnsub = backend.connectDragPreview(1, 2, 3) 280 | expect(TestBackends[0].connectDragPreview).toHaveBeenCalledWith(1, 2, 3) 281 | expect(unsub).not.toHaveBeenCalled() 282 | wrappedUnsub() 283 | expect(unsub).toHaveBeenCalled() 284 | }) 285 | 286 | test('handles backend switch correctly', () => { 287 | const unsub1 = jest.fn() 288 | TestBackends[0].connectDragPreview.mockReturnValue(unsub1) 289 | 290 | const unsub2 = jest.fn() 291 | TestBackends[1].connectDragPreview.mockReturnValue(unsub2) 292 | 293 | const backend = createBackend() 294 | backend.setup() 295 | 296 | expect(TestBackends[0].connectDragPreview).not.toHaveBeenCalled() 297 | const wrappedUnsub = backend.connectDragPreview(1, 2, 3) 298 | expect(TestBackends[0].connectDragPreview).toHaveBeenCalledWith(1, 2, 3) 299 | 300 | expect(TestBackends[1].connectDragPreview).not.toHaveBeenCalled() 301 | switchTouchBackend() 302 | expect(unsub1).toHaveBeenCalled() 303 | expect(TestBackends[1].connectDragPreview).toHaveBeenCalledWith(1, 2, 3) 304 | 305 | expect(unsub2).not.toHaveBeenCalled() 306 | wrappedUnsub() 307 | expect(unsub2).toHaveBeenCalled() 308 | 309 | backend.teardown() 310 | }) 311 | }) 312 | 313 | describe('connectDropTarget function', () => { 314 | test('pass data correctly', () => { 315 | const unsub = jest.fn() 316 | const backend = createBackend() 317 | TestBackends[0].connectDropTarget.mockReturnValue(unsub) 318 | expect(TestBackends[0].connectDropTarget).not.toHaveBeenCalled() 319 | const wrappedUnsub = backend.connectDropTarget(1, 2, 3) 320 | expect(TestBackends[0].connectDropTarget).toHaveBeenCalledWith(1, 2, 3) 321 | expect(unsub).not.toHaveBeenCalled() 322 | wrappedUnsub() 323 | expect(unsub).toHaveBeenCalled() 324 | }) 325 | 326 | test('handles backend switch correctly', () => { 327 | const unsub1 = jest.fn() 328 | TestBackends[0].connectDropTarget.mockReturnValue(unsub1) 329 | 330 | const unsub2 = jest.fn() 331 | TestBackends[1].connectDropTarget.mockReturnValue(unsub2) 332 | 333 | const backend = createBackend() 334 | backend.setup() 335 | 336 | expect(TestBackends[0].connectDropTarget).not.toHaveBeenCalled() 337 | const wrappedUnsub = backend.connectDropTarget(1, 2, 3) 338 | expect(TestBackends[0].connectDropTarget).toHaveBeenCalledWith(1, 2, 3) 339 | 340 | expect(TestBackends[1].connectDropTarget).not.toHaveBeenCalled() 341 | switchTouchBackend() 342 | expect(unsub1).toHaveBeenCalled() 343 | expect(TestBackends[1].connectDropTarget).toHaveBeenCalledWith(1, 2, 3) 344 | 345 | expect(unsub2).not.toHaveBeenCalled() 346 | wrappedUnsub() 347 | expect(unsub2).toHaveBeenCalled() 348 | 349 | backend.teardown() 350 | }) 351 | }) 352 | 353 | describe('profile function', () => { 354 | test('returns the profiling data', () => { 355 | const data = {abc: 123} as Record 356 | TestBackends[0].profile.mockReturnValue(data) 357 | 358 | const backend = createBackend() 359 | 360 | expect(TestBackends[0].profile).not.toHaveBeenCalled() 361 | expect(backend.profile()).toBe(data) 362 | expect(TestBackends[0].profile).toHaveBeenCalledWith() 363 | }) 364 | }) 365 | 366 | describe('previewEnabled/previewsList functions', () => { 367 | test('returns the current backend preview attribute', () => { 368 | const backend = createBackend() 369 | backend.setup() 370 | expect(backend.previewEnabled()).toBe(false) 371 | switchTouchBackend() 372 | expect(backend.previewEnabled()).toBe(true) 373 | 374 | backend.teardown() 375 | }) 376 | 377 | test('notifies the preview list', () => { 378 | const listener = { 379 | backendChanged: jest.fn(), 380 | } 381 | 382 | const backend = createBackend() 383 | backend.previewsList().register(listener) 384 | backend.setup() 385 | expect(listener.backendChanged).not.toHaveBeenCalled() 386 | switchTouchBackend() 387 | expect(listener.backendChanged).toHaveBeenCalledWith(backend) 388 | 389 | backend.teardown() 390 | }) 391 | }) 392 | 393 | describe('backendsList function', () => { 394 | test('returns the backends list based on the pipeline', () => { 395 | const backend = createBackend() 396 | expect(backend.backendsList()[0].id).toBe(TestPipeline.backends[0].id) 397 | }) 398 | }) 399 | 400 | describe('#backendSwitcher function', () => { 401 | test('does not skip the transition', () => { 402 | const handler = jest.fn() 403 | window.addEventListener('touchstart', handler) 404 | 405 | const backend = createBackend(TestPipeline) 406 | backend.setup() 407 | switchTouchBackend() 408 | expect(handler).toHaveBeenCalledTimes(2) 409 | backend.teardown() 410 | 411 | window.removeEventListener('touchstart', handler) 412 | }) 413 | 414 | test('skips the transition', () => { 415 | const handler = jest.fn() 416 | window.addEventListener('touchstart', handler) 417 | 418 | const backend = createBackend(TestPipelineWithSkip) 419 | backend.setup() 420 | switchTouchBackend() 421 | expect(handler).toHaveBeenCalledTimes(1) 422 | backend.teardown() 423 | 424 | window.removeEventListener('touchstart', handler) 425 | }) 426 | }) 427 | }) 428 | -------------------------------------------------------------------------------- /packages/react-dnd-multi-backend/README.md: -------------------------------------------------------------------------------- 1 | # React DnD Multi Backend [![NPM Version][npm-image]][npm-url] [![dependencies Status][deps-image]][deps-url] [![devDependencies Status][deps-dev-image]][deps-dev-url] 2 | 3 | [Try it here!](https://louisbrunner.github.io/dnd-multi-backend/examples/react-dnd-multi-backend.html) 4 | 5 | This project is a Drag'n'Drop backend compatible with [React DnD](https://github.com/react-dnd/react-dnd). 6 | It enables your application to use different DnD backends depending on the situation. 7 | You can either generate your own backend pipeline or use the default one (see [`rdndmb-html5-to-touch`](../rdndmb-html5-to-touch)). 8 | 9 | [`rdndmb-html5-to-touch`](../rdndmb-html5-to-touch) starts by using the [React DnD HTML5 Backend](https://react-dnd.github.io/react-dnd/docs/backends/html5), but switches to the [React DnD Touch Backend](https://react-dnd.github.io/react-dnd/docs/backends/touch) if a touch event is triggered. 10 | You application can smoothly use the nice HTML5 compatible backend and fallback on the Touch one on mobile devices! 11 | 12 | Moreover, because some backends don't support preview, a `Preview` component has been added to make it easier to mock the Drag'n'Drop "ghost". 13 | 14 | See the [migration section](#migrating) for instructions when switching from `2.x.x`, `3.x.x`, `4.x.x`, `5.0.x` or `6.x.x`. 15 | 16 | ## Installation 17 | 18 | ```sh 19 | npm install -S react-dnd-multi-backend 20 | ``` 21 | 22 | You can then import the backend using `import { MultiBackend } from 'react-dnd-multi-backend'`. 23 | 24 | ### Backends pipeline 25 | 26 | In order to use [`rdndmb-html5-to-touch`](../rdndmb-html5-to-touch), you will now need to install it separately: 27 | 28 | ```sh 29 | npm install -S rdndmb-html5-to-touch 30 | ``` 31 | 32 | ## Usage 33 | 34 | ### DndProvider (new API) 35 | 36 | You can use the `DndProvider` component the same way you do the one from `react-dnd` ([docs](https://react-dnd.github.io/react-dnd/docs/api/dnd-provider) for more information), at the difference that you don't need to specify `backend` as a prop, it is implied to be `MultiBackend`. 37 | 38 | You must pass a 'pipeline' to use as argument. [`rdndmb-html5-to-touch`](../rdndmb-html5-to-touch) is provided as another package but you can also write your own. 39 | 40 | ```js 41 | import { DndProvider } from 'react-dnd-multi-backend' 42 | import { HTML5toTouch } from 'rdndmb-html5-to-touch' // or any other pipeline 43 | 44 | const App = () => { 45 | return ( 46 | 47 | 48 | 49 | ) 50 | } 51 | ``` 52 | 53 | ### Backend (old API) 54 | 55 | You can plug this backend in the `DragDropContext` the same way you do for any backend (e.g. `ReactDnDHTML5Backend`), you can see [the docs](https://react-dnd.github.io/react-dnd/docs/backends/html5) for more information. 56 | 57 | You must pass a 'pipeline' to use as argument. [`rdndmb-html5-to-touch`](../rdndmb-html5-to-touch) is provided as another package but you can also write your own. 58 | 59 | ```js 60 | import { DndProvider } from 'react-dnd' 61 | import { MultiBackend } from 'react-dnd-multi-backend' 62 | import { HTML5toTouch } from 'rdndmb-html5-to-touch' // or any other pipeline 63 | 64 | const App = () => { 65 | return ( 66 | 67 | 68 | 69 | ) 70 | } 71 | ``` 72 | 73 | ### Create a custom pipeline 74 | 75 | Creating a pipeline is fairly easy. A pipeline is composed of a list of backends, the first one will be the default one, loaded at the start of the **MultiBackend**, the order of the rest isn't important. 76 | 77 | Each backend entry must specify one property: `backend`, containing the class of the Backend to instantiate. 78 | But other options are available: 79 | 80 | - `id`: a string identifying that backend uniquely 81 | - `backend`: a function used to create the actual backend (usually provided by the corresponding package, e.g. `import { HTML5Backend } from 'react-dnd-html5-backend`) 82 | - `options`: optional, any type, this will be passed to the `backend` factory when creating that backend 83 | - `preview`: optional, a boolean indicating if `Preview` components should be shown 84 | - `transition`: optional, an object returned by the `createTransition` function 85 | - `skipDispatchOnTransition`: optional, a boolean indicating transition events should not be dispatched to new backend, defaults to `false`. See [note below](#note-on-skipdispatchontransition) for details and use cases.) 86 | 87 | Here is the `rdndmb-html5-to-touch` pipeline's code as an example: 88 | ```js 89 | import { HTML5Backend } from 'react-dnd-html5-backend' 90 | import { TouchBackend } from 'react-dnd-touch-backend' 91 | 92 | import { DndProvider, TouchTransition, MouseTransition } from 'react-dnd-multi-backend' 93 | 94 | export const HTML5toTouch = { 95 | backends: [ 96 | { 97 | id: 'html5', 98 | backend: HTML5Backend, 99 | transition: MouseTransition, 100 | }, 101 | { 102 | id: 'touch', 103 | backend: TouchBackend, 104 | options: {enableMouseEvents: true}, 105 | preview: true, 106 | transition: TouchTransition, 107 | }, 108 | ], 109 | } 110 | 111 | const App = () => { 112 | return ( 113 | 114 | 115 | 116 | ) 117 | } 118 | ``` 119 | 120 | #### Transitions 121 | 122 | Transitions are required to allow switching between backends. They really easy to write, here is an example: 123 | 124 | ```js 125 | import { createTransition } from 'react-dnd-multi-backend' 126 | 127 | const TouchTransition = createTransition('touchstart', (event) => { 128 | return event.touches != null 129 | }) 130 | ``` 131 | 132 | The following transitions are provided: 133 | 134 | - `TouchTransition`: triggered when a *touchstart* is received 135 | - `HTML5DragTransition`: triggered when a HTML5 DragEvent is received 136 | - `MouseTransition`: triggered when a MouseEvent is received 137 | 138 | #### Note on `skipDispatchOnTransition` 139 | 140 | By default, when an event triggers a transition, `dnd-multi-backend` dispatches a cloned version of the event after setting up the new backend. This allows the newly activated backend to handle the original event. 141 | 142 | If your app code or another library has registered event listeners for the same events that are being used for transitions, this duplicate event may cause problems. 143 | 144 | You can optionally disable this behavior per backend: 145 | 146 | ```js 147 | const CustomHTML5toTouch = { 148 | backends: [ 149 | { 150 | backend: HTML5Backend, 151 | transition: MouseTransition 152 | // by default, will dispatch a duplicate `mousedown` event when this backend is activated 153 | }, 154 | { 155 | backend: TouchBackend, 156 | // Note that you can call your backends with options 157 | options: {enableMouseEvents: true}, 158 | preview: true, 159 | transition: TouchTransition, 160 | // will not dispatch a duplicate `touchstart` event when this backend is activated 161 | skipDispatchOnTransition: true 162 | } 163 | ] 164 | } 165 | ``` 166 | 167 | **WARNING:** if you enable `skipDispatchOnTransition`, the backend transition will happen as expected, but the new backend may not handle the first event! 168 | 169 | In this example, the first `touchstart` event would trigger the `TouchBackend` to replace the `HTML5Backend`—but the user would have to start a new touch event for the `TouchBackend` to register a drag. 170 | 171 | ### Hooks 172 | 173 | The library provides a set of hooks to expand `useDrag` and `useDrop`. 174 | 175 | #### `useMultiDrag` 176 | 177 | It expands `useDrag` and takes the same arguments but returns an array of: 178 | 179 | - `Index 0`: the original return of `useDrag` 180 | - `Index 1`: an object containing the same types as `useDrag` but specific to each backend (the key being the backend `id`) 181 | - `backend.id`: 182 | - `Index 0`: an object containing collected properties from the collect function. If no `collect` function is defined, an empty object is returned (`Index 0` in `useDrag`'s return) 183 | - `Index 1`: a connector function for the drag source. This must be attached to the draggable portion of the DOM (`Index 1` in `useDrag`'s return) 184 | - `Index 2`: a connector function for the drag preview. This may be attached to the preview portion of the DOM (`Index 2` in `useDrag`'s return) 185 | 186 | Example: 187 | ```js 188 | import { useMultiDrag } from 'react-dnd-multi-backend' 189 | 190 | const MultiCard = (props) => { 191 | const [[dragProps], {html5: [html5Props, html5Drag], touch: [touchProps, touchDrag]}] = useMultiDrag({ 192 | type: 'card', 193 | item: {color: props.color}, 194 | collect: (monitor) => { 195 | return { 196 | isDragging: monitor.isDragging(), 197 | } 198 | }, 199 | }) 200 | 201 | const containerStyle = {opacity: dragProps.isDragging ? 0.5 : 1} 202 | const html5DragStyle = {backgroundColor: props.color, opacity: html5Props.isDragging ? 0.5 : 1} 203 | const touchDragStyle = {backgroundColor: props.color, opacity: touchProps.isDragging ? 0.5 : 1} 204 | return ( 205 |
206 |
HTML5
207 |
Touch
208 |
209 | ) 210 | } 211 | ``` 212 | 213 | #### `useMultiDrop` 214 | 215 | It expands `useDrop` and takes the same arguments but returns an array of: 216 | 217 | - `Index 0`: the original return of `useDrop` 218 | - `Index 1`: an object containing the same types as `useDrop` but specific to each backend (the key being the backend `id`) 219 | - `backend.id`: 220 | - `Index 0`: an object containing collected properties from the collect function. If no `collect` function is defined, an empty object is returned (`Index 0` in `useDrop`'s return) 221 | - `Index 1`: A connector function for the drop target. This must be attached to the drop-target portion of the DOM (`Index 1` in `useDrop`'s return) 222 | 223 | Example: 224 | ```js 225 | import { useMultiDrop } from 'react-dnd-multi-backend' 226 | 227 | const MultiBasket = (props) => { 228 | const [[dropProps], {html5: [html5DropStyle, html5Drop], touch: [touchDropStyle, touchDrop]}] = useMultiDrop({ 229 | accept: 'card', 230 | drop: (item) => { 231 | const message = `Dropped: ${item.color}` 232 | logs.current.innerHTML += `${message}
` 233 | }, 234 | collect: (monitor) => { 235 | return { 236 | isOver: monitor.isOver(), 237 | canDrop: monitor.canDrop(), 238 | } 239 | }, 240 | }) 241 | 242 | const containerStyle = {border: '1px dashed black'} 243 | const html5DropStyle = {backgroundColor: (html5Props.isOver && html5Props.canDrop) ? '#f3f3f3' : '#bbbbbb'} 244 | const touchDropStyle = {backgroundColor: (touchProps.isOver && touchProps.canDrop) ? '#f3f3f3' : '#bbbbbb'} 245 | return ( 246 |
247 |
HTML5
248 |
Touch
249 |
250 | ) 251 | } 252 | ``` 253 | 254 | ### Preview 255 | 256 | The `Preview` class is usable in different ways: hook-based, function-based and context-based. 257 | All of them receive the same data formatted the same way, an object containing the following properties: 258 | 259 | - `display`: only with `usePreview`, boolean indicating if you should render your preview 260 | - `itemType`: the type of the item (`monitor.getItemType()`) 261 | - `item`: the item (`monitor.getItem()`) 262 | - `style`: an object representing the style (used for positioning), it should be passed to the `style` property of your preview component 263 | - `ref`: a reference which can be passed to the final component that will use `style`, it will allow `Preview` to position the previewed component correctly (closer to what HTML5 DnD can do) 264 | - `monitor`: the actual [`DragLayerMonitor`](https://react-dnd.github.io/react-dnd/docs/api/drag-layer-monitor) from `react-dnd` 265 | 266 | Note that this component will only be showed while using a backend flagged with `preview: true` (see [Create a custom pipeline](#create-a-custom-pipeline)) which is the case for the Touch backend in the `rdndmb-html5-to-touch` pipeline. 267 | 268 | #### Hook-based 269 | 270 | ```js 271 | import { DndProvider, usePreview } from 'react-dnd-multi-backend' 272 | 273 | const MyPreview = () => { 274 | const preview = usePreview() 275 | if (!preview.display) { 276 | return null 277 | } 278 | const {itemType, item, style} = preview; 279 | // render your preview 280 | } 281 | 282 | const App = () => { 283 | return ( 284 | 285 | 286 | 287 | ) 288 | } 289 | ``` 290 | 291 | #### Function-based 292 | 293 | ```js 294 | import { DndProvider, Preview } from 'react-dnd-multi-backend' 295 | 296 | const generatePreview = ({itemType, item, style}) => { 297 | // render your preview 298 | } 299 | 300 | const App = () => { 301 | return ( 302 | 303 | 304 | {/* or */} 305 | {generatePreview} 306 | 307 | ) 308 | } 309 | ``` 310 | 311 | #### Context-based 312 | 313 | ```js 314 | import { DndProvider, Preview } from 'react-dnd-multi-backend' 315 | 316 | const MyPreview = () => { 317 | const {itemType, item, style} = useContext(Preview.Context) 318 | // render your preview 319 | } 320 | 321 | const App = () => { 322 | return ( 323 | 324 | 325 | 326 | // or 327 | 328 | {({itemType, item, style}) => /* render your preview */} 329 | 330 | 331 | 332 | ) 333 | } 334 | ``` 335 | 336 | ### Examples 337 | 338 | You can see an example [here](examples/). 339 | 340 | ## Migrating 341 | 342 | ### Migrating from 6.x.x 343 | 344 | Starting with `7.0.0`, `HTML5toTouch` will not be provided through this package anymore but through its own: [`rdndmb-html5-to-touch`](../rdndmb-html5-to-touch/). It also doesn't have a default export anymore. 345 | 346 | Previously: 347 | ```js 348 | import MultiBackend from 'react-dnd-multi-backend' 349 | import HTML5toTouch from 'react-dnd-multi-backend/dist/esm/HTML5toTouch' 350 | // or 351 | import HTML5toTouch from 'react-dnd-multi-backend/dist/cjs/HTML5toTouch' 352 | ``` 353 | 354 | Now: 355 | ```js 356 | import { MultiBackend } from 'react-dnd-multi-backend' 357 | import { HTML5toTouch } from 'rdndmb-html5-to-touch' 358 | ``` 359 | 360 | ### Migrating from 5.0.x 361 | 362 | Starting with `5.1.0`, `react-dnd-multi-backend` will export a new `DndProvider` which you can use instead of the one from `react-dnd`. You don't need to pass the `backend` prop to that component as it's implied you are using `MultiBackend`, however the major benefits is under the hood: 363 | 364 | - No longer relying on global values, allowing better encapsulation of the backend and previews 365 | - `Preview` will be mounted with `DndProvider` using a `React.createPortal`, thus you don't need to worry about mounting your `Preview` at the top of the tree for the absolute positioning to work correctly 366 | 367 | Moreover, every backend in a pipeline will now need a new property called `id` and the library will warn if it isn't specified. The `MultiBackend` will try to guess it if possible, but that might fail and you will need to define them explicitly. 368 | 369 | Note that these aren't breaking changes, you can continue using the library as before. 370 | 371 | ### Migrating from 4.x.x 372 | 373 | Starting with `5.0.0`, `react-dnd-preview` (which provides the `Preview` component) will start passing its arguments packed in one argument, an object `{itemType, item, style}`, instead of 3 different arguments (`itemType`, `item` and `style`). This means that will need to change your generator function to receive arguments correctly. 374 | 375 | ### Migrating from 3.x.x 376 | 377 | Starting with `4.0.0`, `react-dnd-multi-backend` will start using `react-dnd` (and the corresponding backends) `9.0.0` and later. 378 | 379 | This means you need to transition from `DragDropContext(MultiBackend(HTML5toTouch))(App)` to ``. 380 | Accordingly, the pipeline syntax changes and you should specify backend options as a separate property, e.g. `{backend: TouchBackend({enableMouseEvents: true})}` becomes `{backend: TouchBackend, options: {enableMouseEvents: true}}`. 381 | Note that if you use the `HTML5toTouch` pipeline, the same is true for `react-dnd-html5-backend` and `react-dnd-touch-backend`. 382 | 383 | ### Migrating from 3.1.2 384 | 385 | Starting with `3.1.8`, the dependencies of `react-dnd-multi-backend` changed. `react`, `react-dom`, `react-dnd` become peer dependencies and you need to install them manually as `dependencies` in your project `package.json`. 386 | 387 | Note that if you use the `HTML5toTouch` pipeline, the same is true for `react-dnd-html5-backend` and `react-dnd-touch-backend`. 388 | 389 | ### Migrating from 2.x.x 390 | 391 | In 2.x.x, the pipeline was static but corresponded with the behavior of `HTML5toTouch`, so just [including and passing this pipeline as a parameter](#backend) would give you the same experience as before. 392 | 393 | If you used the `start` option, it's a bit different. 394 | With `start: 0` or `start: Backend.HTML5`, **MultiBackend** simply used the default pipeline, so you can also just pass `HTML5toTouch`. 395 | With `start: 1` or `start: Backend.TOUCH`, **MultiBackend** would only use the TouchBackend, so you can replace **MultiBackend** with **TouchBackend** (however, you would lose the `Preview` component) or create a simple pipeline (see [Create a custom pipeline](#create-a-custom-pipeline)) and pass it as a parameter: 396 | ```js 397 | var TouchOnly = { backends: [{ backend: TouchBackend, preview: true }] } 398 | ``` 399 | 400 | ## License 401 | 402 | MIT, Copyright (c) 2016-2022 Louis Brunner 403 | 404 | 405 | 406 | [npm-image]: https://img.shields.io/npm/v/react-dnd-multi-backend.svg 407 | [npm-url]: https://npmjs.org/package/react-dnd-multi-backend 408 | [deps-image]: https://david-dm.org/louisbrunner/react-dnd-multi-backend/status.svg 409 | [deps-url]: https://david-dm.org/louisbrunner/react-dnd-multi-backend 410 | [deps-dev-image]: https://david-dm.org/louisbrunner/react-dnd-multi-backend/dev-status.svg 411 | [deps-dev-url]: https://david-dm.org/louisbrunner/react-dnd-multi-backend?type=dev 412 | --------------------------------------------------------------------------------