diff --git a/.better-commits.json b/.better-commits.json index 8e9be0f..cba5180 100644 --- a/.better-commits.json +++ b/.better-commits.json @@ -4,6 +4,10 @@ "custom_scope": true, "initial_value": "", "options": [ + { + "value": "di", + "label": "di" + }, { "value": "dom", "label": "dom" @@ -20,10 +24,18 @@ "value": "react", "label": "react" }, + { + "value": "router", + "label": "router" + }, { "value": "rspack", "label": "rspack" }, + { + "value": "store", + "label": "store" + }, { "value": "testing", "label": "testing" @@ -32,10 +44,6 @@ "value": "types", "label": "types" }, - { - "value": "di", - "label": "di" - }, { "value": "", "label": "none" diff --git a/docs/docs/react/use-drag-and-drop.mdx b/docs/docs/react/use-drag-and-drop.mdx index 6d70b55..ae4f24a 100644 --- a/docs/docs/react/use-drag-and-drop.mdx +++ b/docs/docs/react/use-drag-and-drop.mdx @@ -52,3 +52,95 @@ function App() { return
This block is draggable
; } ``` + +### Controlling drag start + +By default hook start dragging immediately on `pointerdown` event. + +To disable or change this behavior you can call `preventDefault` on grab event. + +For example you can start drag only if some child element is pressed: + +```ts +const needStartDrag = event => { + return event.nativeEvent.target.classList.contains('handle'); +}; + +useDragAndDrop(ref, { + onGrab(event) { + if (needStartDrag(event)) { + return event.preventDefault(); + } + + // ... your state logic + }, +}); +``` + +Or you can implement dragging activation only after some shift threshold reached: + +```ts +const threshold = 8; + +const needStartDrag = event => { + return Math.abs(event.offset.x - event.startOffset.x) >= threshold; +}; +``` + +### Plugins + +Hook provides ability to define plugins to extend basic drag-and-drop behavior. + +```ts +useDragAndDrop(ref, { + plugins: [myPlugin, myOtherPlugin], + // ...other options +}); +``` + +Plugin can add hooks for some events: + +- `init` - here you can add some listeners +- `grab` - calls hook right after dispatching `grab` event +- `move`- calls hook right after dispatching `move` event +- `drop`- calls hook right after dispatching `drop` event +- `destroy` - here you can remove some listeners + +By default hook uses some builtin plugins. + +You can disable this plugins by passing array: + +```ts +useDragAndDrop(ref, { + // no plugins and no builtins + plugins: [], +}); + +useDragAndDrop(ref, { + // only your plugin and no builtins + plugins: [myPlugin], +}); +``` + +#### Builtin plugin `cleanSelection` + +Plugin that cleans selection during drag. + +#### Builtin plugin `preventClick` + +Plugin that prevents click if target element was moved by drag-and-drop. + +#### Builtin plugin `touchScroll` + +Plugin that prevents page scroll and "pull-to-refresh" for draggable element. + +#### Using builtins selectively + +```ts +import { DragAndDropBuiltinPlugins, useDragAndDrop } from '@krutoo/utils/react'; + +useDragAndDrop(ref, { + // Using only `touchScroll` plugin from builtins + plugins: [DragAndDropBuiltinPlugins.touchScroll], +}); +``` diff --git a/package-lock.json b/package-lock.json index e4ca8a6..54c60c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3616,9 +3616,9 @@ } }, "node_modules/flatted": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz", - "integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, diff --git a/src/react/drag-and-drop/builtin-plugins.ts b/src/react/drag-and-drop/builtin-plugins.ts new file mode 100644 index 0000000..c3676e6 --- /dev/null +++ b/src/react/drag-and-drop/builtin-plugins.ts @@ -0,0 +1,71 @@ +import type { DragAndDropPlugin } from './types.ts'; + +export const DragAndDropBuiltinPlugins = { + /** + * Returns plugin that cleans selection during drag-and-drop. + * @returns Plugin. + */ + cleanSelection(): DragAndDropPlugin { + return api => { + const cleanSelection = () => { + document.getSelection()?.removeAllRanges(); + }; + + api.hooks.grab.add(cleanSelection); + api.hooks.move.add(cleanSelection); + }; + }, + + /** + * Returns plugin that prevents click if target element was moved by drag-and-drop. + * @returns Plugin. + */ + preventClick(): DragAndDropPlugin { + return api => { + const onClickCapture = (event: MouseEvent) => { + const state = api.getState(); + const distance = state.offset.getDistance(state.startOffset); + + if (distance > 0) { + event.preventDefault(); + event.stopPropagation(); + } + }; + + api.hooks.init.add(ctx => { + ctx.element.addEventListener('click', onClickCapture, { capture: true }); + }); + api.hooks.destroy.add(ctx => { + ctx.element.addEventListener('click', onClickCapture, { capture: true }); + }); + }; + }, + + /** + * Returns plugin that prevents page scroll and "pull-to-refresh" on draggable element. + * @returns Plugin. + */ + touchScroll(): DragAndDropPlugin { + return api => { + let onTouchMove: (event: TouchEvent) => void; + + api.hooks.init.add(ctx => { + onTouchMove = (event: TouchEvent) => { + // IMPORTANT: check only inside target + if (!(event.target instanceof Element) || !ctx.element.contains(event.target)) { + return false; + } + + if (api.getState().grabbed) { + event.preventDefault(); + } + }; + + window.addEventListener('touchmove', onTouchMove, { passive: false }); + }); + api.hooks.destroy.add(() => { + window.removeEventListener('touchmove', onTouchMove); + }); + }; + }, +}; diff --git a/src/react/drag-and-drop/events.ts b/src/react/drag-and-drop/events.ts new file mode 100644 index 0000000..332e60d --- /dev/null +++ b/src/react/drag-and-drop/events.ts @@ -0,0 +1,47 @@ +import type { Point2d } from '../../mod.ts'; +import type { DragAndDropEvent, DragAndDropState } from './types.ts'; + +export class DnDEvent implements DragAndDropEvent { + readonly target: HTMLElement; + + readonly nativeEvent: E; + + readonly clientPosition: Point2d; + + readonly grabbed: boolean; + + readonly pressed: boolean; + + readonly offset: Point2d; + + readonly startOffset: Point2d; + + readonly innerOffset: Point2d; + + protected _defaultPrevented: boolean; + + constructor( + target: HTMLElement, + state: DragAndDropState, + nativeEvent: E, + clientPosition: Point2d, + ) { + this.target = target; + this.nativeEvent = nativeEvent; + this.clientPosition = clientPosition; + this.grabbed = state.grabbed; + this.pressed = state.pressed; + this.offset = state.offset; + this.startOffset = state.startOffset; + this.innerOffset = state.innerOffset; + this._defaultPrevented = false; + } + + get defaultPrevented(): boolean { + return this._defaultPrevented; + } + + preventDefault(): void { + this._defaultPrevented = true; + } +} diff --git a/src/react/drag-and-drop/observer.ts b/src/react/drag-and-drop/observer.ts new file mode 100644 index 0000000..e78958d --- /dev/null +++ b/src/react/drag-and-drop/observer.ts @@ -0,0 +1,167 @@ +import { type Point2d, Vector2, getPositionedParentOffset } from '../../mod.ts'; +import { DnDEvent } from './events.ts'; +import { createPluginManager } from './plugin-manager.ts'; +import type { + DragAndDropObserverContext, + DragAndDropObserverOptions, + DragAndDropPluginManager, + DragAndDropState, +} from './types.ts'; + +/** + * Drag-and-drop behavior observer. + * Allows to implement drag-and-drop by providing event handlers. + * Don't affects element' style or any other properties, just listens some events. + * @internal + */ +export class DragAndDropObserver { + protected readonly options: DragAndDropObserverOptions; + + protected state: DragAndDropState; + + protected plugins: DragAndDropPluginManager; + + constructor(options: DragAndDropObserverOptions = {}) { + this.options = options; + this.state = { + pointerId: -1, + pressed: false, + grabbed: false, + offset: Vector2.of(0, 0), + startOffset: Vector2.of(0, 0), + innerOffset: Vector2.of(0, 0), + }; + this.plugins = createPluginManager(() => this.state); + } + + /** + * Connects instance to given element. + * @param element Element. + * @returns Unobserve function. + */ + observe(element: HTMLElement): VoidFunction { + const ctx: DragAndDropObserverContext = { + element, + getOffsets: ( + clientPosition: Vector2, + innerOffset = clientPosition.clone().subtract(element.getBoundingClientRect()), + ) => { + const offset = clientPosition + .clone() + .subtract(innerOffset) + .subtract(getPositionedParentOffset(element, this.options)); + + return { offset, innerOffset }; + }, + }; + + const onPointerDown = (event: PointerEvent) => this.handlePointerDown(event, ctx); + const onPointerMove = (event: PointerEvent) => this.handlePointerMove(event, ctx); + const onPointerRelease = (event: PointerEvent) => this.handlePointerRelease(event, ctx); + + element.addEventListener('pointerdown', onPointerDown); + window.addEventListener('pointermove', onPointerMove); + window.addEventListener('pointerup', onPointerRelease); + window.addEventListener('pointercancel', onPointerRelease); + this.plugins.actions.addPlugins(this.options.plugins ?? []); + this.plugins.actions.hookInit(ctx); + + return () => { + element.removeEventListener('pointerdown', onPointerDown); + window.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('pointerup', onPointerRelease); + window.removeEventListener('pointercancel', onPointerRelease); + this.plugins.actions.hookDestroy(ctx); + this.plugins.actions.clearPlugins(); + }; + } + + /** + * Handles `pointerdown` event. + * @param event Pointer event. + * @param ctx Context. + */ + protected handlePointerDown(event: PointerEvent, ctx: DragAndDropObserverContext): void { + if (this.state.grabbed) { + return; + } + + const clientPosition = Vector2.of(event.clientX, event.clientY); + const { offset, innerOffset } = ctx.getOffsets(clientPosition); + + this.state.pressed = true; + this.state.pointerId = event.pointerId; + this.state.offset = offset; + this.state.startOffset = offset; + this.state.innerOffset = innerOffset; + + this.dispatchGrab(ctx.element, event, clientPosition); + } + + /** + * Handles `pointermove` event. + * @param event Pointer event. + * @param ctx Context. + */ + protected handlePointerMove(event: PointerEvent, ctx: DragAndDropObserverContext): void { + if (this.state.pointerId !== event.pointerId) { + return; + } + + const clientPosition = Vector2.of(event.clientX, event.clientY); + const { offset } = ctx.getOffsets(clientPosition, this.state.innerOffset); + + this.state.offset = offset; + + if (this.state.pressed && !this.state.grabbed) { + this.dispatchGrab(ctx.element, event, clientPosition); + } + + if (this.state.grabbed) { + this.options.onMove?.(new DnDEvent(ctx.element, this.state, event, clientPosition)); + this.plugins.actions.hookMove(ctx); + } + } + + /** + * Handles `pointerup` and `pointercancel` events. + * @param event Pointer event. + * @param ctx Context. + */ + protected handlePointerRelease(event: PointerEvent, ctx: DragAndDropObserverContext): void { + if (!this.state.grabbed || this.state.pointerId !== event.pointerId) { + return; + } + + const clientPosition = Vector2.of(event.clientX, event.clientY); + + this.state.pointerId = -1; + this.state.grabbed = false; + this.state.pressed = false; + + this.options.onDrop?.(new DnDEvent(ctx.element, this.state, event, clientPosition)); + this.plugins.actions.hookDrop(ctx); + } + + /** + * Dispatches `grab` event. + * @param element Element. + * @param nativeEvent Native event. + * @param clientPosition Client position from event. + */ + protected dispatchGrab( + element: HTMLElement, + nativeEvent: PointerEvent, + clientPosition: Point2d, + ): void { + const nextState = { ...this.state, grabbed: true }; + const dndEvent = new DnDEvent(element, nextState, nativeEvent, clientPosition); + + this.options.onGrab?.(dndEvent); + + if (!dndEvent.defaultPrevented) { + this.plugins.actions.hookGrab({ element }); + this.state = nextState; + } + } +} diff --git a/src/react/drag-and-drop/plugin-manager.ts b/src/react/drag-and-drop/plugin-manager.ts new file mode 100644 index 0000000..f0dd1ad --- /dev/null +++ b/src/react/drag-and-drop/plugin-manager.ts @@ -0,0 +1,46 @@ +import type { DragAndDropPluginHook, DragAndDropPluginManager, DragAndDropState } from './types.ts'; + +/** @inheritdoc */ +export function createPluginManager(getState: () => DragAndDropState): DragAndDropPluginManager { + const hooks = { + init: new Set(), + grab: new Set(), + move: new Set(), + drop: new Set(), + destroy: new Set(), + }; + + const manager: DragAndDropPluginManager = { + api: { + hooks: { + init: hooks.init, + grab: hooks.grab, + move: hooks.move, + drop: hooks.drop, + destroy: hooks.destroy, + }, + getState, + }, + actions: { + addPlugins(list) { + for (const item of list) { + item(manager.api); + } + }, + clearPlugins() { + hooks.init.clear(); + hooks.grab.clear(); + hooks.move.clear(); + hooks.drop.clear(); + hooks.destroy.clear(); + }, + hookInit: ctx => hooks.init.forEach(fn => fn(ctx)), + hookGrab: ctx => hooks.grab.forEach(fn => fn(ctx)), + hookMove: ctx => hooks.move.forEach(fn => fn(ctx)), + hookDrop: ctx => hooks.drop.forEach(fn => fn(ctx)), + hookDestroy: ctx => hooks.destroy.forEach(fn => fn(ctx)), + }, + }; + + return manager; +} diff --git a/src/react/drag-and-drop/types.ts b/src/react/drag-and-drop/types.ts new file mode 100644 index 0000000..f271546 --- /dev/null +++ b/src/react/drag-and-drop/types.ts @@ -0,0 +1,140 @@ +import type { DependencyList } from 'react'; +import type { Point2d, Vector2 } from '../../mod.ts'; + +export interface DragAndDropEvent { + /** True during user presses target element. */ + readonly pressed: boolean; + + /** True during element grabbed. */ + readonly grabbed: boolean; + + /** Current offset relative to positioned parent. */ + readonly offset: Point2d; + + /** Started offset relative to positioned parent. */ + readonly startOffset: Point2d; + + /** Current offset relative target element from top left corner to grab point. */ + readonly innerOffset: Point2d; + + /** Draggable element. */ + readonly target: HTMLElement; + + /** Client position (clientX, clientY). */ + readonly clientPosition: Point2d; + + /** Cause event. */ + readonly nativeEvent: E; + + /** True if default behavior vas prevented by calling `preventDefault`. */ + readonly defaultPrevented: boolean; + + /** Prevents default behavior for event. */ + preventDefault(): void; +} + +export interface DragAndDropEventHandler { + (event: DragAndDropEvent): void; +} + +export interface UseDragAndDropOptions { + /** Positioning strategy. Should be same that css `position` property of target element. */ + strategy?: 'fixed' | 'absolute'; + + /** When true, Drag-And-Drop will be disabled. */ + disabled?: boolean; + + /** Will be called when element is grabbed. */ + onGrab?: DragAndDropEventHandler; + + /** Will be called when element is dragging. */ + onMove?: DragAndDropEventHandler; + + /** Will be called when element is dropped. */ + onDrop?: DragAndDropEventHandler; + + /** Plugins. */ + plugins?: DragAndDropPlugin[]; + + /** Extra deps for useEffect hook. */ + extraDeps?: DependencyList; +} + +export interface UseDragAndDropReturn { + captured: boolean; + offset: Point2d; +} + +export interface DragAndDropPluginAPI { + getState(): DragAndDropState; + hooks: Record< + 'init' | 'grab' | 'move' | 'drop' | 'destroy', + { + add(hook: DragAndDropPluginHook): void; + } + >; +} + +export interface DragAndDropPlugin { + (api: DragAndDropPluginAPI): void; +} + +export interface DragAndDropPluginHook { + (ctx: DragAndDropPluginHookContext): void; +} + +export interface DragAndDropPluginHookContext { + element: HTMLElement; +} + +export interface DragAndDropState { + /** Pointer id that changes `pressed` to true. */ + pointerId: number; + + /** True during user presses target element. */ + pressed: boolean; + + /** True during element grabbed. */ + grabbed: boolean; + + /** Current offset relative to positioned parent. */ + offset: Vector2; + + /** Started offset relative to positioned parent. */ + startOffset: Vector2; + + /** Current offset relative target element from top left corner to grab point. */ + innerOffset: Vector2; +} + +/** @internal */ +export interface DragAndDropPluginManager { + api: DragAndDropPluginAPI; + actions: { + addPlugins(list: DragAndDropPlugin[]): void; + clearPlugins(): void; + hookInit(ctx: DragAndDropPluginHookContext): void; + hookGrab(ctx: DragAndDropPluginHookContext): void; + hookMove(ctx: DragAndDropPluginHookContext): void; + hookDrop(ctx: DragAndDropPluginHookContext): void; + hookDestroy(ctx: DragAndDropPluginHookContext): void; + }; +} + +/** @internal */ +export interface DragAndDropObserverOptions { + strategy?: 'fixed' | 'absolute'; + onGrab?: DragAndDropEventHandler; + onMove?: DragAndDropEventHandler; + onDrop?: DragAndDropEventHandler; + plugins?: DragAndDropPlugin[]; +} + +/** @internal */ +export interface DragAndDropObserverContext { + element: HTMLElement; + getOffsets( + clientPosition: Vector2, + innerOffset?: Vector2, + ): { offset: Vector2; innerOffset: Vector2 }; +} diff --git a/src/react/drag-and-drop/use-drag-and-drop.ts b/src/react/drag-and-drop/use-drag-and-drop.ts new file mode 100644 index 0000000..7b63327 --- /dev/null +++ b/src/react/drag-and-drop/use-drag-and-drop.ts @@ -0,0 +1,67 @@ +import { type RefObject } from 'react'; +import { noop } from '../../mod.ts'; +import { zeroDeps } from '../constants.ts'; +import { useIsomorphicLayoutEffect } from '../use-isomorphic-layout-effect.ts'; +import { useLatestRef } from '../use-latest-ref.ts'; +import { useStableCallback } from '../use-stable-callback.ts'; +import { DragAndDropBuiltinPlugins } from './builtin-plugins.ts'; +import { DragAndDropObserver } from './observer.ts'; +import type { UseDragAndDropOptions } from './types.ts'; + +/** + * Hook of simple "drag and drop". + * @param ref Target element. + * @param options Options. + */ +export function useDragAndDrop( + ref: RefObject | RefObject | RefObject, + { + strategy, + disabled, + onGrab, + onMove, + onDrop, + plugins, + extraDeps = zeroDeps, + }: UseDragAndDropOptions = {}, +): void { + const pluginsRef = useLatestRef(plugins); + const handleGrab = useStableCallback(onGrab ?? noop); + const handleMove = useStableCallback(onMove ?? noop); + const handleDrop = useStableCallback(onDrop ?? noop); + + useIsomorphicLayoutEffect(() => { + const element = ref.current; + + if (disabled || !element) { + return; + } + + const observer = new DragAndDropObserver({ + strategy, + onGrab: handleGrab, + onMove: handleMove, + onDrop: handleDrop, + plugins: pluginsRef.current ?? [ + DragAndDropBuiltinPlugins.cleanSelection(), + DragAndDropBuiltinPlugins.preventClick(), + DragAndDropBuiltinPlugins.touchScroll(), + ], + }); + + return observer.observe(element); + }, [ + ref, + disabled, + strategy, + + // stable: + handleGrab, + handleMove, + handleDrop, + pluginsRef, + + // eslint-disable-next-line react-hooks/exhaustive-deps + ...extraDeps, + ]); +} diff --git a/src/react/mod.ts b/src/react/mod.ts index 2097f97..bb696b9 100644 --- a/src/react/mod.ts +++ b/src/react/mod.ts @@ -14,7 +14,6 @@ export * from './context/resize-observer-context.ts'; export * from './context/visual-viewport-context.ts'; // web api -export * from './use-drag-and-drop.ts'; export * from './use-exact-click.ts'; export * from './use-intersection.ts'; export * from './use-match-media.ts'; @@ -24,6 +23,21 @@ export * from './use-storage-item.ts'; export * from './use-transition-status.ts'; export * from './use-visual-viewport.ts'; +// drag-and-drop +export type { + UseDragAndDropOptions, + UseDragAndDropReturn, + DragAndDropEvent, + DragAndDropEventHandler, + DragAndDropPlugin, + DragAndDropPluginAPI, + DragAndDropPluginHook, + DragAndDropPluginHookContext, + DragAndDropState, +} from './drag-and-drop/types.ts'; +export { useDragAndDrop } from './drag-and-drop/use-drag-and-drop.ts'; +export { DragAndDropBuiltinPlugins } from './drag-and-drop/builtin-plugins.ts'; + // merging refs export * from './merge-refs.ts'; export * from './use-merge-refs.ts'; diff --git a/src/react/use-drag-and-drop.ts b/src/react/use-drag-and-drop.ts deleted file mode 100644 index 3631a6e..0000000 --- a/src/react/use-drag-and-drop.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { type DependencyList, type RefObject, useMemo } from 'react'; -import { getPositionedParentOffset } from '../dom/mod.ts'; -import { type Point2d, Vector2 } from '../math/mod.ts'; -import { useIsomorphicLayoutEffect } from '../react/use-isomorphic-layout-effect.ts'; -import { useStableCallback } from '../react/use-stable-callback.ts'; -import { zeroDeps } from './constants.ts'; - -export interface DnDEventHandler { - (event: { target: HTMLElement; offset: Point2d; clientPosition: Point2d }): void; -} - -export interface UseDragAndDropOptions { - /** Positioning strategy. Should be same that css `position` property of target element. */ - strategy?: 'fixed' | 'absolute'; - - /** When true, Drag-And-Drop will be disabled. */ - disabled?: boolean; - - /** Will be called when element is grabbed. */ - onGrab?: DnDEventHandler; - - /** Will be called when element is dragging. */ - onMove?: DnDEventHandler; - - /** Will be called when element is dropped. */ - onDrop?: DnDEventHandler; - - /** Will be called on `pointerdown` event, if false returns than drag will no be started. */ - needStartDrag?: (event: PointerEvent) => boolean; - - /** Extra deps for useEffect hook. */ - extraDeps?: DependencyList; -} - -export interface UseDragAndDropReturn { - captured: boolean; - offset: Point2d; -} - -/** - * Initial state factory for useDragAndDrop hook. - * @returns State. - */ -function getInitialState() { - return { - pointerId: -1, - captured: false, - offset: Vector2.of(0, 0), - innerOffset: Vector2.of(0, 0), - }; -} - -/** - * Default value for `needPreventTouchEvent` option of `UseDragAndDropOptions`. - * @param event Event. - * @returns Boolean. - */ -function canStartDragDefault(event: TouchEvent | PointerEvent | MouseEvent): boolean { - if ( - event.target instanceof Element && - (event.target.tagName === 'BUTTON' || - event.target.tagName === 'INPUT' || - event.target.tagName === 'OPTION' || - event.target.tagName === 'SELECT' || - event.target.tagName === 'TEXTAREA') - ) { - // @todo что если внутри кнопки svg? - return false; - } - - return true; -} - -/** - * Hook of simple "drag and drop". - * @param ref Target element. - * @param options Options. - */ -export function useDragAndDrop( - ref: RefObject | RefObject | RefObject, - { - strategy = 'absolute', - disabled, - onGrab, - onMove, - onDrop, - extraDeps = zeroDeps, - needStartDrag = canStartDragDefault, - }: UseDragAndDropOptions = {}, -): void { - const state = useMemo(getInitialState, zeroDeps); - - const onPointerDown = useStableCallback((event: PointerEvent) => { - if (state.captured) { - return; - } - - const element = ref.current; - - if (!element) { - return; - } - - if (!needStartDrag(event)) { - return; - } - - // @todo ability to disable - element.releasePointerCapture(event.pointerId); - - // @todo ability to disable - document.getSelection()?.removeAllRanges(); - - const clientPosition = Vector2.of(event.clientX, event.clientY); - const newInnerOffset = clientPosition.clone().subtract(element.getBoundingClientRect()); - const newOffset = clientPosition - .clone() - .subtract(newInnerOffset) - .subtract(getPositionedParentOffset(element, { strategy })); - - state.pointerId = event.pointerId; - state.captured = true; - state.offset = newOffset; - state.innerOffset = newInnerOffset; - - onGrab?.({ - target: element, - offset: newOffset.clone(), - clientPosition, - }); - }); - - const onPointerMove = useStableCallback((event: PointerEvent) => { - if (!state.captured) { - return; - } - - if (state.pointerId !== event.pointerId) { - return; - } - - const element = ref.current; - - if (!element) { - return; - } - - const clientPosition = Vector2.of(event.clientX, event.clientY); - const newOffset = clientPosition - .clone() - .subtract(state.innerOffset) - .subtract(getPositionedParentOffset(element)); - - state.offset = newOffset; - - onMove?.({ - target: element, - offset: newOffset.clone(), - clientPosition, - }); - }); - - const onPointerUp = useStableCallback((event: PointerEvent) => { - if (!state.captured) { - return; - } - - if (state.pointerId !== event.pointerId) { - return; - } - - const element = ref.current; - - if (!element) { - return; - } - - const clientPosition = Vector2.of(event.clientX, event.clientY); - - onDrop?.({ - target: element, - offset: state.offset.clone(), - clientPosition, - }); - - state.pointerId = -1; - state.captured = false; - }); - - // handle touchend because scrolling inside target mutes pointer events - const onTouchEnd = useStableCallback((event: TouchEvent) => { - const element = ref.current; - - if (!element) { - return; - } - - if (!(event.target instanceof Node && ref.current?.contains(event.target))) { - return; - } - - const clientPosition = Vector2.of(event.touches[0]?.clientX ?? 0, event.touches[0]?.clientY); - - onDrop?.({ - target: element, - offset: state.offset.clone(), - clientPosition, - }); - - state.pointerId = -1; - state.captured = false; - }); - - useIsomorphicLayoutEffect(() => { - const element = ref.current; - - if (disabled || !element) { - return; - } - - element.addEventListener('pointerdown', onPointerDown); - window.addEventListener('pointermove', onPointerMove); - window.addEventListener('pointerup', onPointerUp); - window.addEventListener('touchend', onTouchEnd, true); - - return () => { - element.removeEventListener('pointerdown', onPointerDown); - window.removeEventListener('pointermove', onPointerMove); - window.removeEventListener('pointerup', onPointerUp); - window.removeEventListener('touchend', onTouchEnd, true); - }; - }, [ - ref, - disabled, - onPointerDown, - onPointerMove, - onPointerUp, - onTouchEnd, - - // eslint-disable-next-line react-hooks/exhaustive-deps - ...extraDeps, - ]); -} diff --git a/tests-e2e/stories/use-drag-and-drop/primary.m.css b/tests-e2e/stories/use-drag-and-drop/primary.m.css index d95b6f8..709375c 100644 --- a/tests-e2e/stories/use-drag-and-drop/primary.m.css +++ b/tests-e2e/stories/use-drag-and-drop/primary.m.css @@ -7,8 +7,11 @@ height: 100px; background: #1abc9c; color: #fff; - border-radius: 12px; + border-radius: 4px; display: flex; + flex-direction: column; + gap: 12px; + padding: 12px; align-items: center; justify-content: center; transition: transform 120ms; @@ -20,5 +23,32 @@ .draggable:active { cursor: grabbing; - transform: scale(1.06); +} + +.actions { + width: 100%; + display: flex; + gap: 8px; + align-items: center; + justify-content: space-around; +} + +.actions > a { + color: inherit; + text-decoration: none; +} + +.actions > a:hover { + background-color: rgba(255, 255, 255, 0.2); +} + +.actions > a, +.actions > button { + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + min-width: 72px; + height: 36px; + padding: 0 12px; } diff --git a/tests-e2e/stories/use-drag-and-drop/primary.story.tsx b/tests-e2e/stories/use-drag-and-drop/primary.story.tsx index 6802557..1256d72 100644 --- a/tests-e2e/stories/use-drag-and-drop/primary.story.tsx +++ b/tests-e2e/stories/use-drag-and-drop/primary.story.tsx @@ -9,23 +9,20 @@ export const meta = { export default function Example() { const ref = useRef(null); - const [grabbed, setGrabbed] = useState(false); + const [moved, setMoved] = useState(false); const [offset, setOffset] = useState({ x: 0, y: 0 }); useDragAndDrop(ref, { onGrab(event) { - setGrabbed(true); setOffset(event.offset); }, onMove(event) { + setMoved(true); setOffset(event.offset); }, - onDrop() { - setGrabbed(false); - }, }); - const style: CSSProperties = grabbed + const style: CSSProperties = moved ? { position: 'absolute', left: offset.x, @@ -35,8 +32,14 @@ export default function Example() { return (
-
+
This block is draggable +
+ + + Link + +
); diff --git a/tests-e2e/stories/use-drag-and-drop/scrollable.m.css b/tests-e2e/stories/use-drag-and-drop/scrollable.m.css new file mode 100644 index 0000000..07c9d41 --- /dev/null +++ b/tests-e2e/stories/use-drag-and-drop/scrollable.m.css @@ -0,0 +1,32 @@ +.container { + height: 200vh; +} + +.draggable { + width: 240px; + height: 320px; + background: #1abc9c; + color: #fff; + border-radius: 12px; + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px; + transition: transform 120ms; +} + +.scrollable { + border: 1px solid #000; + border-radius: 8px; + padding: 12px; + background: rgba(0, 0, 0, 0.2); + overflow-y: scroll; +} + +.draggable:hover { + cursor: grab; +} + +.draggable:active { + cursor: grabbing; +} diff --git a/tests-e2e/stories/use-drag-and-drop/scrollable.story.tsx b/tests-e2e/stories/use-drag-and-drop/scrollable.story.tsx new file mode 100644 index 0000000..5ce8d21 --- /dev/null +++ b/tests-e2e/stories/use-drag-and-drop/scrollable.story.tsx @@ -0,0 +1,105 @@ +import { CSSProperties, useRef, useState } from 'react'; +import { findClosest, isScrollable } from '@krutoo/utils'; +import { DragAndDropEvent, useDragAndDrop } from '@krutoo/utils/react'; +import styles from './scrollable.m.css'; + +export const meta = { + category: 'React hooks/useDragAndDrop', + title: 'Scrollable content', +}; + +/** + * Returns true when element vertically scrolled to top or bottom edge. + * @param element Element. + * @returns Boolean. + */ +export function isScrolledToBoundsY(element: Element): boolean { + return !( + element.scrollTop > 0 && element.scrollTop + element.clientHeight < element.scrollHeight + ); +} + +export default function Example() { + const ref = useRef(null); + const [moved, setMoved] = useState(false); + const [offset, setOffset] = useState({ x: 0, y: 0 }); + + const needPreventGrab = (event: DragAndDropEvent): boolean => { + if (event.nativeEvent.target instanceof Element) { + const scrollableChild = findClosest(event.nativeEvent.target, isScrollable, { + needBreakLoop: el => el === event.target, + }); + + if (scrollableChild) { + return event.nativeEvent.pointerType === 'touch'; + } + } + + return false; + }; + + useDragAndDrop(ref, { + onGrab(event) { + if (needPreventGrab(event)) { + return event.preventDefault(); + } + + setMoved(true); + setOffset(event.offset); + }, + onMove(event) { + setMoved(true); + setOffset(event.offset); + }, + onDrop() {}, + }); + + const style: CSSProperties = moved + ? { + position: 'absolute', + left: offset.x, + top: offset.y, + } + : {}; + + return ( +
+
+ ⠿ This block is draggable +
+ +
+
+
+ ); +} + +function SomeArticle() { + return ( + <> +

Lorem ipsum, dolor sit amet consectetur adipisicing elit. Ab eius voluptas debitis!

+

+ At architecto saepe ducimus cum error repellendus illo, libero impedit vero praesentium? +

+

+ Sapiente, assumenda! Incidunt aspernatur a sint cupiditate porro, id inventore + exercitationem repudiandae. +

+

Asperiores autem ad dolore minus eaque enim tenetur perferendis quam. Explicabo, rerum?

+

+ Beatae odit consequatur sapiente cupiditate facere dolorum obcaecati ipsum eos eligendi at. +

+

Iste suscipit rerum minus, iure natus harum eius tenetur fugit aspernatur earum?

+

+ Corrupti dolorem cupiditate nesciunt cum aspernatur dolores fugiat nostrum aperiam neque + reiciendis! +

+

+ Nobis maxime incidunt adipisci fugiat perferendis placeat explicabo tempora expedita eos + officia. +

+

Dicta, beatae. Nisi recusandae qui ratione doloribus minima vitae accusantium ut eius?

+

Quo consequatur culpa vitae facere illo aliquam temporibus a similique tenetur! Vitae.

+ + ); +} diff --git a/tests-e2e/tests/use-drag-and-drop.spec.ts b/tests-e2e/tests/use-drag-and-drop.spec.ts new file mode 100644 index 0000000..cc919a5 --- /dev/null +++ b/tests-e2e/tests/use-drag-and-drop.spec.ts @@ -0,0 +1,28 @@ +import { expect, test } from '@playwright/test'; + +test('useDragAndDrop', async ({ page }) => { + await page.goto('/sandbox.html?path=/use-drag-and-drop/primary'); + + const draggable = page.getByTestId('draggable'); + + const start = { x: 16, y: 16 }; + const grab = { x: 20, y: 20 }; + const shift = { x: 80, y: 120 }; + + expect(await draggable.count()).toBe(1); + expect(await draggable.boundingBox()).toHaveProperty('x', start.x); + expect(await draggable.boundingBox()).toHaveProperty('y', start.y); + + await page.mouse.move(grab.x, grab.y); + await page.mouse.down(); + await page.mouse.move(grab.x + shift.x, grab.y + shift.y); + await page.mouse.up(); + + expect(await draggable.boundingBox()).toHaveProperty('x', start.x + shift.x); + expect(await draggable.boundingBox()).toHaveProperty('y', start.y + shift.y); + + await page.mouse.move(0, 0); + + expect(await draggable.boundingBox()).toHaveProperty('x', start.x + shift.x); + expect(await draggable.boundingBox()).toHaveProperty('y', start.y + shift.y); +}); diff --git a/tests-e2e/tsconfig.json b/tests-e2e/tsconfig.json index ce4f850..158a1db 100644 --- a/tests-e2e/tsconfig.json +++ b/tests-e2e/tsconfig.json @@ -16,10 +16,12 @@ "allowImportingTsExtensions": true, "paths": { "@krutoo/utils": ["../src/mod.ts"], + "@krutoo/utils/di": ["../src/di/mod.ts"], "@krutoo/utils/dom": ["../src/dom/mod.ts"], "@krutoo/utils/math": ["../src/math/mod.ts"], "@krutoo/utils/misc": ["../src/misc/mod.ts"], "@krutoo/utils/react": ["../src/react/mod.ts"], + "@krutoo/utils/router": ["../src/router/mod.ts"], "@krutoo/utils/rspack": ["../src/rspack/mod.ts"], "@krutoo/utils/types": ["../src/types/mod.ts"] }