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:
parent
627989bea1
commit
1d0495f1f2
3 changed files with 734 additions and 0 deletions
199
src/lib/components/dnd/DraggableItem.svelte
Normal file
199
src/lib/components/dnd/DraggableItem.svelte
Normal 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>
|
||||||
179
src/lib/components/dnd/DropZone.svelte
Normal file
179
src/lib/components/dnd/DropZone.svelte
Normal 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>
|
||||||
356
src/lib/composables/drag-drop.svelte.ts
Normal file
356
src/lib/composables/drag-drop.svelte.ts
Normal 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>
|
||||||
Loading…
Reference in a new issue