hensei-web/src/lib/composables/drag-drop.svelte.ts
Justin Edmund 16e18525da Fix drag-drop swapping
Add parent +server.ts files to fix routing conflicts
Pass actual target item IDs instead of placeholder strings
2025-09-16 17:19:26 -07:00

360 lines
No EOL
9 KiB
TypeScript

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
type?: GridItemType
}
target: {
container: string
position: number
itemId?: string
type?: GridItemType
}
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(targetItem?: GridItem) {
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, !!targetItem),
timestamp: Date.now(),
source: {
container: state.draggedItem.source.container,
position: state.draggedItem.source.position,
itemId: state.draggedItem.data.id,
type: state.draggedItem.source.type
},
target: {
container: state.hoveredOver.container,
position: state.hoveredOver.position,
itemId: targetItem?.id || undefined,
type: state.hoveredOver.type
},
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>