From a57007083dccc4401744ebcc2af107fb5019db3b Mon Sep 17 00:00:00 2001 From: Hunter Johnston <64506580+huntabyte@users.noreply.github.com> Date: Tue, 9 Jan 2024 19:53:07 -0500 Subject: [PATCH] feat: move (#5) --- .changeset/config.json | 2 +- .changeset/mean-cats-jam.md | 5 + src/lib/interactions/move/create.ts | 329 ++++++++++++++++++++++++++++ src/lib/interactions/move/events.ts | 58 +++++ src/lib/interactions/move/index.ts | 0 svelte-interactions | 1 + 6 files changed, 394 insertions(+), 1 deletion(-) create mode 100644 .changeset/mean-cats-jam.md create mode 100644 src/lib/interactions/move/create.ts create mode 100644 src/lib/interactions/move/events.ts create mode 100644 src/lib/interactions/move/index.ts create mode 160000 svelte-interactions diff --git a/.changeset/config.json b/.changeset/config.json index 3abfe70..ba4e821 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -2,7 +2,7 @@ "$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json", "changelog": [ "@svitejs/changesets-changelog-github-compact", - { "repo": "huntabyte/svelte-interactions" } + { "repo": "svecosystem/svelte-interactions" } ], "commit": false, "fixed": [], diff --git a/.changeset/mean-cats-jam.md b/.changeset/mean-cats-jam.md new file mode 100644 index 0000000..11b21fe --- /dev/null +++ b/.changeset/mean-cats-jam.md @@ -0,0 +1,5 @@ +--- +"svelte-interactions": minor +--- + +feat: `move` diff --git a/src/lib/interactions/move/create.ts b/src/lib/interactions/move/create.ts new file mode 100644 index 0000000..bb08e37 --- /dev/null +++ b/src/lib/interactions/move/create.ts @@ -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) => void; + 'on:movestart'?: (e: CustomEvent) => void; + 'on:moveend'?: (e: CustomEvent) => 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({ + didMove: false, + lastPosition: null, + id: null + }); + + const { addGlobalListener, removeGlobalListener } = createGlobalListeners(); + + function dispatchMoveEvent(moveEvent: MoveEvent) { + nodeEl?.dispatchEvent(new CustomEvent(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); +} diff --git a/src/lib/interactions/move/events.ts b/src/lib/interactions/move/events.ts new file mode 100644 index 0000000..db3065f --- /dev/null +++ b/src/lib/interactions/move/events.ts @@ -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; +}; diff --git a/src/lib/interactions/move/index.ts b/src/lib/interactions/move/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/svelte-interactions b/svelte-interactions new file mode 160000 index 0000000..23d93f6 --- /dev/null +++ b/svelte-interactions @@ -0,0 +1 @@ +Subproject commit 23d93f654c1a6703f50ce41f96742af74564c97b