|
<script lang="ts"> |
|
import { scale } from 'svelte/transition'; |
|
import { backOut } from 'svelte/easing'; |
|
|
|
interface Props { |
|
/** Unique identifier for this item */ |
|
id: string; |
|
/** Initial position */ |
|
position: { x: number; y: number }; |
|
/** Called when drag starts */ |
|
onDragStart?: () => void; |
|
/** Called when drag ends */ |
|
onDragEnd?: () => void; |
|
/** Called when item should be dismissed */ |
|
onDismiss?: () => void; |
|
/** Called when position changes (for state sync) */ |
|
onPositionChange?: (x: number, y: number) => void; |
|
/** Called when over drop zone state changes */ |
|
onOverDropZoneChange?: (isOver: boolean) => void; |
|
/** Drop zone bounds { left, right, top, bottom } */ |
|
dropZoneBounds?: { left: number; right: number; top: number; bottom: number }; |
|
/** Slot content */ |
|
children?: import('svelte').Snippet; |
|
} |
|
|
|
let { |
|
id, |
|
position, |
|
onDragStart, |
|
onDragEnd, |
|
onDismiss, |
|
onPositionChange, |
|
onOverDropZoneChange, |
|
dropZoneBounds, |
|
children |
|
}: Props = $props(); |
|
|
|
// Local position state for smooth drag (no store round-trip) |
|
let localPosition = $state({ x: position.x, y: position.y }); |
|
|
|
// Drag state |
|
let isDragging = $state(false); |
|
let didMove = $state(false); |
|
let dragOffset = $state({ x: 0, y: 0 }); |
|
let isOverDropZone = $state(false); |
|
|
|
// Store reference for pointer capture release |
|
let element: HTMLElement | null = null; |
|
|
|
// Default drop zone: bottom center of screen |
|
const getDropZoneBounds = () => dropZoneBounds ?? { |
|
left: window.innerWidth / 2 - 56, |
|
right: window.innerWidth / 2 + 56, |
|
top: window.innerHeight - 80, |
|
bottom: window.innerHeight - 16 |
|
}; |
|
|
|
function checkDropZone(clientX: number, clientY: number) { |
|
const bounds = getDropZoneBounds(); |
|
const wasOver = isOverDropZone; |
|
isOverDropZone = |
|
clientX >= bounds.left && |
|
clientX <= bounds.right && |
|
clientY >= bounds.top && |
|
clientY <= bounds.bottom; |
|
|
|
if (isOverDropZone !== wasOver) { |
|
onOverDropZoneChange?.(isOverDropZone); |
|
} |
|
} |
|
|
|
// ==================== Pointer Events ==================== |
|
|
|
function handlePointerDown(event: PointerEvent) { |
|
if (event.button !== 0) return; // Left click only |
|
|
|
event.preventDefault(); |
|
event.stopPropagation(); |
|
|
|
element = event.currentTarget as HTMLElement; |
|
element.setPointerCapture(event.pointerId); |
|
|
|
isDragging = true; |
|
didMove = false; |
|
dragOffset = { |
|
x: event.clientX - localPosition.x, |
|
y: event.clientY - localPosition.y |
|
}; |
|
|
|
onDragStart?.(); |
|
|
|
window.addEventListener('pointermove', handlePointerMove); |
|
window.addEventListener('pointerup', handlePointerUp); |
|
} |
|
|
|
function handlePointerMove(event: PointerEvent) { |
|
if (!isDragging) return; |
|
|
|
didMove = true; |
|
localPosition = { |
|
x: event.clientX - dragOffset.x, |
|
y: event.clientY - dragOffset.y |
|
}; |
|
|
|
checkDropZone(event.clientX, event.clientY); |
|
} |
|
|
|
function handlePointerUp(event: PointerEvent) { |
|
if (!isDragging) return; |
|
|
|
if (element) { |
|
element.releasePointerCapture(event.pointerId); |
|
element = null; |
|
} |
|
|
|
window.removeEventListener('pointermove', handlePointerMove); |
|
window.removeEventListener('pointerup', handlePointerUp); |
|
|
|
if (isOverDropZone) { |
|
onDismiss?.(); |
|
} else { |
|
onPositionChange?.(localPosition.x, localPosition.y); |
|
} |
|
|
|
isDragging = false; |
|
isOverDropZone = false; |
|
onDragEnd?.(); |
|
} |
|
|
|
// ==================== Touch Events ==================== |
|
|
|
function handleTouchStart(event: TouchEvent) { |
|
if (event.touches.length !== 1) return; |
|
event.preventDefault(); |
|
|
|
isDragging = true; |
|
didMove = false; |
|
const touch = event.touches[0]; |
|
dragOffset = { |
|
x: touch.clientX - localPosition.x, |
|
y: touch.clientY - localPosition.y |
|
}; |
|
|
|
onDragStart?.(); |
|
|
|
document.addEventListener('touchmove', handleTouchMove, { passive: false }); |
|
document.addEventListener('touchend', handleTouchEnd); |
|
document.addEventListener('touchcancel', handleTouchEnd); |
|
} |
|
|
|
function handleTouchMove(event: TouchEvent) { |
|
if (!isDragging || event.touches.length !== 1) return; |
|
event.preventDefault(); // Prevents scrolling during drag |
|
|
|
didMove = true; |
|
const touch = event.touches[0]; |
|
localPosition = { |
|
x: touch.clientX - dragOffset.x, |
|
y: touch.clientY - dragOffset.y |
|
}; |
|
|
|
checkDropZone(touch.clientX, touch.clientY); |
|
} |
|
|
|
function handleTouchEnd() { |
|
if (!isDragging) return; |
|
|
|
document.removeEventListener('touchmove', handleTouchMove); |
|
document.removeEventListener('touchend', handleTouchEnd); |
|
document.removeEventListener('touchcancel', handleTouchEnd); |
|
|
|
if (isOverDropZone) { |
|
onDismiss?.(); |
|
} else { |
|
onPositionChange?.(localPosition.x, localPosition.y); |
|
} |
|
|
|
isDragging = false; |
|
isOverDropZone = false; |
|
onDragEnd?.(); |
|
} |
|
|
|
function handleClick(event: MouseEvent) { |
|
// Only trigger click if we didn't drag |
|
if (didMove) { |
|
didMove = false; |
|
event.stopPropagation(); |
|
} |
|
} |
|
</script> |
|
|
|
<!-- svelte-ignore a11y_no_static_element_interactions --> |
|
<div |
|
class="pointer-events-auto absolute" |
|
style="left: {localPosition.x}px; top: {localPosition.y}px; transform: translate(-50%, -50%); z-index: {isDragging ? 100 : 0};" |
|
> |
|
<!-- svelte-ignore a11y_click_events_have_key_events --> |
|
<div |
|
class="cursor-grab active:cursor-grabbing transition-transform duration-150 |
|
{isDragging ? 'scale-110' : ''} |
|
{isOverDropZone ? 'opacity-50' : ''}" |
|
style="touch-action: none;" |
|
onpointerdown={handlePointerDown} |
|
ontouchstart={handleTouchStart} |
|
onclick={handleClick} |
|
role="button" |
|
tabindex="0" |
|
in:scale={{ duration: 300, easing: backOut }} |
|
out:scale={{ duration: 150 }} |
|
> |
|
{@render children?.()} |
|
</div> |
|
</div> |