import React, { createContext, useContext, useEffect, useRef, useState, useCallback, useMemo } from 'react' import type { InventoryWindowState, PlayerState, ItemStack, SlotState } from '../types' import type { InventoryConnector } from '../connector/types' import { isItemEqual, getMaxStackSize } from '../utils/isItemEqual' import { createInventoryDebugSession, getActionSlotIndexes, summarizeAction, summarizeItem, summarizeSlots, summarizeWindowState, type InventoryDebugSession, } from '../debug/inventoryDebug' export interface DragPreviewEntry { count: number } export interface InventoryContextValue { windowState: InventoryWindowState | null playerState: PlayerState | null heldItem: ItemStack | null setHeldItem: (item: ItemStack | null) => void connector: InventoryConnector | null sendAction: InventoryConnector['sendAction'] isDragging: boolean dragSlots: number[] dragButton: 'left' | 'right' | null /** Client-side preview of item counts for each slot in the current drag */ dragPreview: Map /** Number of items remaining on the cursor during an active spread drag */ dragRemainder: number | null startDrag: (slotIndex: number, button: 'left' | 'right') => void addDragSlot: (slotIndex: number) => void endDrag: () => void cancelDrag: () => void hoveredSlot: number | null setHoveredSlot: (slot: number | null) => void activeNumberKey: number | null setActiveNumberKey: (key: number | null) => void getSlot: (index: number) => SlotState | undefined /** Pixel offset from the cursor item's top-left to the grab point, preserving pick-up position */ grabOffset: { x: number; y: number } setGrabOffset: (offset: { x: number; y: number }) => void /** Whether drag/spread operations are disabled */ noDragSpread: boolean /** Whether P-key slot numbering mode is active */ pKeyActive: boolean setPKeyActive: (v: boolean) => void /** Currently focused slot index (via P-key number entry) */ focusedSlot: number | null setFocusedSlot: (slot: number | null) => void /** Pending first digit for P-key slot number entry */ pKeyDigit: string setPKeyDigit: (d: string) => void /** Ref set to true when a drag just ended; cleared on next mouseDown. * Used by Slot to suppress spurious click events that fire after endDrag. */ dragEndedRef: React.MutableRefObject /** When true, empty slot labels (Head, Chest, Legs, etc.) are not rendered */ noPlaceholders: boolean /** Optional resolver for enchantment ID → display name (version-aware) */ resolveEnchantmentName?: (id: number) => string | undefined } const InventoryContext = createContext(null) export function useInventoryContext(): InventoryContextValue { const ctx = useContext(InventoryContext) if (!ctx) throw new Error('useInventoryContext must be used within InventoryProvider') return ctx } interface InventoryProviderProps { connector: InventoryConnector | null children: React.ReactNode noDragSpread?: boolean noPlaceholders?: boolean resolveEnchantmentName?: (id: number) => string | undefined } export function InventoryProvider({ connector, children, noDragSpread = false, noPlaceholders = false, resolveEnchantmentName }: InventoryProviderProps) { const [windowState, setWindowState] = useState( () => connector?.getWindowState() ?? null, ) const [playerState, setPlayerState] = useState( () => connector?.getPlayerState() ?? null, ) const [heldItem, setHeldItemState] = useState( () => connector?.getWindowState()?.heldItem ?? null, ) const [hoveredSlot, setHoveredSlot] = useState(null) const [isDragging, setIsDragging] = useState(false) const [dragSlots, setDragSlots] = useState([]) const [dragButton, setDragButton] = useState<'left' | 'right' | null>(null) const [dragPreview, setDragPreview] = useState>(new Map()) const [dragRemainder, setDragRemainder] = useState(null) const [activeNumberKey, setActiveNumberKey] = useState(null) const [grabOffset, setGrabOffset] = useState<{ x: number; y: number }>({ x: 0, y: 0 }) const [pKeyActive, setPKeyActive] = useState(false) const [focusedSlot, setFocusedSlot] = useState(null) const [pKeyDigit, setPKeyDigit] = useState('') const connectorRef = useRef(connector) connectorRef.current = connector // Refs so endDrag can read current values without being in its dep array const heldItemRef = useRef(heldItem) heldItemRef.current = heldItem const windowStateRef = useRef(windowState) windowStateRef.current = windowState const dragButtonRef = useRef(dragButton) dragButtonRef.current = dragButton // Set to true when endDrag fires; cleared on the next mouseDown in Slot. // Prevents spurious mouseUp events from sending unwanted clicks after a drag. const dragEndedRef = useRef(false) /** Latest full context value; used by the DevTools debug API (`globalThis.__mcInv.state`). */ const valueRef = useRef(null) const debugSessionRef = useRef(null) useEffect(() => { const session = createInventoryDebugSession('inventory-provider', () => { const value = valueRef.current if (!value) return null return { windowState: value.windowState, playerState: value.playerState, heldItem: value.heldItem, isDragging: value.isDragging, dragSlots: [...value.dragSlots], } }) debugSessionRef.current = session session.log({ event: 'provider.mount' }) return () => { session.log({ event: 'provider.unmount' }) session.dispose() if (debugSessionRef.current === session) debugSessionRef.current = null } }, []) useEffect(() => { if (!connector) return setWindowState(connector.getWindowState()) setPlayerState(connector.getPlayerState()) return connector.subscribe((event) => { if (event.type === 'windowOpen' || event.type === 'windowUpdate') { debugSessionRef.current?.log({ event: `connector.${event.type}`, windowId: event.state.windowId, windowType: event.state.type, slots: summarizeSlots(event.state.slots), heldItem: summarizeItem(event.state.heldItem), }) setWindowState({ ...event.state }) setHeldItemState(event.state.heldItem) } else if (event.type === 'windowClose') { debugSessionRef.current?.log({ event: 'connector.windowClose' }) setWindowState(null) setHeldItemState(null) setIsDragging(false) setDragSlots([]) } else if (event.type === 'playerUpdate') { debugSessionRef.current?.log({ event: 'connector.playerUpdate', slots: summarizeSlots(event.state.inventory), data: { activeHotbarSlot: event.state.activeHotbarSlot, }, }) setPlayerState(event.state) } else if (event.type === 'heldItemChange') { debugSessionRef.current?.log({ event: 'connector.heldItemChange', heldItem: summarizeItem(event.item), }) setHeldItemState(event.item) } }) }, [connector]) const setHeldItem = useCallback((item: ItemStack | null) => { setHeldItemState(item) }, []) const sendAction = useCallback( (action) => { const slotIndexes = getActionSlotIndexes(action) const state = valueRef.current?.windowState ?? connectorRef.current?.getWindowState() ?? null debugSessionRef.current?.log({ event: 'ui.action', windowId: state?.windowId, windowType: state?.type, action: summarizeAction(action), slots: state ? summarizeSlots(state.slots, slotIndexes) : undefined, heldItem: summarizeItem(valueRef.current?.heldItem ?? state?.heldItem), data: { before: summarizeWindowState(state, slotIndexes), }, }) return connectorRef.current?.sendAction(action) }, [], ) const computeDragPreview = useCallback((slots: number[], button: 'left' | 'right', held: ItemStack | null, ws: InventoryWindowState | null): { preview: Map; remainder: number } => { const preview = new Map() if (!held || slots.length === 0) return { preview, remainder: held?.count ?? 0 } const maxStack = getMaxStackSize(held) let totalPlaced = 0 if (button === 'left') { // Only spread into compatible slots (empty or same item type) const compatibleSlots = slots.filter((idx) => { const existingItem = ws?.slots.find((s) => s.index === idx)?.item return !existingItem || isItemEqual(existingItem, held) }) if (compatibleSlots.length === 0) return { preview, remainder: held.count } const perSlot = Math.floor(held.count / compatibleSlots.length) // Vanilla behavior: if perSlot=0 (more slots than items), nothing is distributed if (perSlot === 0) return { preview, remainder: held.count } for (const idx of compatibleSlots) { const existingCount = ws?.slots.find((s) => s.index === idx)?.item?.count ?? 0 const total = Math.min(existingCount + perSlot, maxStack) const added = total - existingCount totalPlaced += added preview.set(idx, { count: total }) } } else { for (const idx of slots) { const existing = ws?.slots.find((s) => s.index === idx) const existingItem = existing?.item if (existingItem && !isItemEqual(existingItem, held)) continue const existingCount = existingItem ? existingItem.count : 0 const total = Math.min(existingCount + 1, maxStack) const added = total - existingCount totalPlaced += added preview.set(idx, { count: total }) } } return { preview, remainder: held.count - totalPlaced } }, []) const startDrag = useCallback((slotIndex: number, button: 'left' | 'right') => { if (noDragSpread) return dragEndedRef.current = false setIsDragging(true) setDragButton(button) setDragSlots([slotIndex]) setDragPreview(new Map()) setDragRemainder(null) }, [noDragSpread]) const addDragSlot = useCallback((slotIndex: number) => { setDragSlots((prev) => { if (prev.includes(slotIndex)) return prev const next = [...prev, slotIndex] const result = computeDragPreview(next, dragButton!, heldItem, windowState) setDragPreview(result.preview) setDragRemainder(result.remainder) return next }) }, [computeDragPreview, dragButton, heldItem, windowState]) const endDrag = useCallback(() => { dragEndedRef.current = true setDragSlots((slots) => { const button = dragButtonRef.current const held = heldItemRef.current const ws = windowStateRef.current // Only send drag action if multiple slots were involved (single slot = normal click) if (slots.length > 1 && button && held) { debugSessionRef.current?.log({ event: 'ui.drag.optimisticUpdate', windowId: ws?.windowId, windowType: ws?.type, action: summarizeAction({ type: 'drag', slots, button }), slots: ws ? summarizeSlots(ws.slots, slots) : undefined, heldItem: summarizeItem(held), data: { before: summarizeWindowState(ws, slots), }, }) connectorRef.current?.sendAction({ type: 'drag', slots, button }) // Optimistic client-side update: apply item distribution immediately // so slots visually update before server responds. const maxStack = getMaxStackSize(held) const newSlots = ws ? [...ws.slots] : [] if (button === 'left') { const compatibleSlots = slots.filter((idx) => { const existing = newSlots.find((s) => s.index === idx)?.item return !existing || isItemEqual(existing, held) }) if (compatibleSlots.length > 0) { const perSlot = Math.floor(held.count / compatibleSlots.length) if (perSlot > 0) { let totalPlaced = 0 for (const idx of compatibleSlots) { const existingIdx = newSlots.findIndex((s) => s.index === idx) const existingCount = existingIdx >= 0 ? (newSlots[existingIdx].item?.count ?? 0) : 0 const add = Math.min(perSlot, maxStack - existingCount) totalPlaced += add const newCount = existingCount + add if (existingIdx >= 0) { newSlots[existingIdx] = { index: idx, item: { ...held, count: newCount } } } else { newSlots.push({ index: idx, item: { ...held, count: newCount } }) } } if (ws) setWindowState({ ...ws, slots: newSlots }) const remaining = held.count - totalPlaced if (remaining > 0) setHeldItemState({ ...held, count: remaining }) else setHeldItemState(null) } } } else { // Right-click drag: place 1 per slot let remaining = held.count for (const idx of slots) { if (remaining <= 0) break const existingIdx = newSlots.findIndex((s) => s.index === idx) const existingItem = existingIdx >= 0 ? newSlots[existingIdx].item : null if (existingItem && !isItemEqual(existingItem, held)) continue const existingCount = existingItem?.count ?? 0 const newCount = Math.min(existingCount + 1, maxStack) if (existingIdx >= 0) { newSlots[existingIdx] = { index: idx, item: { ...held, count: newCount } } } else { newSlots.push({ index: idx, item: { ...held, count: newCount } }) } remaining-- } if (ws) setWindowState({ ...ws, slots: newSlots }) if (remaining <= 0) setHeldItemState(null) else setHeldItemState({ ...held, count: remaining }) } } return [] }) setIsDragging(false) setDragButton(null) setDragPreview(new Map()) setDragRemainder(null) }, []) const cancelDrag = useCallback(() => { setIsDragging(false) setDragSlots([]) setDragButton(null) setDragPreview(new Map()) setDragRemainder(null) }, []) // Global mouseup listener to finalize drag when the button is released outside slot elements. // Slot.handleMouseUp calls e.stopPropagation(), so when mouseup lands on a slot the window // listener does NOT fire — the two handlers are mutually exclusive. useEffect(() => { if (!isDragging) return const handleGlobalMouseUp = () => { endDrag() } const handleBlur = () => { cancelDrag() } window.addEventListener('mouseup', handleGlobalMouseUp) window.addEventListener('blur', handleBlur) return () => { window.removeEventListener('mouseup', handleGlobalMouseUp) window.removeEventListener('blur', handleBlur) } }, [isDragging, endDrag, cancelDrag]) const getSlot = useCallback( (index: number) => { return windowState?.slots.find((s) => s.index === index) }, [windowState], ) const value: InventoryContextValue = { windowState, playerState, heldItem, setHeldItem, connector, sendAction, isDragging, dragSlots, dragButton, dragPreview, dragRemainder, startDrag, addDragSlot, endDrag, cancelDrag, hoveredSlot, setHoveredSlot, activeNumberKey, setActiveNumberKey, getSlot, grabOffset, setGrabOffset, noDragSpread, noPlaceholders, pKeyActive, setPKeyActive, focusedSlot, setFocusedSlot, pKeyDigit, setPKeyDigit, dragEndedRef, resolveEnchantmentName, } valueRef.current = value return {children} }