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"]
}