27 KiB
27 KiB
Drag-and-Drop Implementation PRD
Overview
Custom drag-and-drop solution for Granblue Fantasy party management grids (CharacterGrid, SummonGrid, WeaponGrid) using Svelte 5 runes. This is a client-side prototype focused on interaction patterns, built with API integration in mind for future persistence.
Requirements
CharacterGrid
- Layout: 5 horizontal slots
- Behavior: Sequential filling (no gaps allowed)
- Drag Rules:
- All slots are draggable and droppable
- Empty slots automatically sort to the end
- Dragging to reorder maintains sequential filling
SummonGrid
- Layout: Main + Friend (vertical) + 2x2 sub-summons
- Positions:
- Main: position -1 (non-draggable)
- Sub-summons: positions 0-3 (draggable)
- Friend: position 6 (non-draggable)
- Drag Rules:
- Sub-summons can have gaps
- Can swap filled slots
- Can move to empty slots
WeaponGrid
- Layout: Mainhand (vertical) + 3x3 sub-weapons
- Positions:
- Mainhand: position -1 (non-draggable)
- Sub-weapons: positions 0-8 (draggable)
- Drag Rules:
- Sub-weapons can have gaps
- Can swap filled slots
- Can move to empty slots
Cross-Container Support
- Extra characters (separate container)
- Subaura summons (positions 4-5)
- Extra weapons (positions 9+)
Implementation Phases
Phase 1: Core Drag-and-Drop System ✅ COMPLETED
Tasks:
-
Create core drag-and-drop composable (
$lib/composables/drag-drop.svelte.ts)- Implement reactive drag state management using runes
- Add drag source and drop target tracking
- Create coordinate and element detection helpers
- Implement unified pointer event handlers (mouse/touch)
- Add operation queue for future API sync
- Implement error recovery mechanisms
-
Create draggable item wrapper component (
$lib/components/dnd/DraggableItem.svelte)- Wrap unit components (CharacterUnit, WeaponUnit, SummonUnit)
- Handle drag start/end events
- Add visual feedback (opacity, cursor changes)
- Implement custom drag preview/ghost image
- Add touch gesture support with long-press detection
-
Create drop zone component (
$lib/components/dnd/DropZone.svelte)- Manage drop targets
- Implement hover state detection
- Add validation callbacks with console.log
- Implement visual feedback for valid/invalid drops
- Handle touch hover simulation
Phase 2: Grid-Specific Implementation
CharacterGrid Tasks:
- Implement draggable character slots
- Add sequential filling logic (no gaps allowed)
- Implement auto-sort to move empty slots to end
- Add position swapping between characters
- Test character reordering
- Add operation recording for future API sync
SummonGrid Tasks:
- Make main/friend slots non-draggable
- Implement draggable sub-summons (2x2 grid)
- Allow gaps between filled slots
- Implement position swapping between filled slots
- Add move to empty slot functionality
- Test all summon grid interactions
- Add operation recording for future API sync
WeaponGrid Tasks:
- Make mainhand slot non-draggable
- Implement draggable sub-weapons (3x3 grid)
- Allow gaps between filled slots
- Implement position swapping between filled slots
- Add move to empty slot functionality
- Test all weapon grid interactions
- Add operation recording for future API sync
Phase 3: Cross-Container Dragging
Tasks:
- Enable dragging between main and extra character slots
- Enable dragging between main and subaura summon slots
- Enable dragging between main and extra weapon slots
- Add validation rules with console.log output
- Test cross-container interactions
- Implement operation batching for complex moves
Phase 4: Testing & Polish
Tasks:
- Create comprehensive test route at
/test/drag-drop - Add mock data for all grid types
- Test all drag scenarios thoroughly
- Test touch device interactions
- Add smooth animations and transitions
- Optimize performance for smooth dragging
- Add visual indicators for drag states
- Test error recovery scenarios
Phase 5: API Integration Preparation
Tasks:
- Review operation queue implementation
- Add optimistic UI updates pattern
- Implement rollback mechanism for failed operations
- Add sync status indicators
- Document API contract requirements
Technical Design
Core Composable Structure with API-Ready State
// $lib/composables/drag-drop.svelte.ts
// Operation types for future API sync
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
}
interface TouchState {
touchStartPos: { x: number, y: number } | null
touchStartTime: number
longPressTimer: number | null
touchThreshold: number // 10px minimum movement to start drag
longPressDuration: number // 500ms for long press
currentTouch: Touch | null
}
interface DragState {
isDragging: boolean
draggedItem: {
type: 'character' | 'weapon' | 'summon'
data: GridCharacter | GridWeapon | GridSummon
source: {
container: string
position: number
}
} | null
hoveredOver: {
container: string
position: number
} | null
validDrop: boolean
dragPreview: HTMLElement | null
operationQueue: DragOperation[]
lastError: Error | null
touchState: TouchState
}
export function createDragDropContext() {
const state = $state<DragState>({
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
}
})
// Unified pointer event handling
function handlePointerDown(e: PointerEvent, item: GridItem, source: Position) {
if (e.pointerType === 'touch') {
initiateTouchDrag(e, item, source)
} else {
// Mouse handling remains immediate
startDrag(item, source)
}
}
function initiateTouchDrag(e: PointerEvent, item: GridItem, source: Position) {
state.touchState.touchStartPos = { x: e.clientX, y: e.clientY }
state.touchState.touchStartTime = Date.now()
// Long press timer for touch devices
state.touchState.longPressTimer = window.setTimeout(() => {
startDrag(item, source)
// Haptic feedback if available
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)
)
// Cancel long press if user moves too much
if (distance > state.touchState.touchThreshold && state.touchState.longPressTimer) {
clearTimeout(state.touchState.longPressTimer)
state.touchState.longPressTimer = null
}
}
// Core drag operations with error handling
function startDrag(item: GridItem, source: Position) {
try {
state.isDragging = true
state.draggedItem = {
type: detectItemType(item),
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.innerHTML = `
<div class="drag-preview-content">
<img src="${item.icon}" alt="${item.name}" />
<span>${item.name}</span>
</div>
`
document.body.appendChild(preview)
state.dragPreview = preview
}
function endDrag() {
try {
if (state.validDrop && state.draggedItem && state.hoveredOver) {
// Record operation for future API sync
const operation: DragOperation = {
id: crypto.randomUUID(),
type: determineOperationType(state.draggedItem.source, state.hoveredOver),
timestamp: Date.now(),
source: {
...state.draggedItem.source,
itemId: state.draggedItem.data.id
},
target: state.hoveredOver,
status: 'pending',
retryCount: 0
}
// Queue operation
state.operationQueue.push(operation)
// Perform local state update (optimistic)
performLocalUpdate(operation)
// Future: Trigger API sync here
// scheduleSyncOperation(operation)
}
} catch (error) {
handleDragError(error as Error)
} finally {
cleanupDragState()
}
}
function handleDragError(error: Error) {
console.error('🔥 Drag operation failed:', error)
state.lastError = error
// Rollback UI state if needed
rollbackLastOperation()
// Reset drag state
cleanupDragState()
// Could emit error event or show toast
// eventBus.emit('drag-error', error)
}
function cleanupDragState() {
state.isDragging = false
state.draggedItem = null
state.hoveredOver = null
state.validDrop = false
// Clean up drag preview
if (state.dragPreview) {
state.dragPreview.remove()
state.dragPreview = null
}
// Clear touch state
if (state.touchState.longPressTimer) {
clearTimeout(state.touchState.longPressTimer)
}
state.touchState = {
...state.touchState,
touchStartPos: null,
longPressTimer: null,
currentTouch: null
}
}
function rollbackLastOperation() {
const lastOp = state.operationQueue.at(-1)
if (lastOp && lastOp.status === 'pending') {
// Revert the optimistic update
console.log('🔄 Rolling back operation:', lastOp.id)
// Implementation depends on grid type
state.operationQueue = state.operationQueue.filter(op => op.id !== lastOp.id)
}
}
// API sync preparation
async function syncOperations() {
const pending = state.operationQueue.filter(op => op.status === 'pending')
for (const operation of pending) {
try {
// Future API call would go here
// await api.syncDragOperation(operation)
operation.status = 'synced'
console.log('✅ Operation synced:', operation.id)
} catch (error) {
operation.status = 'failed'
operation.retryCount++
if (operation.retryCount < 3) {
// Retry later
setTimeout(() => retryOperation(operation), 1000 * operation.retryCount)
} else {
// Max retries reached, handle failure
handleSyncFailure(operation)
}
}
}
}
function validateDrop(source: Position, target: Position): boolean {
console.group('🎯 Drop Validation')
console.log('Source:', source)
console.log('Target:', target)
try {
// Check item type compatibility
if (!isCompatibleType(source, target)) {
console.log('❌ Type mismatch')
return false
}
// Check position restrictions
if (!isValidPosition(target)) {
console.log('❌ Invalid target position')
return false
}
console.log('✅ Drop allowed')
return true
} catch (error) {
console.error('❌ Validation error:', error)
return false
} finally {
console.groupEnd()
}
}
return {
get state() { return state },
handlePointerDown,
handlePointerMove,
startDrag,
updateHover,
endDrag,
validateDrop,
syncOperations,
getQueuedOperations: () => state.operationQueue.filter(op => op.status === 'pending'),
clearQueue: () => { state.operationQueue = [] }
}
}
Enhanced Component Integration
<!-- DraggableItem.svelte -->
<script lang="ts">
import { getContext, onMount } from 'svelte'
interface Props {
item: GridItem
container: string
position: number
canDrag?: boolean
customPreview?: boolean
}
let { item, container, position, canDrag = true, customPreview = false }: Props = $props()
const dragContext = getContext('drag-drop')
let elementRef: HTMLElement
// Handle both mouse and touch events
function handleDragStart(e: DragEvent) {
if (!canDrag) {
e.preventDefault()
return
}
// Custom drag preview
if (customPreview) {
const ghost = createCustomGhost()
e.dataTransfer?.setDragImage(ghost, e.offsetX, e.offsetY)
// Clean up ghost after frame
requestAnimationFrame(() => ghost.remove())
}
// Set drag data
e.dataTransfer!.effectAllowed = 'move'
e.dataTransfer!.setData('application/json', JSON.stringify({
item,
container,
position
}))
dragContext.startDrag(item, { container, position })
}
function createCustomGhost(): HTMLElement {
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)'
ghost.style.opacity = '0.8'
// Clone the dragged element's content
ghost.innerHTML = elementRef.innerHTML
document.body.appendChild(ghost)
return ghost
}
// Touch support via pointer events
function handlePointerDown(e: PointerEvent) {
if (!canDrag) return
dragContext.handlePointerDown(e, item, { container, position })
}
function handlePointerMove(e: PointerEvent) {
dragContext.handlePointerMove(e)
}
function handlePointerUp(e: PointerEvent) {
if (dragContext.state.touchState.longPressTimer) {
clearTimeout(dragContext.state.touchState.longPressTimer)
}
}
onMount(() => {
// Add touch event listeners for better mobile support
if ('ontouchstart' in window) {
elementRef?.addEventListener('touchstart', handleTouchStart, { passive: false })
elementRef?.addEventListener('touchmove', handleTouchMove, { passive: false })
elementRef?.addEventListener('touchend', handleTouchEnd)
}
return () => {
if ('ontouchstart' in window) {
elementRef?.removeEventListener('touchstart', handleTouchStart)
elementRef?.removeEventListener('touchmove', handleTouchMove)
elementRef?.removeEventListener('touchend', handleTouchEnd)
}
}
})
// Prevent default touch behavior for better drag experience
function handleTouchStart(e: TouchEvent) {
if (!canDrag) return
e.preventDefault() // Prevent scrolling while dragging
}
function handleTouchMove(e: TouchEvent) {
if (dragContext.state.isDragging) {
e.preventDefault()
// Update drag preview position
updateDragPreviewPosition(e.touches[0])
}
}
function handleTouchEnd(e: TouchEvent) {
if (dragContext.state.isDragging) {
// Find drop target
const touch = e.changedTouches[0]
const dropTarget = document.elementFromPoint(touch.clientX, touch.clientY)
// Trigger drop on target
dragContext.endDrag()
}
}
function updateDragPreviewPosition(touch: Touch) {
if (dragContext.state.dragPreview) {
dragContext.state.dragPreview.style.left = `${touch.clientX}px`
dragContext.state.dragPreview.style.top = `${touch.clientY}px`
}
}
</script>
<div
bind:this={elementRef}
draggable={canDrag}
ondragstart={handleDragStart}
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
class:dragging={dragContext.state.isDragging &&
dragContext.state.draggedItem?.source.position === position}
class:can-drag={canDrag}
data-container={container}
data-position={position}
>
<slot />
</div>
<style lang="scss">
.dragging {
opacity: 0.5;
cursor: grabbing;
}
.can-drag {
cursor: grab;
touch-action: none; /* Prevent touch scrolling */
user-select: none;
&:active {
cursor: grabbing;
}
}
</style>
Grid State Management with Operation Queue
// Grid component state (e.g., CharacterGrid.svelte)
import { createDragDropContext } from '$lib/composables/drag-drop.svelte.ts'
// Local state that will sync with API
let characters = $state<GridCharacter[]>([
{ id: '1', position: 0, character: mockCharacter1 },
{ id: '2', position: 1, character: mockCharacter2 },
])
// Track sync status
let syncStatus = $state<'idle' | 'syncing' | 'error'>('idle')
let unsyncedChanges = $derived(dragContext.getQueuedOperations().length > 0)
const dragContext = createDragDropContext()
// Handle drop with optimistic update
function handleCharacterDrop(operation: DragOperation) {
// Optimistically update local state
const tempCharacters = [...characters]
if (operation.type === 'swap') {
// Swap positions
const sourceIndex = tempCharacters.findIndex(c => c.position === operation.source.position)
const targetIndex = tempCharacters.findIndex(c => c.position === operation.target.position)
if (sourceIndex !== -1 && targetIndex !== -1) {
[tempCharacters[sourceIndex], tempCharacters[targetIndex]] =
[tempCharacters[targetIndex], tempCharacters[sourceIndex]]
}
} else if (operation.type === 'move') {
// Move to empty slot
const sourceChar = tempCharacters.find(c => c.position === operation.source.position)
if (sourceChar) {
sourceChar.position = operation.target.position
}
}
// Apply optimistic update
characters = tempCharacters
// Queue for sync (will be processed when API is integrated)
console.log('📝 Operation queued for sync:', operation)
}
// Future: Auto-sync with API
$effect(() => {
if (unsyncedChanges && syncStatus === 'idle') {
// Debounce sync attempts
const timer = setTimeout(() => {
syncWithAPI()
}, 1000)
return () => clearTimeout(timer)
}
})
async function syncWithAPI() {
syncStatus = 'syncing'
try {
// Future API implementation
// await dragContext.syncOperations()
console.log('🔄 Would sync operations here:', dragContext.getQueuedOperations())
syncStatus = 'idle'
} catch (error) {
syncStatus = 'error'
console.error('Sync failed:', error)
}
}
Error Recovery Patterns
Automatic Retry Logic
class DragOperationError extends Error {
constructor(
message: string,
public operation: DragOperation,
public recoverable: boolean = true
) {
super(message)
this.name = 'DragOperationError'
}
}
async function retryOperation(operation: DragOperation) {
console.log(`🔄 Retrying operation ${operation.id} (attempt ${operation.retryCount + 1})`)
try {
// Future API call
// await api.syncDragOperation(operation)
operation.status = 'synced'
} catch (error) {
if (error instanceof DragOperationError && !error.recoverable) {
// Unrecoverable error, rollback
rollbackOperation(operation)
} else {
// Schedule another retry
operation.retryCount++
if (operation.retryCount < 3) {
setTimeout(() => retryOperation(operation), Math.pow(2, operation.retryCount) * 1000)
}
}
}
}
User Feedback for Sync Status
<!-- SyncStatusIndicator.svelte -->
<script lang="ts">
interface Props {
status: 'idle' | 'syncing' | 'error'
pendingCount: number
}
let { status, pendingCount }: Props = $props()
</script>
{#if pendingCount > 0}
<div class="sync-indicator" class:error={status === 'error'}>
{#if status === 'syncing'}
<span class="spinner">⟳</span> Saving changes...
{:else if status === 'error'}
<span class="error-icon">⚠</span> Sync failed - changes pending
<button onclick={retry}>Retry</button>
{:else}
<span class="pending-icon">●</span> {pendingCount} unsaved {pendingCount === 1 ? 'change' : 'changes'}
{/if}
</div>
{/if}
Custom Drag Preview Specifications
Visual Customization
.drag-preview {
position: fixed;
pointer-events: none;
z-index: 10000;
transform: rotate(3deg) scale(1.05);
opacity: 0.9;
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3));
transition: transform 0.2s ease-out;
.drag-preview-content {
background: var(--bg-primary);
border: 2px solid var(--accent-color);
border-radius: 8px;
padding: 8px;
display: flex;
align-items: center;
gap: 8px;
img {
width: 40px;
height: 40px;
border-radius: 4px;
}
span {
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
}
}
}
// Touch device specific styles
@media (hover: none) {
.drag-preview {
transform: translateY(-50px) scale(1.1); // Offset from finger
}
}
Touch/Mobile Interaction Patterns
Long Press Visual Feedback
.draggable-item {
position: relative;
&.long-press-active::before {
content: '';
position: absolute;
inset: -4px;
border: 2px solid var(--accent-color);
border-radius: 8px;
animation: pulse 0.5s ease-in-out;
pointer-events: none;
}
}
@keyframes pulse {
0% {
opacity: 0;
transform: scale(0.95);
}
50% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(1.05);
}
}
Touch Gesture Configurations
interface TouchConfig {
longPressDuration: number // 500ms default
touchThreshold: number // 10px movement threshold
scrollLockThreshold: number // 5px to lock scrolling
hapticFeedback: boolean // Enable vibration
visualFeedback: boolean // Show press indicator
}
// Allow customization per grid type
const characterGridTouchConfig: TouchConfig = {
longPressDuration: 400, // Faster for experienced users
touchThreshold: 15, // More forgiving
scrollLockThreshold: 5,
hapticFeedback: true,
visualFeedback: true
}
Validation Rules
Console Logging Pattern (Enhanced)
function validateDrop(source: Position, target: Position): boolean {
console.group('🎯 Drop Validation')
console.log('Source:', source)
console.log('Target:', target)
console.log('Queue Length:', state.operationQueue.length)
try {
// Check if operation would create invalid state
const wouldCreateInvalidState = checkStateValidity(source, target)
if (wouldCreateInvalidState) {
console.log('❌ Would create invalid state:', wouldCreateInvalidState)
return false
}
// Check item type compatibility
if (source.type !== target.expectedType) {
console.log('❌ Type mismatch:', source.type, 'vs', target.expectedType)
return false
}
// Check position restrictions
if (target.position === -1 && !target.allowMainhand) {
console.log('❌ Cannot drop in mainhand slot')
return false
}
// Check container transfer rules
if (source.container !== target.container) {
const transferAllowed = validateContainerTransfer(source.container, target.container)
if (!transferAllowed) {
console.log('❌ Container transfer not allowed')
return false
}
console.log('✅ Valid container transfer')
}
console.log('✅ Drop allowed')
return true
} catch (error) {
console.error('❌ Validation error:', error)
state.lastError = error as Error
return false
} finally {
console.groupEnd()
}
}
Testing Scenarios
Touch Device Testing
- Long press to initiate drag
- Drag across viewport boundaries
- Multi-touch rejection (only one drag at a time)
- Scroll prevention during drag
- Touch cancel handling (incoming call, notification)
Error Recovery Testing
- Simulate failed API calls
- Test retry mechanism with exponential backoff
- Verify rollback of optimistic updates
- Test max retry limit behavior
- Verify error state UI feedback
Operation Queue Testing
- Queue multiple operations rapidly
- Verify operations maintain order
- Test queue persistence (page refresh simulation)
- Test sync status indicators
- Verify batch operation handling
Drag Preview Testing
- Custom preview rendering
- Preview position relative to cursor/touch
- Preview cleanup on drag end
- Preview visibility across z-indexes
- Performance with complex preview content
Performance Optimizations
Touch Performance
// Debounce touch move events
let touchMoveFrame: number | null = null
function handleTouchMove(e: TouchEvent) {
if (touchMoveFrame) return
touchMoveFrame = requestAnimationFrame(() => {
updateDragPosition(e.touches[0])
touchMoveFrame = null
})
}
Operation Queue Optimization
// Batch operations for efficiency
function batchOperations(operations: DragOperation[]): BatchedOperation {
return {
id: crypto.randomUUID(),
operations: operations,
timestamp: Date.now(),
type: 'batch'
}
}
// Deduplicate redundant operations
function deduplicateQueue(queue: DragOperation[]): DragOperation[] {
const seen = new Map<string, DragOperation>()
for (const op of queue) {
const key = `${op.source.itemId}-${op.target.position}`
if (!seen.has(key) || op.timestamp > seen.get(key)!.timestamp) {
seen.set(key, op)
}
}
return Array.from(seen.values())
}
Future API Contract Requirements
Expected Endpoints
interface DragDropAPI {
// Single operation
syncOperation(operation: DragOperation): Promise<{ success: boolean, error?: string }>
// Batch operations
syncBatch(operations: DragOperation[]): Promise<BatchSyncResult>
// Get current state (for reconciliation)
getGridState(gridType: string, partyId: string): Promise<GridState>
// Validate operation before performing
validateOperation(operation: DragOperation): Promise<ValidationResult>
}
Optimistic Update Pattern
async function performOperationWithOptimisticUpdate(operation: DragOperation) {
// 1. Apply optimistic update immediately
applyLocalUpdate(operation)
// 2. Queue for sync
state.operationQueue.push(operation)
// 3. Attempt sync (non-blocking)
syncOperation(operation).catch(error => {
// 4. Rollback on failure
if (error.code === 'INVALID_STATE') {
rollbackOperation(operation)
// Fetch fresh state from server
refreshGridState()
}
})
}
Success Metrics
- All drag operations feel smooth (60fps)
- Touch interactions feel native and responsive
- Visual feedback is clear and immediate
- Error recovery is transparent to users
- Operation queue handles offline scenarios gracefully
- Custom drag previews enhance user experience
- Console logs provide useful debugging info
- No breaking changes to existing components
- Code is clean and follows Svelte 5 patterns
- Test page demonstrates all features including error scenarios
- API integration points are clearly defined
Implementation Notes
- Use Svelte 5 runes exclusively ($state, $derived, $effect)
- Implement pointer events for unified mouse/touch handling
- Keep operation queue in memory (localStorage for persistence if needed)
- Use RequestAnimationFrame for smooth animations
- Implement proper cleanup in component unmount
- Add comprehensive error boundaries
- Document API contract assumptions
- Follow existing SCSS module patterns
- Comment complex logic thoroughly
- Consider accessibility in future phases