Add drag-drop system for grids

- Core composable with touch support & operation queue
- DraggableItem & DropZone components
- Proper position mapping for containers
- Cross-container swap/move operations
- Visual feedback states

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Justin Edmund 2025-09-16 02:43:58 -07:00
parent 627989bea1
commit 1d0495f1f2
3 changed files with 734 additions and 0 deletions

View file

@ -0,0 +1,199 @@
<svelte:options runes={true} />
<script lang="ts">
import { getContext, onMount } from 'svelte'
import type { GridItem, GridItemType, DragDropContext } from '$lib/composables/drag-drop.svelte'
interface Props {
item: GridItem | undefined
container: string
position: number
type: GridItemType
canDrag?: boolean
customPreview?: boolean
children?: any
}
let {
item,
container,
position,
type,
canDrag = true,
customPreview = false,
children
}: Props = $props()
const dragContext = getContext<DragDropContext>('drag-drop')
let elementRef: HTMLElement | undefined = $state()
let isDragging = $derived(
dragContext?.state.isDragging &&
dragContext?.state.draggedItem?.source.container === container &&
dragContext?.state.draggedItem?.source.position === position
)
function handleDragStart(e: DragEvent) {
if (!canDrag || !item || !dragContext) {
e.preventDefault()
return
}
e.dataTransfer!.effectAllowed = 'move'
e.dataTransfer!.setData('application/json', JSON.stringify({
item,
container,
position,
type
}))
if (customPreview && elementRef) {
const ghost = createCustomGhost()
e.dataTransfer!.setDragImage(ghost, e.offsetX, e.offsetY)
requestAnimationFrame(() => ghost.remove())
}
dragContext.startDrag(item, { container, position, type })
}
function handleDragEnd(e: DragEvent) {
if (!dragContext) return
dragContext.endDrag()
}
function createCustomGhost(): HTMLElement {
if (!elementRef) throw new Error('Element ref not available')
const ghost = document.createElement('div')
ghost.className = 'drag-ghost'
ghost.style.position = 'absolute'
ghost.style.top = '-1000px'
ghost.style.left = '-1000px'
ghost.style.transform = 'rotate(5deg) scale(1.05)'
ghost.style.opacity = '0.8'
ghost.innerHTML = elementRef.innerHTML
document.body.appendChild(ghost)
return ghost
}
function handlePointerDown(e: PointerEvent) {
if (!canDrag || !item || !dragContext) return
dragContext.handlePointerDown(e, item, { container, position }, type)
}
function handlePointerMove(e: PointerEvent) {
if (!dragContext) return
dragContext.handlePointerMove(e)
if (dragContext.state.isDragging && e.pointerType === 'touch') {
e.preventDefault()
dragContext.updateDragPreviewPosition(e.clientX, e.clientY)
}
}
function handlePointerUp(e: PointerEvent) {
if (!dragContext) return
dragContext.handlePointerUp()
}
function handleTouchStart(e: TouchEvent) {
if (!canDrag || !item) return
e.preventDefault()
}
function handleTouchMove(e: TouchEvent) {
if (!dragContext || !dragContext.state.isDragging) return
e.preventDefault()
const touch = e.touches[0]
if (touch) {
dragContext.updateDragPreviewPosition(touch.clientX, touch.clientY)
}
}
function handleTouchEnd(e: TouchEvent) {
if (!dragContext || !dragContext.state.isDragging) return
const touch = e.changedTouches[0]
if (touch) {
const dropTarget = document.elementFromPoint(touch.clientX, touch.clientY)
if (dropTarget) {
const event = new CustomEvent('drop', {
detail: { clientX: touch.clientX, clientY: touch.clientY }
})
dropTarget.dispatchEvent(event)
}
}
dragContext.endDrag()
}
onMount(() => {
if (!elementRef || !('ontouchstart' in window)) return
elementRef.addEventListener('touchstart', handleTouchStart, { passive: false })
elementRef.addEventListener('touchmove', handleTouchMove, { passive: false })
elementRef.addEventListener('touchend', handleTouchEnd)
return () => {
if (elementRef) {
elementRef.removeEventListener('touchstart', handleTouchStart)
elementRef.removeEventListener('touchmove', handleTouchMove)
elementRef.removeEventListener('touchend', handleTouchEnd)
}
}
})
</script>
<div
bind:this={elementRef}
draggable={canDrag && !!item}
ondragstart={handleDragStart}
ondragend={handleDragEnd}
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
class="draggable-item"
class:dragging={isDragging}
class:can-drag={canDrag && !!item}
class:empty={!item}
data-container={container}
data-position={position}
data-type={type}
>
{@render children?.()}
</div>
<style lang="scss">
.draggable-item {
position: relative;
transition: opacity 0.2s, transform 0.2s;
&.dragging {
opacity: 0.5;
cursor: grabbing;
}
&.can-drag {
cursor: grab;
touch-action: none;
user-select: none;
&:hover {
transform: scale(1.02);
}
&:active {
cursor: grabbing;
}
}
&.empty {
cursor: default;
}
}
:global(.drag-ghost) {
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3));
}
</style>

