Files
tools/vue/toolkit/src/composables/elements/useDraggable/index.ts
T
robonen aa2938cb34 refactor(toolkit): type source any with proper types
Genuinely type composable any usages (useStepper/useStorage/useForm/
createEventHook/useSorted/etc.) as proper generics/unknown; keep idiomatic
any-function and overload-impl signatures with comments; skipped test -> .todo.
2026-06-15 16:55:07 +07:00

324 lines
8.8 KiB
TypeScript

import { computed, shallowRef, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue';
import { clamp, noop } from '@robonen/stdlib';
import { defaultWindow } from '@/types';
import { useEventListener } from '@/composables/browser/useEventListener';
import { unrefElement } from '@/composables/component/unrefElement';
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
export type UseDraggableAxis = 'x' | 'y' | 'both';
export type UseDraggablePointerType = 'mouse' | 'touch' | 'pen';
export interface Position {
x: number;
y: number;
}
export interface UseDraggableOptions {
/**
* Initial position of the draggable element.
*
* @default { x: 0, y: 0 }
*/
initialValue?: MaybeRefOrGetter<Position>;
/**
* Axis along which dragging is allowed.
*
* @default 'both'
*/
axis?: UseDraggableAxis;
/**
* Element that initiates the drag. Defaults to the dragged `target` itself.
* Accepts an element ref, getter, or component instance.
*
* @default target
*/
handle?: MaybeComputedElementRef;
/**
* Element whose bounds constrain the dragged element. When set, the position
* is clamped so the element cannot be dragged outside of it.
*
* @default undefined
*/
containerElement?: MaybeComputedElementRef;
/**
* Element on which the `pointermove` / `pointerup` listeners are attached.
* Defaults to `window` so dragging keeps working when the pointer leaves the
* element.
*
* @default window
*/
draggingElement?: MaybeComputedElementRef | Window | Document;
/**
* Only start dragging when the pointer goes down on the `handle` itself, not
* on one of its descendants.
*
* @default false
*/
exact?: MaybeRefOrGetter<boolean>;
/**
* Pointer types that are allowed to start a drag.
*
* @default ['mouse', 'touch', 'pen']
*/
pointerTypes?: UseDraggablePointerType[];
/**
* Pointer buttons that are allowed to start a drag (`0` = primary/left).
*
* @default [0]
*/
buttons?: MaybeRefOrGetter<number[]>;
/**
* Call `preventDefault` on the pointer events. When `false` listeners are
* attached passively for better scroll performance.
*
* @default false
*/
preventDefault?: MaybeRefOrGetter<boolean>;
/**
* Call `stopPropagation` on the pointer events.
*
* @default false
*/
stopPropagation?: MaybeRefOrGetter<boolean>;
/**
* Use event capture when attaching the listeners.
*
* @default true
*/
capture?: boolean;
/**
* Disable dragging entirely. May be reactive to toggle at runtime.
*
* @default false
*/
disabled?: MaybeRefOrGetter<boolean>;
/**
* Invoked when a drag starts. Return `false` to cancel the drag.
*/
onStart?: (position: Position, event: PointerEvent) => void | false;
/**
* Invoked on every pointer move while dragging.
*/
onMove?: (position: Position, event: PointerEvent) => void;
/**
* Invoked when the drag ends.
*/
onEnd?: (position: Position, event: PointerEvent) => void;
}
export interface UseDraggableReturn {
/**
* Current x position.
*/
x: Ref<number>;
/**
* Current y position.
*/
y: Ref<number>;
/**
* Current position as a `{ x, y }` object.
*/
position: Ref<Position>;
/**
* Whether a drag is currently in progress.
*/
isDragging: ComputedRef<boolean>;
/**
* Ready-to-bind inline `style` string positioning the element.
*/
style: ComputedRef<string>;
}
/**
* @name useDraggable
* @category Elements
* @description Make an element draggable by pointer, tracking its position with
* optional axis locking, a drag handle, container constraints, and lifecycle
* callbacks. SSR-safe and built on passive pointer listeners.
*
* @param {MaybeComputedElementRef} target - The element to make draggable
* @param {UseDraggableOptions} [options={}] - Options
* @returns {UseDraggableReturn} Reactive `x`, `y`, `position`, `isDragging`, and a `style` string
*
* @example
* const el = useTemplateRef<HTMLElement>('el');
* const { x, y, style } = useDraggable(el, { initialValue: { x: 40, y: 40 } });
*
* @example
* // Lock to the horizontal axis and only drag from a handle.
* const { position } = useDraggable(el, { axis: 'x', handle: handleEl });
*
* @since 0.0.15
*/
export function useDraggable(
target: MaybeComputedElementRef,
options: UseDraggableOptions = {},
): UseDraggableReturn {
const {
initialValue,
axis = 'both',
handle = target,
containerElement,
draggingElement = defaultWindow,
exact,
pointerTypes,
buttons = [0],
preventDefault,
stopPropagation,
capture = true,
disabled,
onStart = noop,
onMove = noop,
onEnd = noop,
} = options;
const position = shallowRef<Position>(toValue(initialValue) ?? { x: 0, y: 0 });
// Offset from the pointer to the element's top-left at drag start.
// `null` means we are not dragging.
const pressedDelta = shallowRef<Position | null>(null);
const filterEvent = (event: PointerEvent): boolean => {
if (pointerTypes)
return pointerTypes.includes(event.pointerType as UseDraggablePointerType);
return true;
};
const handleEvent = (event: PointerEvent): void => {
if (toValue(preventDefault))
event.preventDefault();
if (toValue(stopPropagation))
event.stopPropagation();
};
const start = (event: PointerEvent): void => {
if (toValue(disabled))
return;
if (!toValue(buttons).includes(event.button))
return;
if (!filterEvent(event))
return;
const el = unrefElement(target) as HTMLElement | SVGElement | null | undefined;
if (toValue(exact) && event.target !== el)
return;
const container = unrefElement(containerElement) as HTMLElement | SVGElement | null | undefined;
const containerRect = container?.getBoundingClientRect();
const targetRect = el?.getBoundingClientRect();
if (!targetRect)
return;
const pos: Position = {
x: event.clientX - (container ? targetRect.left - containerRect!.left + container.scrollLeft : targetRect.left),
y: event.clientY - (container ? targetRect.top - containerRect!.top + container.scrollTop : targetRect.top),
};
if (onStart(pos, event) === false)
return;
pressedDelta.value = pos;
handleEvent(event);
};
const move = (event: PointerEvent): void => {
if (toValue(disabled))
return;
if (!pressedDelta.value)
return;
if (!filterEvent(event))
return;
const el = unrefElement(target) as HTMLElement | SVGElement | null | undefined;
const container = unrefElement(containerElement) as HTMLElement | SVGElement | null | undefined;
const targetRect = el?.getBoundingClientRect();
let { x, y } = position.value;
if (axis === 'x' || axis === 'both') {
x = event.clientX - pressedDelta.value.x;
if (container && targetRect)
x = clamp(x, 0, container.scrollWidth - targetRect.width);
}
if (axis === 'y' || axis === 'both') {
y = event.clientY - pressedDelta.value.y;
if (container && targetRect)
y = clamp(y, 0, container.scrollHeight - targetRect.height);
}
position.value = { x, y };
onMove(position.value, event);
handleEvent(event);
};
const end = (event: PointerEvent): void => {
if (toValue(disabled))
return;
if (!pressedDelta.value)
return;
if (!filterEvent(event))
return;
pressedDelta.value = null;
onEnd(position.value, event);
handleEvent(event);
};
if (defaultWindow) {
const config = (): AddEventListenerOptions => ({
capture,
passive: !toValue(preventDefault),
});
// `MaybeComputedElementRef` includes `VueInstance`, which isn't an `EventTarget`;
// these targets always resolve to a real element/window at runtime (useEventListener
// unwraps via unrefElement), so cast to the EventTarget overload.
const asEventTarget = (value: unknown): MaybeRefOrGetter<EventTarget | null | undefined> =>
value as MaybeRefOrGetter<EventTarget | null | undefined>;
useEventListener(asEventTarget(handle), 'pointerdown', start as (e: Event) => void, config);
useEventListener(asEventTarget(draggingElement), 'pointermove', move as (e: Event) => void, config);
useEventListener(asEventTarget(draggingElement), 'pointerup', end as (e: Event) => void, config);
}
const x = computed<number>({
get: () => position.value.x,
set: value => (position.value = { x: value, y: position.value.y }),
});
const y = computed<number>({
get: () => position.value.y,
set: value => (position.value = { x: position.value.x, y: value }),
});
return {
x,
y,
position,
isDragging: computed(() => !!pressedDelta.value),
style: computed(() => `left:${position.value.x}px;top:${position.value.y}px;`),
};
}