mirror of
https://github.com/LukeHagar/svelte-interactions.git
synced 2025-12-06 04:21:32 +00:00
feat: move (#5)
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
"$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json",
|
"$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json",
|
||||||
"changelog": [
|
"changelog": [
|
||||||
"@svitejs/changesets-changelog-github-compact",
|
"@svitejs/changesets-changelog-github-compact",
|
||||||
{ "repo": "huntabyte/svelte-interactions" }
|
{ "repo": "svecosystem/svelte-interactions" }
|
||||||
],
|
],
|
||||||
"commit": false,
|
"commit": false,
|
||||||
"fixed": [],
|
"fixed": [],
|
||||||
|
|||||||
5
.changeset/mean-cats-jam.md
Normal file
5
.changeset/mean-cats-jam.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"svelte-interactions": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
feat: `move`
|
||||||
329
src/lib/interactions/move/create.ts
Normal file
329
src/lib/interactions/move/create.ts
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
import type { PointerType } from '$lib/types/events.js';
|
||||||
|
import type { ActionReturn } from 'svelte/action';
|
||||||
|
import type {
|
||||||
|
MoveMoveEvent,
|
||||||
|
MoveStartEvent,
|
||||||
|
MoveEndEvent,
|
||||||
|
MoveHandlers,
|
||||||
|
MoveEvent
|
||||||
|
} from './events.js';
|
||||||
|
import { createGlobalListeners } from '$lib/utils/globalListeners.js';
|
||||||
|
import { get, writable } from 'svelte/store';
|
||||||
|
import { disableTextSelection, restoreTextSelection } from '$lib/utils/textSelection.js';
|
||||||
|
import { executeCallbacks, noop } from '$lib/utils/callbacks.js';
|
||||||
|
import { addEventListener } from '$lib/utils/addEventListener.js';
|
||||||
|
|
||||||
|
interface EventBase {
|
||||||
|
shiftKey: boolean;
|
||||||
|
ctrlKey: boolean;
|
||||||
|
metaKey: boolean;
|
||||||
|
altKey: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MoveActionReturn = ActionReturn<
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
'on:move'?: (e: CustomEvent<MoveMoveEvent>) => void;
|
||||||
|
'on:movestart'?: (e: CustomEvent<MoveStartEvent>) => void;
|
||||||
|
'on:moveend'?: (e: CustomEvent<MoveEndEvent>) => void;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type MoveConfig = MoveHandlers;
|
||||||
|
|
||||||
|
export type MoveResult = {
|
||||||
|
/**
|
||||||
|
* A Svelte action which handles applying the event listeners
|
||||||
|
* and dispatching events to the element
|
||||||
|
*/
|
||||||
|
moveAction: (node: HTMLElement | SVGElement) => MoveActionReturn;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles move interactions across mouse, touch, and keyboard, including dragging with
|
||||||
|
* the mouse or touch, and using the arrow keys. Normalizes behavior across browsers and
|
||||||
|
* platforms, and ignores emulated mouse events on touch devices.
|
||||||
|
*/
|
||||||
|
export function createMove(config?: MoveConfig): MoveResult {
|
||||||
|
const defaults = {
|
||||||
|
onMove: undefined,
|
||||||
|
onMoveStart: undefined,
|
||||||
|
onMoveEnd: undefined
|
||||||
|
};
|
||||||
|
const { onMove, onMoveStart, onMoveEnd } = { ...defaults, ...config };
|
||||||
|
|
||||||
|
type MoveState = {
|
||||||
|
didMove: boolean;
|
||||||
|
lastPosition: { pageX: number; pageY: number } | null;
|
||||||
|
id: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let nodeEl: HTMLElement | SVGElement | null = null;
|
||||||
|
|
||||||
|
const state = writable<MoveState>({
|
||||||
|
didMove: false,
|
||||||
|
lastPosition: null,
|
||||||
|
id: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const { addGlobalListener, removeGlobalListener } = createGlobalListeners();
|
||||||
|
|
||||||
|
function dispatchMoveEvent(moveEvent: MoveEvent) {
|
||||||
|
nodeEl?.dispatchEvent(new CustomEvent<MoveEvent>(moveEvent.type, { detail: moveEvent }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(
|
||||||
|
originalEvent: EventBase,
|
||||||
|
pointerType: PointerType,
|
||||||
|
deltaX: number,
|
||||||
|
deltaY: number
|
||||||
|
) {
|
||||||
|
if (deltaX === 0 && deltaY === 0) return;
|
||||||
|
|
||||||
|
const $state = get(state);
|
||||||
|
|
||||||
|
if (!$state.didMove) {
|
||||||
|
state.update((curr) => ({ ...curr, didMove: true }));
|
||||||
|
|
||||||
|
const moveStartEvent = {
|
||||||
|
type: 'movestart' as const,
|
||||||
|
pointerType,
|
||||||
|
shiftKey: originalEvent.shiftKey,
|
||||||
|
ctrlKey: originalEvent.ctrlKey,
|
||||||
|
metaKey: originalEvent.metaKey,
|
||||||
|
altKey: originalEvent.altKey
|
||||||
|
};
|
||||||
|
|
||||||
|
if (onMoveStart) {
|
||||||
|
onMoveStart(moveStartEvent);
|
||||||
|
} else {
|
||||||
|
// dispatch move start event
|
||||||
|
dispatchMoveEvent(moveStartEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const moveEvent = {
|
||||||
|
type: 'move' as const,
|
||||||
|
pointerType,
|
||||||
|
deltaX: deltaX,
|
||||||
|
deltaY: deltaY,
|
||||||
|
shiftKey: originalEvent.shiftKey,
|
||||||
|
ctrlKey: originalEvent.ctrlKey,
|
||||||
|
metaKey: originalEvent.metaKey,
|
||||||
|
altKey: originalEvent.altKey
|
||||||
|
};
|
||||||
|
if (onMove) {
|
||||||
|
onMove(moveEvent);
|
||||||
|
} else {
|
||||||
|
dispatchMoveEvent(moveEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function end(originalEvent: EventBase, pointerType: PointerType) {
|
||||||
|
restoreTextSelection();
|
||||||
|
if (get(state).didMove) {
|
||||||
|
const moveEndEvent = {
|
||||||
|
type: 'moveend' as const,
|
||||||
|
pointerType,
|
||||||
|
shiftKey: originalEvent.shiftKey,
|
||||||
|
ctrlKey: originalEvent.ctrlKey,
|
||||||
|
metaKey: originalEvent.metaKey,
|
||||||
|
altKey: originalEvent.altKey
|
||||||
|
};
|
||||||
|
if (onMoveEnd) {
|
||||||
|
onMoveEnd(moveEndEvent);
|
||||||
|
} else {
|
||||||
|
dispatchMoveEvent(moveEndEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
disableTextSelection();
|
||||||
|
state.update((curr) => ({ ...curr, didMove: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMouseAndTouchHandlers() {
|
||||||
|
function globalMouseMove(e: MouseEvent) {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
const $state = get(state);
|
||||||
|
const deltaX = e.pageX - ($state.lastPosition?.pageX ?? 0);
|
||||||
|
const deltaY = e.pageY - ($state.lastPosition?.pageY ?? 0);
|
||||||
|
move(e, 'mouse', deltaX, deltaY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function globalMouseUp(e: MouseEvent) {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
end(e, 'mouse');
|
||||||
|
removeGlobalListener(window, 'mousemove', globalMouseMove, false);
|
||||||
|
removeGlobalListener(window, 'mouseup', globalMouseUp, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseDown(e: MouseEvent) {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
start();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
state.update((curr) => ({ ...curr, lastPosition: { pageX: e.pageX, pageY: e.pageY } }));
|
||||||
|
addGlobalListener(window, 'mousemove', globalMouseMove, false);
|
||||||
|
addGlobalListener(window, 'mouseup', globalMouseUp, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function globalTouchMove(e: TouchEvent) {
|
||||||
|
const $state = get(state);
|
||||||
|
const touch = getTouchIndex(e, $state.id);
|
||||||
|
if (touch < 0) return;
|
||||||
|
const { pageX, pageY } = e.changedTouches[touch];
|
||||||
|
const deltaX = pageX - ($state.lastPosition?.pageX ?? 0);
|
||||||
|
const deltaY = pageY - ($state.lastPosition?.pageY ?? 0);
|
||||||
|
move(e, 'touch', deltaX, deltaY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function globalTouchEnd(e: TouchEvent) {
|
||||||
|
const $state = get(state);
|
||||||
|
const touch = getTouchIndex(e, $state.id);
|
||||||
|
if (touch < 0) return;
|
||||||
|
end(e, 'touch');
|
||||||
|
state.update((curr) => ({ ...curr, id: null }));
|
||||||
|
removeGlobalListener(window, 'touchmove', globalTouchMove, false);
|
||||||
|
removeGlobalListener(window, 'touchend', globalTouchEnd, false);
|
||||||
|
removeGlobalListener(window, 'touchcancel', globalTouchEnd, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchStart(e: TouchEvent) {
|
||||||
|
const $state = get(state);
|
||||||
|
if (e.changedTouches.length === 0 || $state.id !== null) return;
|
||||||
|
|
||||||
|
const { pageX, pageY, identifier } = e.changedTouches[0];
|
||||||
|
start();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
state.update((curr) => ({ ...curr, lastPosition: { pageX, pageY }, id: identifier }));
|
||||||
|
|
||||||
|
addGlobalListener(window, 'touchmove', globalTouchMove, false);
|
||||||
|
addGlobalListener(window, 'touchend', globalTouchEnd, false);
|
||||||
|
addGlobalListener(window, 'touchcancel', globalTouchEnd, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
onMouseDown,
|
||||||
|
onTouchStart
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPointerHandlers() {
|
||||||
|
function globalPointerMove(e: PointerEvent) {
|
||||||
|
const $state = get(state);
|
||||||
|
if (e.pointerId !== $state.id) return;
|
||||||
|
const pointerType = (e.pointerType || 'mouse') as PointerType;
|
||||||
|
|
||||||
|
// Problems with PointerEvent#movementX/movementY:
|
||||||
|
// 1. it is always 0 on macOS Safari.
|
||||||
|
// 2. On Chrome Android, it's scaled by devicePixelRatio, but not on Chrome macOS
|
||||||
|
const deltaX = e.pageX - ($state.lastPosition?.pageX ?? 0);
|
||||||
|
const deltaY = e.pageY - ($state.lastPosition?.pageY ?? 0);
|
||||||
|
move(e, pointerType, deltaX, deltaY);
|
||||||
|
state.update((curr) => ({ ...curr, lastPosition: { pageX: e.pageX, pageY: e.pageY } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function globalPointerUp(e: PointerEvent) {
|
||||||
|
const $state = get(state);
|
||||||
|
if (e.pointerId !== $state.id) return;
|
||||||
|
const pointerType = (e.pointerType || 'mouse') as PointerType;
|
||||||
|
end(e, pointerType);
|
||||||
|
state.update((curr) => ({ ...curr, id: null }));
|
||||||
|
removeGlobalListener(window, 'pointermove', globalPointerMove, false);
|
||||||
|
removeGlobalListener(window, 'pointerup', globalPointerUp, false);
|
||||||
|
removeGlobalListener(window, 'pointercancel', globalPointerUp, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
const $state = get(state);
|
||||||
|
if (e.button !== 0 || $state.id !== null) return;
|
||||||
|
start();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
state.update((curr) => ({
|
||||||
|
...curr,
|
||||||
|
lastPosition: { pageX: e.pageX, pageY: e.pageY },
|
||||||
|
id: e.pointerId
|
||||||
|
}));
|
||||||
|
addGlobalListener(window, 'pointermove', globalPointerMove, false);
|
||||||
|
addGlobalListener(window, 'pointerup', globalPointerUp, false);
|
||||||
|
addGlobalListener(window, 'pointercancel', globalPointerUp, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
onPointerDown
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getKeyboardHandlers() {
|
||||||
|
function triggerKeyboardMove(e: KeyboardEvent, deltaX: number, deltaY: number) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
start();
|
||||||
|
move(e, 'keyboard', deltaX, deltaY);
|
||||||
|
end(e, 'keyboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
switch (e.key) {
|
||||||
|
case 'Left':
|
||||||
|
case 'ArrowLeft':
|
||||||
|
triggerKeyboardMove(e, -1, 0);
|
||||||
|
break;
|
||||||
|
case 'Right':
|
||||||
|
case 'ArrowRight':
|
||||||
|
triggerKeyboardMove(e, 1, 0);
|
||||||
|
break;
|
||||||
|
case 'Up':
|
||||||
|
case 'ArrowUp':
|
||||||
|
triggerKeyboardMove(e, 0, -1);
|
||||||
|
break;
|
||||||
|
case 'Down':
|
||||||
|
case 'ArrowDown':
|
||||||
|
triggerKeyboardMove(e, 0, 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
onKeyDown
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveAction(node: HTMLElement | SVGElement) {
|
||||||
|
nodeEl = node;
|
||||||
|
const keyboardHandlers = getKeyboardHandlers();
|
||||||
|
const unsubKeyboardHandlers = executeCallbacks(
|
||||||
|
addEventListener(node, 'keydown', keyboardHandlers.onKeyDown)
|
||||||
|
);
|
||||||
|
|
||||||
|
let unsubHandlers = noop;
|
||||||
|
|
||||||
|
if (typeof PointerEvent !== 'undefined') {
|
||||||
|
const handlers = getPointerHandlers();
|
||||||
|
unsubHandlers = executeCallbacks(
|
||||||
|
addEventListener(node, 'pointerdown', handlers.onPointerDown)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const handlers = getMouseAndTouchHandlers();
|
||||||
|
unsubHandlers = executeCallbacks(
|
||||||
|
addEventListener(node, 'mousedown', handlers.onMouseDown),
|
||||||
|
addEventListener(node, 'touchstart', handlers.onTouchStart)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
unsubHandlers();
|
||||||
|
unsubKeyboardHandlers();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { moveAction };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTouchIndex(e: TouchEvent, id: number | null) {
|
||||||
|
return [...e.changedTouches].findIndex(({ identifier }) => identifier === id);
|
||||||
|
}
|
||||||
58
src/lib/interactions/move/events.ts
Normal file
58
src/lib/interactions/move/events.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import type { PointerType } from '$lib/types/events.js';
|
||||||
|
|
||||||
|
export interface BaseMoveEvent {
|
||||||
|
/** The pointer type that triggered the move event. */
|
||||||
|
pointerType: PointerType;
|
||||||
|
|
||||||
|
/** Whether the shift keyboard modifier was held during the move event. */
|
||||||
|
shiftKey: boolean;
|
||||||
|
|
||||||
|
/** Whether the ctrl keyboard modifier was held during the move event. */
|
||||||
|
ctrlKey: boolean;
|
||||||
|
|
||||||
|
/** Whether the meta keyboard modifier was held during the move event. */
|
||||||
|
metaKey: boolean;
|
||||||
|
|
||||||
|
/** Whether the alt keyboard modifier was held during the move event. */
|
||||||
|
altKey: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MoveStartEvent extends BaseMoveEvent {
|
||||||
|
/** The type of move event being fired. */
|
||||||
|
type: 'movestart';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MoveMoveEvent extends BaseMoveEvent {
|
||||||
|
/** The type of move event being fired. */
|
||||||
|
type: 'move';
|
||||||
|
|
||||||
|
/** The amount moved in the X direction since the last event. */
|
||||||
|
deltaX: number;
|
||||||
|
|
||||||
|
/** The amount moved in the Y direction since the last event. */
|
||||||
|
deltaY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MoveEndEvent extends BaseMoveEvent {
|
||||||
|
/** The type of move event being fired. */
|
||||||
|
type: 'moveend';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MoveEvent = MoveStartEvent | MoveMoveEvent | MoveEndEvent;
|
||||||
|
|
||||||
|
export type MoveHandlers = {
|
||||||
|
/**
|
||||||
|
* Handler that is called when a move interaction starts.
|
||||||
|
*/
|
||||||
|
onMoveStart?: (e: MoveStartEvent) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler that is called when a move interaction ends.
|
||||||
|
*/
|
||||||
|
onMoveEnd?: (e: MoveEndEvent) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler that is called when the element is moved.
|
||||||
|
*/
|
||||||
|
onMove?: (e: MoveMoveEvent) => void;
|
||||||
|
};
|
||||||
0
src/lib/interactions/move/index.ts
Normal file
0
src/lib/interactions/move/index.ts
Normal file
1
svelte-interactions
Submodule
1
svelte-interactions
Submodule
Submodule svelte-interactions added at 23d93f654c
Reference in New Issue
Block a user