View file

@ -0,0 +1,179 @@
<svelte:options runes={true} />
<script lang="ts">
import { getContext } from 'svelte'
import type { GridItem, GridItemType, DragDropContext } from '$lib/composables/drag-drop.svelte'
interface Props {
container: string
position: number
type: GridItemType
item?: GridItem
canDrop?: boolean
onDrop?: (item: GridItem) => void
children?: any
}
let {
container,
position,
type,
item,
canDrop = true,
onDrop,
children
}: Props = $props()
const dragContext = getContext<DragDropContext>('drag-drop')
let elementRef: HTMLElement | undefined = $state()
let isHovered = $derived(
dragContext?.state.hoveredOver?.container === container &&
dragContext?.state.hoveredOver?.position === position
)
let isValidDrop = $derived(
isHovered && dragContext?.state.validDrop
)
let isInvalidDrop = $derived(
isHovered && !dragContext?.state.validDrop
)
function handleDragOver(e: DragEvent) {
if (!canDrop || !dragContext) return
e.preventDefault()
e.dataTransfer!.dropEffect = 'move'
if (dragContext.state.draggedItem) {
const target = { container, position, type }
dragContext.updateHover(target)
const isValid = dragContext.validateDrop(
dragContext.state.draggedItem.source,
target
)
if (!isValid) {
e.dataTransfer!.dropEffect = 'none'
}
}
}
function handleDragEnter(e: DragEvent) {
if (!canDrop || !dragContext) return
e.preventDefault()
const target = { container, position, type }
dragContext.updateHover(target)
}
function handleDragLeave(e: DragEvent) {
if (!dragContext) return
if (e.relatedTarget && elementRef?.contains(e.relatedTarget as Node)) {
return
}
dragContext.updateHover(null)
}
function handleDrop(e: DragEvent) {
if (!canDrop || !dragContext) return
e.preventDefault()
e.stopPropagation()
const target = { container, position, type }
if (dragContext.state.draggedItem && dragContext.state.validDrop) {
const success = dragContext.handleDrop(target, item)
if (success && onDrop && dragContext.state.draggedItem) {
onDrop(dragContext.state.draggedItem.data)
}
}
dragContext.updateHover(null)
}
</script>
<div
bind:this={elementRef}
ondragover={handleDragOver}
ondragenter={handleDragEnter}
ondragleave={handleDragLeave}
ondrop={handleDrop}
class="drop-zone"
class:hovered={isHovered}
class:valid-drop={isValidDrop}
class:invalid-drop={isInvalidDrop}
class:can-drop={canDrop}
data-container={container}
data-position={position}
data-type={type}
>
{@render children?.()}
</div>
<style lang="scss">
.drop-zone {
position: relative;
transition: all 0.2s ease-out;
&.hovered {
transform: scale(1.02);
}
&.valid-drop {
border: 2px dashed #4CAF50;
background: rgba(76, 175, 80, 0.1);
border-radius: 8px;
&::before {
content: '';
position: absolute;
inset: -4px;
border: 2px solid #4CAF50;
border-radius: 10px;
opacity: 0.3;
pointer-events: none;
animation: pulse 1s ease-in-out infinite;
}
}
&.invalid-drop {
border: 2px dashed #F44336;
background: rgba(244, 67, 54, 0.1);
border-radius: 8px;
opacity: 0.7;
&::after {
content: '⚠';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 24px;
color: #F44336;
pointer-events: none;
}
}
}
@keyframes pulse {
0% {
opacity: 0.3;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(1.02);
}
100% {
opacity: 0.3;
transform: scale(1);
}
}
</style>

View file

