UNPKG

@dvcol/neo-svelte

Version:

Neomorphic ui library for svelte 5

371 lines (370 loc) 14.9 kB
import { debounce } from '@dvcol/common-utils/common/debounce'; import { watch } from '@dvcol/svelte-utils/watch'; import { Logger } from '../../utils/logger.utils.js'; export const defaultSnap = { enabled: false, corner: false, outside: true, placement: false, offset: 25, translate: { duration: 600, easing: 'var(--neo-easing-spring, ease-in-out)' }, }; export const defaultHandle = { full: false, visible: true, position: 'inside', minSize: 16, }; export const defaultMovable = { enabled: false, step: 4, margin: 16, contain: false, resetOnClose: true, snap: defaultSnap, handle: defaultHandle, }; const translateRegex = /translate[^;]+/g; export function useMovable(options) { const offset = $derived(options.offset); const element = $derived(options.element); const placement = $derived(options.placement); const movable = $derived({ ...defaultMovable, ...options.movable, }); const snap = $derived.by(() => { const _snap = typeof movable.snap === 'object' ? movable.snap : { enabled: !!movable.snap, corner: movable.snap === 'corner' }; return { ...defaultSnap, enabled: !!movable.snap, ..._snap, translate: { ...defaultSnap.translate, ..._snap.translate }, }; }); let initial = $state({ x: 0, y: 0 }); const translate = $derived.by(() => { if (movable.axis === 'x') return `${offset?.x ?? 0}px 0`; if (movable.axis === 'y') return `0 ${offset?.y ?? 0}px`; return `${offset?.x ?? 0}px ${offset?.y ?? 0}px`; }); let translating = $state(0); let transition = ''; let timeout; const startTranslating = (value = 1, { easing = snap.translate.easing, duration = snap.translate.duration } = {}) => { clearTimeout(timeout); translating = value; if (!element) return; if (!translating) transition = element.style.transition; const computed = getComputedStyle(element).transition; if (computed.includes('translate')) element.style.transition = computed.replace(translateRegex, `translate ${duration}ms ${easing}`); else element.style.transition = `${computed}, translate ${duration}ms ${easing}`; return { easing, duration }; }; const stopTranslating = debounce(async (delay = snap.translate.duration) => { clearTimeout(timeout); const { resolve, promise } = Promise.withResolvers(); timeout = setTimeout(() => { if (!element) return resolve(false); element.style.transition = transition; transition = ''; translating = 0; resolve(true); }, delay); return promise; }, 50); let available = $state({ top: 0, right: 0, bottom: 0, left: 0 }); const updateAvailable = () => { if (!element) return {}; const { top, right, bottom, left, width, height } = element.getBoundingClientRect(); const margin = movable.margin ?? 0; available = { top: Math.max(0, top - offset.y - margin), bottom: Math.max(0, window.innerHeight - (bottom - offset.y) - margin), left: Math.max(0, left - offset.x - margin), right: Math.max(0, window.innerWidth - (right - offset.x) - margin), }; return { top, right, bottom, left, width, height, margin, available }; }; const threshold = $derived.by(() => { if (!movable.closeThreshold) return; const _threshold = typeof movable.closeThreshold === 'number' ? { x: movable.closeThreshold, y: movable.closeThreshold } : movable.closeThreshold; const _fallback = _threshold.outside ? available : { top: 0, right: 0, bottom: 0, left: 0 }; const _margin = movable.margin ?? 0; const { width, height } = element?.getBoundingClientRect() ?? { width: 0, height: 0 }; return { top: _threshold.top ?? _threshold.y ?? _fallback.top + _margin + height, bottom: _threshold.bottom ?? _threshold.y ?? _fallback.bottom + _margin + height, left: _threshold.left ?? _threshold.x ?? _fallback.left + _margin + width, right: _threshold.right ?? _threshold.x ?? _fallback.right + _margin + width, }; }); const setOffset = (x, y, { contain = movable.contain, outside, limits = movable.limits } = {}) => { if (contain) { x = Math.min(Math.max(x, -available.left), available.right); y = Math.min(Math.max(y, -available.top), available.bottom); } if (limits?.x) { if (limits.x.min !== undefined) x = Math.max(x, limits.x.min); if (limits.x.max !== undefined) x = Math.min(x, limits.x.max); } if (limits?.y) { if (limits.y.min !== undefined) y = Math.max(y, limits.y.min); if (limits.y.max !== undefined) y = Math.min(y, limits.y.max); } options.offset.x = x; options.offset.y = y; if (outside !== undefined) options.outside = outside; }; const resetOffset = async ({ x, y, translate: _translate, ...opts } = {}) => { let duration = 0; if (_translate) duration = startTranslating(1, typeof _translate === 'object' ? _translate : undefined)?.duration ?? 0; setOffset(x ?? 0, y ?? 0, { contain: false, ...opts }); return stopTranslating(duration); }; watch(() => { resetOffset().catch(Logger.error); }, () => placement, { skip: 1 }); const onPointerMove = (_e) => { setOffset(_e.clientX - initial.x, _e.clientY - initial.y); }; const snapToClosest = async () => { if (!element || !snap.enabled) return; const { left, right, top, bottom, width, height, margin } = updateAvailable(); if (left === undefined || right === undefined || top === undefined || bottom === undefined) return; startTranslating(); const windowX = window.innerWidth / 2; const halfWidth = width / 2; const middleX = left + halfWidth; const _offset = { x: 0, y: 0 }; const _placement = { x: '', y: '' }; const _outside = { previous: options.outside, current: false }; // If element center is over the middle of the window if (middleX > windowX && (snap.corner || middleX - windowX > window.innerWidth - middleX)) { _placement.x = 'right'; // If the element center is outside the window if (snap.outside && !_outside.previous && middleX > window.innerWidth) { _offset.x = available.right + width + margin - snap.offset; _outside.current = 'right'; } else _offset.x = available.right; // If the element center is closer to the middle of the window } else if (middleX > windowX) _offset.x = available.right + margin - (windowX - halfWidth); // If the element center is before the middle of the window else if (snap.corner || middleX < windowX - middleX) { _placement.x = 'left'; // If the element center is outside the window if (snap.outside && !_outside.current && !_outside.previous && middleX < 0) { _offset.x = -available.left - width - margin + snap.offset; _outside.current = 'left'; } else _offset.x = -available.left; // If the element center is closer to the middle of the window } else _offset.x = windowX - halfWidth - available.left - margin; const windowY = window.innerHeight / 2; const halfHeight = height / 2; const middleY = top + halfHeight; // If element center is below the middle of the window if (middleY > windowY && (snap.corner || middleY - windowY > window.innerHeight - middleY)) { _placement.y = 'bottom'; // If the element center is outside the window if (snap.outside && !_outside.current && !_outside.previous && middleY > window.innerHeight) { _offset.y = available.bottom + height + margin - snap.offset; _outside.current = 'bottom'; } else _offset.y = available.bottom; // If the element center is closer to the middle of the window } else if (middleY > windowY) _offset.y = available.bottom + margin - (windowY - halfHeight); // If the element center is above the middle of the window else if (snap.corner || middleY < windowY - middleY) { _placement.y = 'top'; // If the element center is outside the window if (snap.outside && !_outside.current && !_outside.previous && middleY < 0) { _offset.y = -available.top - height - margin + snap.offset; _outside.current = 'top'; } else _offset.y = -available.top; // If the element center is closer to the middle of the window } else _offset.y = windowY - halfHeight - available.top - margin; setOffset(_offset.x, _offset.y, { outside: _outside.current }); await stopTranslating(); if (!snap.placement) return; if (!_placement.x && !_placement.y) { options.placement = 'center'; } else if (_placement.y === 'top' && _placement.x === 'left') { options.placement = snap.corner || options.placement?.startsWith('left') ? 'left-start' : 'top-start'; } else if (_placement.y === 'top' && _placement.x === 'right') { options.placement = snap.corner || options.placement?.startsWith('right') ? 'right-start' : 'top-end'; } else if (_placement.y === 'bottom' && _placement.x === 'left') { options.placement = snap.corner || options.placement?.startsWith('left') ? 'left-end' : 'bottom-start'; } else if (_placement.y === 'bottom' && _placement.x === 'right') { options.placement = snap.corner || options.placement?.startsWith('right') ? 'right-end' : 'bottom-end'; } else if (_placement.y === 'top' && !_placement.x) { options.placement = 'top'; } else if (_placement.y === 'bottom' && !_placement.x) { options.placement = 'bottom'; } else if (_placement.x === 'left' && !_placement.y) { options.placement = 'left'; } else if (_placement.x === 'right' && !_placement.y) { options.placement = 'right'; } }; const closeOnThreshold = async () => { if (!element || !threshold) return false; if ( // If the offset x is positive we are moving right offset.x <= threshold.right && -offset.x <= threshold.left // If the offset y is positive we are moving down && offset.y <= threshold.bottom && -offset.y <= threshold.top) { return false; } await options.close?.(); return true; }; let moving = $state(false); const onPointerStop = async () => { window.removeEventListener('pointermove', onPointerMove); window.removeEventListener('pointerup', onPointerStop); window.removeEventListener('pointercancel', onPointerStop); window.removeEventListener('pointerleave', onPointerStop); moving = false; try { if (await closeOnThreshold()) return; await snapToClosest(); } catch (e) { Logger.error(e); } }; const onPointerDown = async (e) => { if (!movable.enabled || !element || e.button !== 0) return; if (translating) await stopTranslating(0); e.preventDefault(); moving = true; initial = { x: e.clientX - offset.x, y: e.clientY - offset.y }; updateAvailable(); window.addEventListener('pointermove', onPointerMove); window.addEventListener('pointerup', onPointerStop); window.addEventListener('pointercancel', onPointerStop); window.addEventListener('pointerleave', onPointerStop); }; const onKeyDown = async (e) => { if (!movable.enabled || !element) return; if (!e.key.startsWith('Arrow')) return; initial = { x: 0, y: 0 }; await stopTranslating.cancel(); startTranslating(Math.min(translating + 1, 10), { duration: 100, easing: 'linear' }); const step = (movable.step ?? 4) * translating; if (e.key === 'ArrowLeft') { setOffset(offset.x - step, offset.y); } else if (e.key === 'ArrowRight') { setOffset(offset.x + step, offset.y); } else if (e.key === 'ArrowUp') { setOffset(offset.x, offset.y - step); } else if (e.key === 'ArrowDown') { setOffset(offset.x, offset.y + step); } }; const onKeyUp = async (e) => { if (!e.key.startsWith('Arrow')) return; await stopTranslating(0); await snapToClosest(); }; return { get offset() { return offset; }, get outside() { return options.outside; }, get placement() { return placement; }, get movable() { return movable; }, get element() { return element; }, get translate() { return translate; }, get translating() { return translating; }, get moving() { return moving; }, get handlers() { return { onpointerdown: (e) => { onPointerDown(e).catch(Logger.error); return options.handlers?.onpointerdown?.(e); }, onkeydown: async (e) => { await onKeyDown(e); return options.handlers?.onkeydown?.(e); }, onkeyup: async (e) => { onKeyUp(e).catch(Logger.error); return options.handlers?.onkeyup?.(e); }, onblur: (e) => { stopTranslating().catch(Logger.error); return options.handlers?.onblur?.(e); }, }; }, reset: resetOffset, }; }