@ -0,0 +1,356 @@
import type { GridCharacter, GridWeapon, GridSummon } from '$lib/types/api/party'
export type GridItemType = 'character' | 'weapon' | 'summon'
export type GridItem = GridCharacter | GridWeapon | GridSummon
export interface Position {
container: string
position: number
}
export interface DragSource extends Position {
type: GridItemType
}
export interface DraggedItem {
type: GridItemType
data: GridItem
source: DragSource
}
export interface DropTarget extends Position {
type: GridItemType
}
export interface DragOperation {
id: string
type: 'move' | 'swap' | 'reorder'
timestamp: number
source: {
container: string
position: number
itemId: string
}
target: {
container: string
position: number
itemId?: string
}
status: 'pending' | 'synced' | 'failed'
retryCount: number
}
export interface TouchState {
touchStartPos: { x: number; y: number } | null
touchStartTime: number
longPressTimer: number | null
touchThreshold: number
longPressDuration: number
currentTouch: Touch | null
}
export interface DragDropState {
isDragging: boolean
draggedItem: DraggedItem | null
hoveredOver: DropTarget | null
validDrop: boolean
dragPreview: HTMLElement | null
operationQueue: DragOperation[]
lastError: Error | null
touchState: TouchState
}
export interface DragDropHandlers {
onDrop?: (from: DragSource, to: DropTarget, item: GridItem) => void
onSwap?: (from: DragSource, to: DropTarget, fromItem: GridItem, toItem: GridItem) => void
onValidate?: (from: DragSource, to: DropTarget) => boolean
onLocalUpdate?: (operation: DragOperation) => void
}
export function createDragDropContext(handlers: DragDropHandlers = {}) {
let state = $state<DragDropState>({
isDragging: false,
draggedItem: null,
hoveredOver: null,
validDrop: false,
dragPreview: null,
operationQueue: [],
lastError: null,
touchState: {
touchStartPos: null,
touchStartTime: 0,
longPressTimer: null,
touchThreshold: 10,
longPressDuration: 500,
currentTouch: null
}
})
function detectItemType(item: GridItem): GridItemType {
if ('character' in item) return 'character'
if ('weapon' in item) return 'weapon'
if ('summon' in item) return 'summon'
throw new Error('Unknown item type')
}
function handlePointerDown(e: PointerEvent, item: GridItem, source: Position, type: GridItemType) {
if (e.pointerType === 'touch') {
initiateTouchDrag(e, item, source, type)
} else {
startDrag(item, { ...source, type })
}
}
function initiateTouchDrag(e: PointerEvent, item: GridItem, source: Position, type: GridItemType) {
state.touchState.touchStartPos = { x: e.clientX, y: e.clientY }
state.touchState.touchStartTime = Date.now()
state.touchState.longPressTimer = window.setTimeout(() => {
startDrag(item, { ...source, type })
if ('vibrate' in navigator) {
navigator.vibrate(50)
}
}, state.touchState.longPressDuration)
}
function handlePointerMove(e: PointerEvent) {
if (!state.touchState.touchStartPos) return
const distance = Math.sqrt(
Math.pow(e.clientX - state.touchState.touchStartPos.x, 2) +
Math.pow(e.clientY - state.touchState.touchStartPos.y, 2)
)
if (distance > state.touchState.touchThreshold && state.touchState.longPressTimer) {
clearTimeout(state.touchState.longPressTimer)
state.touchState.longPressTimer = null
}
}
function handlePointerUp() {
if (state.touchState.longPressTimer) {
clearTimeout(state.touchState.longPressTimer)
state.touchState.longPressTimer = null
}
state.touchState.touchStartPos = null
}
function startDrag(item: GridItem, source: DragSource) {
try {
console.group('🚀 Drag Start')
console.log('Item:', item)
console.log('Source:', source)
console.groupEnd()
state.isDragging = true
state.draggedItem = {
type: source.type,
data: item,
source
}
createDragPreview(item)
} catch (error) {
handleDragError(error as Error)
}
}
function createDragPreview(item: GridItem) {
const preview = document.createElement('div')
preview.className = 'drag-preview'
preview.style.position = 'fixed'
preview.style.pointerEvents = 'none'
preview.style.zIndex = '10000'
preview.style.opacity = '0.8'
const itemName = 'character' in item ? item.character.name :
'weapon' in item ? item.weapon.name :
'summon' in item ? item.summon.name : 'Unknown'
preview.innerHTML = `
<div class="drag-preview-content" style="padding: 8px; background: white; border: 2px solid #ccc; border-radius: 4px;">
<span>${itemName || 'Item'}</span>
</div>
`
document.body.appendChild(preview)
state.dragPreview = preview
}
function updateHover(target: DropTarget | null) {
state.hoveredOver = target
if (target && state.draggedItem) {
const isValid = validateDrop(state.draggedItem.source, target)
state.validDrop = isValid
} else {
state.validDrop = false
}
}
function determineOperationType(source: Position, target: Position, targetHasItem: boolean): 'move' | 'swap' | 'reorder' {
if (source.position === target.position && source.container === target.container) return 'reorder'
if (targetHasItem) return 'swap'
return 'move'
}
function endDrag(targetHasItem: boolean = false) {
try {
console.group('🏁 Drag End')
console.log('Final state:', { ...state })
if (state.validDrop && state.draggedItem && state.hoveredOver) {
const operation: DragOperation = {
id: crypto.randomUUID(),
type: determineOperationType(state.draggedItem.source, state.hoveredOver, targetHasItem),
timestamp: Date.now(),
source: {
container: state.draggedItem.source.container,
position: state.draggedItem.source.position,
itemId: state.draggedItem.data.id
},
target: {
container: state.hoveredOver.container,
position: state.hoveredOver.position,
itemId: targetHasItem ? 'has-item' : undefined
},
status: 'pending',
retryCount: 0
}
state.operationQueue.push(operation)
console.log('📝 Operation queued:', operation)
handlers.onLocalUpdate?.(operation)
}
console.groupEnd()
} catch (error) {
handleDragError(error as Error)
} finally {
cleanupDragState()
}
}
function handleDragError(error: Error) {
console.error('🔥 Drag operation failed:', error)
state.lastError = error
cleanupDragState()
}
function cleanupDragState() {
state.isDragging = false
state.draggedItem = null
state.hoveredOver = null
state.validDrop = false
if (state.dragPreview) {
state.dragPreview.remove()
state.dragPreview = null
}
if (state.touchState.longPressTimer) {
clearTimeout(state.touchState.longPressTimer)
}
state.touchState = {
...state.touchState,
touchStartPos: null,
longPressTimer: null,
currentTouch: null
}
}
function validateDrop(source: DragSource, target: DropTarget): boolean {
console.group('🎯 Drop Validation')
console.log('Source:', source)
console.log('Target:', target)
// Can't drop on self
if (source.container === target.container && source.position === target.position) {
console.log('❌ Cannot drop on self')
console.groupEnd()
return false
}
// Type mismatch check
if (source.type !== target.type) {
console.log('❌ Type mismatch:', source.type, 'vs', target.type)
console.groupEnd()
return false
}
// Custom validation
if (handlers.onValidate) {
const customValid = handlers.onValidate(source, target)
console.log(customValid ? '✅ Custom validation passed' : '❌ Custom validation failed')
console.groupEnd()
return customValid
}
console.log('✅ Drop allowed')
console.groupEnd()
return true
}
function handleDrop(target: DropTarget, targetItem?: GridItem) {
if (!state.draggedItem || !state.validDrop) {
console.log('❌ Invalid drop attempt')
return false
}
const source = state.draggedItem.source
const item = state.draggedItem.data
console.group('💧 Handle Drop')
console.log('From:', source)
console.log('To:', target)
console.log('Item:', item)
console.log('Target Item:', targetItem)
if (targetItem) {
// Swap items
console.log('🔄 Swapping items')
handlers.onSwap?.(source, target, item, targetItem)
} else {
// Move to empty slot
console.log('📦 Moving to empty slot')
handlers.onDrop?.(source, target, item)
}
console.groupEnd()
endDrag(!!targetItem)
return true
}
function updateDragPreviewPosition(x: number, y: number) {
if (state.dragPreview) {
state.dragPreview.style.left = `${x + 10}px`
state.dragPreview.style.top = `${y - 20}px`
}
}
function getQueuedOperations() {
return state.operationQueue.filter(op => op.status === 'pending')
}
function clearQueue() {
state.operationQueue = []
}
return {
get state() {
return state
},
startDrag,
updateHover,
endDrag,
validateDrop,
handleDrop,
handlePointerDown,
handlePointerMove,
handlePointerUp,
updateDragPreviewPosition,
getQueuedOperations,
clearQueue
}
}
export type DragDropContext = ReturnType<typeof createDragDropContext>