minecraft-inventory 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -42,12 +42,17 @@ export function Slot({
42
42
  isDragging,
43
43
  dragSlots,
44
44
  dragButton,
45
+ dragPreview,
45
46
  startDrag,
46
47
  addDragSlot,
47
48
  endDrag,
48
49
  hoveredSlot,
49
50
  setHoveredSlot,
50
51
  activeNumberKey,
52
+ pKeyActive,
53
+ setPKeyActive,
54
+ focusedSlot,
55
+ setFocusedSlot,
51
56
  } = useInventoryContext()
52
57
 
53
58
  const { contentSize } = useScale()
@@ -89,6 +94,10 @@ export function Slot({
89
94
 
90
95
  const isHovered = hoveredSlot === index
91
96
  const isDragTarget = dragSlots.includes(index)
97
+ const dragPreviewEntry = dragPreview.get(index)
98
+ const isFocused = focusedSlot === index
99
+ const showPKeyNumber = pKeyActive && index >= 0 && index <= 99
100
+ const isInFocusSwapMode = focusedSlot !== null || pKeyActive
92
101
 
93
102
  // Keyboard number key while hovering
94
103
  useEffect(() => {
@@ -129,22 +138,44 @@ export function Slot({
129
138
  (e: React.MouseEvent) => {
130
139
  if (isMobile || disabled) return
131
140
  e.preventDefault()
141
+ e.stopPropagation()
132
142
  const button = e.button === 2 ? 'right' : e.button === 1 ? 'middle' : 'left'
133
143
  if (isDragging && dragSlots.length > 1) {
134
144
  endDrag()
135
145
  return
136
146
  }
147
+
148
+ // Focus/swap logic — active in P mode OR when a slot is already focused
149
+ if (button === 'left' && (pKeyActive || focusedSlot !== null)) {
150
+ if (pKeyActive) setPKeyActive(false)
151
+ if (focusedSlot === null) {
152
+ setFocusedSlot(index)
153
+ } else if (focusedSlot === index) {
154
+ setFocusedSlot(null)
155
+ } else {
156
+ sendAction({ type: 'click', slotIndex: focusedSlot, button: 'left', mode: 'normal' })
157
+ sendAction({ type: 'click', slotIndex: index, button: 'left', mode: 'normal' })
158
+ sendAction({ type: 'click', slotIndex: focusedSlot, button: 'left', mode: 'normal' })
159
+ setFocusedSlot(null)
160
+ }
161
+ if (isDragging) endDrag()
162
+ return
163
+ }
164
+
137
165
  const mode = e.shiftKey ? 'shift' : 'normal'
138
166
  if (onClickOverride) {
139
167
  onClickOverride(button, mode)
140
168
  } else {
141
- // Hide tooltip immediately when picking up or placing an item
142
- if (button === 'left' || button === 'right') setShowTooltip(false)
143
- sendAction({ type: 'click', slotIndex: index, button, mode })
169
+ if (resultSlot && heldItem && !item && mode === 'normal') {
170
+ // Cannot place items into result/output slots
171
+ } else {
172
+ if (button === 'left' || button === 'right') setShowTooltip(false)
173
+ sendAction({ type: 'click', slotIndex: index, button, mode })
174
+ }
144
175
  }
145
176
  if (isDragging) endDrag()
146
177
  },
147
- [isMobile, disabled, isDragging, dragSlots.length, sendAction, index, endDrag, onClickOverride],
178
+ [isMobile, disabled, isDragging, dragSlots.length, sendAction, index, endDrag, onClickOverride, resultSlot, heldItem, item, pKeyActive, setPKeyActive, focusedSlot, setFocusedSlot],
148
179
  )
149
180
 
150
181
  const handleDoubleClick = useCallback(
@@ -212,17 +243,30 @@ export function Slot({
212
243
  touchStartRef.current = null
213
244
  const touch = e.changedTouches[0]
214
245
  if (Math.abs(touch.clientX - start.x) > 10 || Math.abs(touch.clientY - start.y) > 10) return
246
+ e.stopPropagation()
247
+
248
+ if (pKeyActive) setPKeyActive(false)
215
249
 
216
250
  if (heldItem) {
251
+ // When holding an item, place it (standard behavior, no focus needed)
252
+ sendAction({ type: 'click', slotIndex: index, button: 'left', mode: 'normal' })
253
+ return
254
+ }
255
+
256
+ // On mobile, tapping always uses the focus/swap mechanism:
257
+ // first tap focuses, second tap on a different slot swaps, same slot clears.
258
+ if (focusedSlot === null) {
259
+ setFocusedSlot(index)
260
+ } else if (focusedSlot === index) {
261
+ setFocusedSlot(null)
262
+ } else {
263
+ sendAction({ type: 'click', slotIndex: focusedSlot, button: 'left', mode: 'normal' })
217
264
  sendAction({ type: 'click', slotIndex: index, button: 'left', mode: 'normal' })
218
- } else if (item) {
219
- const rect = slotRef.current?.getBoundingClientRect()
220
- if (rect) setMobileTouchPos({ x: rect.right, y: rect.top })
221
- setMobileMenuOpen(true)
222
- setShowTooltip(true)
265
+ sendAction({ type: 'click', slotIndex: focusedSlot, button: 'left', mode: 'normal' })
266
+ setFocusedSlot(null)
223
267
  }
224
268
  },
225
- [isMobile, disabled, heldItem, item, sendAction, index],
269
+ [isMobile, disabled, heldItem, sendAction, index, pKeyActive, setPKeyActive, focusedSlot, setFocusedSlot],
226
270
  )
227
271
 
228
272
  const handleMobilePickAll = useCallback(() => {
@@ -274,11 +318,13 @@ export function Slot({
274
318
  ]
275
319
  .filter(Boolean)
276
320
  .join(' ')}
321
+ tabIndex={index >= 0 ? 0 : undefined}
277
322
  style={{
278
323
  width: renderSize,
279
324
  height: renderSize,
280
325
  position: 'relative',
281
326
  flexShrink: 0,
327
+ ...(isFocused ? { outline: `2px dashed #ff0`, outlineOffset: -2, animation: 'mc-inv-focus-dash 0.5s linear infinite' } : {}),
282
328
  ...style,
283
329
  }}
284
330
  onMouseEnter={handleMouseEnter}
@@ -314,7 +360,58 @@ export function Slot({
314
360
  </div>
315
361
  )}
316
362
 
317
- {item && showTooltip && !mobileMenuOpen && (
363
+ {showPKeyNumber && (
364
+ <div
365
+ className="mc-inv-pkey-overlay"
366
+ style={{
367
+ position: 'absolute',
368
+ inset: 0,
369
+ background: 'rgba(255, 0, 0, 0.2)',
370
+ border: '1px solid rgba(255, 0, 0, 0.5)',
371
+ display: 'flex',
372
+ alignItems: 'center',
373
+ justifyContent: 'center',
374
+ paddingLeft: 4,
375
+ pointerEvents: 'none',
376
+ zIndex: 4,
377
+ boxSizing: 'border-box',
378
+ }}
379
+ >
380
+ <span
381
+ style={{
382
+ fontSize: Math.round(renderSize * 0.4),
383
+ fontFamily: "'Minecraftia', 'Minecraft', monospace",
384
+ color: '#ffffff',
385
+ textShadow: '1px 1px 0 rgba(0,0,0,0.7)',
386
+ lineHeight: 1,
387
+ }}
388
+ >
389
+ {String(index).padStart(2, '0')}
390
+ </span>
391
+ </div>
392
+ )}
393
+
394
+ {dragPreviewEntry && (
395
+ <div
396
+ className="mc-inv-drag-preview-count"
397
+ style={{
398
+ position: 'absolute',
399
+ right: 1,
400
+ bottom: 1,
401
+ fontSize: Math.round(renderSize * 0.45),
402
+ fontFamily: "'Minecraftia', 'Minecraft', monospace",
403
+ color: '#ffff00',
404
+ textShadow: '1px 1px 0 #3f3f00',
405
+ lineHeight: 1,
406
+ pointerEvents: 'none',
407
+ zIndex: 3,
408
+ }}
409
+ >
410
+ {dragPreviewEntry.count}
411
+ </div>
412
+ )}
413
+
414
+ {item && showTooltip && !mobileMenuOpen && !heldItem && (
318
415
  <Tooltip item={item} visible />
319
416
  )}
320
417
 
@@ -1,20 +1,52 @@
1
1
  import type { InventoryAction, InventoryWindowState, PlayerState, SlotState, ItemStack } from '../types'
2
2
  import type { InventoryConnector, ConnectorListener, ConnectorEvent, MineflayerBot } from './types'
3
3
 
4
- function botSlotToItemStack(slot: MineflayerBot['heldItem']): ItemStack | null {
5
- if (!slot || slot.type === -1 || slot.type === 0) return null
6
- return {
7
- type: slot.type,
8
- count: slot.count,
9
- metadata: slot.metadata,
10
- nbt: slot.nbt as Record<string, unknown> | undefined,
4
+ type RawSlot = { type: number; count: number; metadata?: number; nbt?: unknown }
5
+
6
+ /**
7
+ * Options for {@link createMineflayerConnector}.
8
+ */
9
+ export interface MineflayerConnectorOptions {
10
+ /**
11
+ * Custom item mapper called for every slot conversion from raw mineflayer data to
12
+ * {@link ItemStack}. Receives the raw slot data and the default-mapped stack.
13
+ * Return a modified stack to override fields (e.g. `name`, `textureKey`, `displayName`),
14
+ * or return the second argument unchanged to use the default mapping.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * createMineflayerConnector(bot, {
19
+ * itemMapper: (raw, mapped) => ({
20
+ * ...mapped,
21
+ * // Override texture for specific numeric type IDs:
22
+ * textureKey: raw.type === 438 ? 'item/potion_water' : mapped.textureKey,
23
+ * }),
24
+ * })
25
+ * ```
26
+ */
27
+ itemMapper?: (raw: RawSlot, mapped: ItemStack) => ItemStack
28
+ }
29
+
30
+ function makeSlotConverter(itemMapper?: MineflayerConnectorOptions['itemMapper']) {
31
+ return function botSlotToItemStack(slot: RawSlot | null | undefined): ItemStack | null {
32
+ if (!slot || slot.type === -1 || slot.type === 0) return null
33
+ const mapped: ItemStack = {
34
+ type: slot.type,
35
+ count: slot.count,
36
+ metadata: slot.metadata,
37
+ nbt: slot.nbt as Record<string, unknown> | undefined,
38
+ }
39
+ return itemMapper ? itemMapper(slot, mapped) : mapped
11
40
  }
12
41
  }
13
42
 
14
- function botSlotsToSlotStates(slots: MineflayerBot['inventory']['slots']): SlotState[] {
43
+ function botSlotsToSlotStates(
44
+ slots: MineflayerBot['inventory']['slots'],
45
+ convert: (slot: RawSlot | null | undefined) => ItemStack | null,
46
+ ): SlotState[] {
15
47
  return slots.map((slot, index) => ({
16
48
  index,
17
- item: botSlotToItemStack(slot),
49
+ item: convert(slot),
18
50
  }))
19
51
  }
20
52
 
@@ -67,23 +99,47 @@ function isBeaconWindow(win: unknown): win is { setBeaconEffects?: (primary: num
67
99
  return win != null && (typeof (win as Record<string, unknown>).setBeaconEffects === 'function' || /beacon/i.test(String((win as Record<string, unknown>).type)))
68
100
  }
69
101
 
70
- export function createMineflayerConnector(bot: MineflayerBot): InventoryConnector {
102
+ export function createMineflayerConnector(bot: MineflayerBot, options?: MineflayerConnectorOptions): InventoryConnector {
71
103
  const listeners = new Set<ConnectorListener>()
72
104
  const ext = bot as MineflayerBotExtended
105
+ const convert = makeSlotConverter(options?.itemMapper)
73
106
 
74
107
  function emit(event: ConnectorEvent) {
75
108
  listeners.forEach((l) => l(event))
76
109
  }
77
110
 
111
+ /**
112
+ * Builds a window state from the currently open window, OR from `bot.inventory`
113
+ * when no container is open (exposing the player's own inventory as a synthetic
114
+ * 'player' window with windowId = 0).
115
+ */
78
116
  function buildWindowState(): InventoryWindowState | null {
79
117
  const win = bot.currentWindow
80
- if (!win) return null
118
+ if (win) {
119
+ return {
120
+ windowId: win.id,
121
+ type: win.type ?? 'unknown',
122
+ title: win.title,
123
+ slots: botSlotsToSlotStates(win.slots, convert),
124
+ heldItem: convert(bot.heldItem),
125
+ }
126
+ }
127
+ // No open container — expose the player inventory as a synthetic 'player' window.
128
+ const invSlots: SlotState[] = []
129
+ // Slots 0–8: crafting/armour — leave empty (not accessible from bot.inventory directly)
130
+ for (let i = 0; i < 9; i++) invSlots.push({ index: i, item: null })
131
+ // Slots 9–35: main inventory
132
+ for (let i = 9; i <= 35; i++) invSlots.push({ index: i, item: convert(bot.inventory.slots[i]) })
133
+ // Slots 36–44: hotbar
134
+ for (let i = 36; i <= 44; i++) invSlots.push({ index: i, item: convert(bot.inventory.slots[i]) })
135
+ // Slot 45: offhand
136
+ invSlots.push({ index: 45, item: convert(bot.inventory.slots[45]) })
81
137
  return {
82
- windowId: win.id,
83
- type: win.type ?? 'unknown',
84
- title: win.title,
85
- slots: botSlotsToSlotStates(win.slots),
86
- heldItem: botSlotToItemStack(bot.heldItem),
138
+ windowId: 0,
139
+ type: 'player',
140
+ title: undefined,
141
+ slots: invSlots,
142
+ heldItem: convert(bot.heldItem),
87
143
  }
88
144
  }
89
145
 
@@ -91,7 +147,7 @@ export function createMineflayerConnector(bot: MineflayerBot): InventoryConnecto
91
147
  const inv = bot.inventory.slots
92
148
  return {
93
149
  activeHotbarSlot: bot.quickBarSlot,
94
- inventory: botSlotsToSlotStates(inv),
150
+ inventory: botSlotsToSlotStates(inv, convert),
95
151
  }
96
152
  }
97
153
 
@@ -135,21 +191,20 @@ export function createMineflayerConnector(bot: MineflayerBot): InventoryConnecto
135
191
  // Llama inventory structure (per registry):
136
192
  // Slot 0: Carpet (saddle) - empty since we don't have entity data
137
193
  slots.push({ index: 0, item: null })
138
- // Slot 1: skipped (not used)
139
194
  // Slots 2-16: Llama chest (5×3 grid = 15 slots) - empty since we don't have entity data
140
195
  for (let i = 2; i <= 16; i++) {
141
196
  slots.push({ index: i, item: null })
142
197
  }
143
198
  // Slots 17-43: Player inventory (bot.inventory.slots indices 9-35 map to window slots 17-43)
144
199
  for (let i = 9; i <= 35; i++) {
145
- slots.push({ index: i + 8, item: botSlotToItemStack(bot.inventory.slots[i]) })
200
+ slots.push({ index: i + 8, item: convert(bot.inventory.slots[i]) })
146
201
  }
147
202
  // Slots 44-52: Hotbar (bot.inventory.slots indices 36-44 map to window slots 44-52)
148
203
  for (let i = 36; i <= 44; i++) {
149
- slots.push({ index: i + 8, item: botSlotToItemStack(bot.inventory.slots[i]) })
204
+ slots.push({ index: i + 8, item: convert(bot.inventory.slots[i]) })
150
205
  }
151
206
  // Slot 53: Offhand (bot.inventory slot 45 maps to window slot 53)
152
- slots.push({ index: 53, item: botSlotToItemStack(bot.inventory.slots[45]) })
207
+ slots.push({ index: 53, item: convert(bot.inventory.slots[45]) })
153
208
  } else {
154
209
  // Player inventory window structure (per registry):
155
210
  // Slots 0-8: Crafting result + grid + armor (not in bot.inventory.slots, leave empty)
@@ -158,14 +213,14 @@ export function createMineflayerConnector(bot: MineflayerBot): InventoryConnecto
158
213
  }
159
214
  // Slots 9-35: Player inventory (bot.inventory.slots indices 9-35)
160
215
  for (let i = 9; i <= 35; i++) {
161
- slots.push({ index: i, item: botSlotToItemStack(bot.inventory.slots[i]) })
216
+ slots.push({ index: i, item: convert(bot.inventory.slots[i]) })
162
217
  }
163
218
  // Slots 36-44: Hotbar (bot.inventory.slots indices 36-44)
164
219
  for (let i = 36; i <= 44; i++) {
165
- slots.push({ index: i, item: botSlotToItemStack(bot.inventory.slots[i]) })
220
+ slots.push({ index: i, item: convert(bot.inventory.slots[i]) })
166
221
  }
167
222
  // Slot 45: Offhand (bot.inventory slot 45)
168
- slots.push({ index: 45, item: botSlotToItemStack(bot.inventory.slots[45]) })
223
+ slots.push({ index: 45, item: convert(bot.inventory.slots[45]) })
169
224
  }
170
225
 
171
226
  const windowState: InventoryWindowState = {
@@ -173,7 +228,7 @@ export function createMineflayerConnector(bot: MineflayerBot): InventoryConnecto
173
228
  type: inventoryType,
174
229
  title: inventoryType === 'llama' ? 'Llama' : undefined,
175
230
  slots,
176
- heldItem: botSlotToItemStack(bot.heldItem),
231
+ heldItem: convert(bot.heldItem),
177
232
  }
178
233
 
179
234
  emit({ type: 'windowOpen', state: windowState })
@@ -1,6 +1,11 @@
1
- import React, { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react'
1
+ import React, { createContext, useContext, useEffect, useRef, useState, useCallback, useMemo } from 'react'
2
2
  import type { InventoryWindowState, PlayerState, ItemStack, SlotState } from '../types'
3
3
  import type { InventoryConnector } from '../connector/types'
4
+ import { isItemEqual, getMaxStackSize } from '../utils/isItemEqual'
5
+
6
+ export interface DragPreviewEntry {
7
+ count: number
8
+ }
4
9
 
5
10
  export interface InventoryContextValue {
6
11
  windowState: InventoryWindowState | null
@@ -12,6 +17,8 @@ export interface InventoryContextValue {
12
17
  isDragging: boolean
13
18
  dragSlots: number[]
14
19
  dragButton: 'left' | 'right' | null
20
+ /** Client-side preview of item counts for each slot in the current drag */
21
+ dragPreview: Map<number, DragPreviewEntry>
15
22
  startDrag: (slotIndex: number, button: 'left' | 'right') => void
16
23
  addDragSlot: (slotIndex: number) => void
17
24
  endDrag: () => void
@@ -24,6 +31,17 @@ export interface InventoryContextValue {
24
31
  /** Pixel offset from the cursor item's top-left to the grab point, preserving pick-up position */
25
32
  grabOffset: { x: number; y: number }
26
33
  setGrabOffset: (offset: { x: number; y: number }) => void
34
+ /** Whether drag/spread operations are disabled */
35
+ noDragSpread: boolean
36
+ /** Whether P-key slot numbering mode is active */
37
+ pKeyActive: boolean
38
+ setPKeyActive: (v: boolean) => void
39
+ /** Currently focused slot index (via P-key number entry) */
40
+ focusedSlot: number | null
41
+ setFocusedSlot: (slot: number | null) => void
42
+ /** Pending first digit for P-key slot number entry */
43
+ pKeyDigit: string
44
+ setPKeyDigit: (d: string) => void
27
45
  }
28
46
 
29
47
  const InventoryContext = createContext<InventoryContextValue | null>(null)
@@ -37,9 +55,10 @@ export function useInventoryContext(): InventoryContextValue {
37
55
  interface InventoryProviderProps {
38
56
  connector: InventoryConnector | null
39
57
  children: React.ReactNode
58
+ noDragSpread?: boolean
40
59
  }
41
60
 
42
- export function InventoryProvider({ connector, children }: InventoryProviderProps) {
61
+ export function InventoryProvider({ connector, children, noDragSpread = false }: InventoryProviderProps) {
43
62
  const [windowState, setWindowState] = useState<InventoryWindowState | null>(
44
63
  () => connector?.getWindowState() ?? null,
45
64
  )
@@ -53,12 +72,24 @@ export function InventoryProvider({ connector, children }: InventoryProviderProp
53
72
  const [isDragging, setIsDragging] = useState(false)
54
73
  const [dragSlots, setDragSlots] = useState<number[]>([])
55
74
  const [dragButton, setDragButton] = useState<'left' | 'right' | null>(null)
75
+ const [dragPreview, setDragPreview] = useState<Map<number, DragPreviewEntry>>(new Map())
56
76
  const [activeNumberKey, setActiveNumberKey] = useState<number | null>(null)
57
77
  const [grabOffset, setGrabOffset] = useState<{ x: number; y: number }>({ x: 0, y: 0 })
78
+ const [pKeyActive, setPKeyActive] = useState(false)
79
+ const [focusedSlot, setFocusedSlot] = useState<number | null>(null)
80
+ const [pKeyDigit, setPKeyDigit] = useState('')
58
81
 
59
82
  const connectorRef = useRef(connector)
60
83
  connectorRef.current = connector
61
84
 
85
+ // Refs so endDrag can read current values without being in its dep array
86
+ const heldItemRef = useRef(heldItem)
87
+ heldItemRef.current = heldItem
88
+ const windowStateRef = useRef(windowState)
89
+ windowStateRef.current = windowState
90
+ const dragButtonRef = useRef(dragButton)
91
+ dragButtonRef.current = dragButton
92
+
62
93
  useEffect(() => {
63
94
  if (!connector) return
64
95
  setWindowState(connector.getWindowState())
@@ -92,35 +123,130 @@ export function InventoryProvider({ connector, children }: InventoryProviderProp
92
123
  [],
93
124
  )
94
125
 
126
+ const computeDragPreview = useCallback((slots: number[], button: 'left' | 'right', held: ItemStack | null, ws: InventoryWindowState | null) => {
127
+ const preview = new Map<number, DragPreviewEntry>()
128
+ if (!held || slots.length === 0) return preview
129
+
130
+ const maxStack = getMaxStackSize(held)
131
+
132
+ if (button === 'left') {
133
+ // Only spread into compatible slots (empty or same item type)
134
+ const compatibleSlots = slots.filter((idx) => {
135
+ const existingItem = ws?.slots.find((s) => s.index === idx)?.item
136
+ return !existingItem || isItemEqual(existingItem, held)
137
+ })
138
+ if (compatibleSlots.length === 0) return preview
139
+ const perSlot = Math.floor(held.count / compatibleSlots.length)
140
+ let remainder = held.count % compatibleSlots.length
141
+ for (const idx of compatibleSlots) {
142
+ const existingCount = ws?.slots.find((s) => s.index === idx)?.item?.count ?? 0
143
+ const add = perSlot + (remainder > 0 ? 1 : 0)
144
+ if (remainder > 0) remainder--
145
+ const total = Math.min(existingCount + add, maxStack)
146
+ preview.set(idx, { count: total })
147
+ }
148
+ } else {
149
+ for (const idx of slots) {
150
+ const existing = ws?.slots.find((s) => s.index === idx)
151
+ const existingItem = existing?.item
152
+ if (existingItem && !isItemEqual(existingItem, held)) continue
153
+ const existingCount = existingItem ? existingItem.count : 0
154
+ const total = Math.min(existingCount + 1, maxStack)
155
+ preview.set(idx, { count: total })
156
+ }
157
+ }
158
+ return preview
159
+ }, [])
160
+
95
161
  const startDrag = useCallback((slotIndex: number, button: 'left' | 'right') => {
162
+ if (noDragSpread) return
96
163
  setIsDragging(true)
97
164
  setDragButton(button)
98
165
  setDragSlots([slotIndex])
99
- connectorRef.current?.sendAction({ type: 'drag', slots: [], button })
100
- }, [])
166
+ setDragPreview(new Map())
167
+ }, [noDragSpread])
101
168
 
102
169
  const addDragSlot = useCallback((slotIndex: number) => {
103
170
  setDragSlots((prev) => {
104
171
  if (prev.includes(slotIndex)) return prev
105
- return [...prev, slotIndex]
172
+ const next = [...prev, slotIndex]
173
+ setDragPreview(computeDragPreview(next, dragButton!, heldItem, windowState))
174
+ return next
106
175
  })
107
- }, [])
176
+ }, [computeDragPreview, dragButton, heldItem, windowState])
108
177
 
109
178
  const endDrag = useCallback(() => {
110
179
  setDragSlots((slots) => {
111
- if (slots.length > 0 && dragButton) {
112
- connectorRef.current?.sendAction({ type: 'drag', slots, button: dragButton })
180
+ const button = dragButtonRef.current
181
+ const held = heldItemRef.current
182
+ const ws = windowStateRef.current
183
+
184
+ // Only send drag action if multiple slots were involved (single slot = normal click)
185
+ if (slots.length > 1 && button && held) {
186
+ connectorRef.current?.sendAction({ type: 'drag', slots, button })
187
+
188
+ // Optimistic client-side update: apply item distribution immediately
189
+ // so slots visually update before server responds.
190
+ const maxStack = getMaxStackSize(held)
191
+ const newSlots = ws ? [...ws.slots] : []
192
+
193
+ if (button === 'left') {
194
+ const compatibleSlots = slots.filter((idx) => {
195
+ const existing = newSlots.find((s) => s.index === idx)?.item
196
+ return !existing || isItemEqual(existing, held)
197
+ })
198
+ if (compatibleSlots.length > 0) {
199
+ const perSlot = Math.floor(held.count / compatibleSlots.length)
200
+ let remainder = held.count % compatibleSlots.length
201
+ for (const idx of compatibleSlots) {
202
+ const existingIdx = newSlots.findIndex((s) => s.index === idx)
203
+ const existingCount = existingIdx >= 0 ? (newSlots[existingIdx].item?.count ?? 0) : 0
204
+ const add = perSlot + (remainder > 0 ? 1 : 0)
205
+ if (remainder > 0) remainder--
206
+ const newCount = Math.min(existingCount + add, maxStack)
207
+ if (existingIdx >= 0) {
208
+ newSlots[existingIdx] = { index: idx, item: { ...held, count: newCount } }
209
+ } else {
210
+ newSlots.push({ index: idx, item: { ...held, count: newCount } })
211
+ }
212
+ }
213
+ if (ws) setWindowState({ ...ws, slots: newSlots })
214
+ setHeldItemState(null)
215
+ }
216
+ } else {
217
+ // Right-click drag: place 1 per slot
218
+ let remaining = held.count
219
+ for (const idx of slots) {
220
+ if (remaining <= 0) break
221
+ const existingIdx = newSlots.findIndex((s) => s.index === idx)
222
+ const existingItem = existingIdx >= 0 ? newSlots[existingIdx].item : null
223
+ if (existingItem && !isItemEqual(existingItem, held)) continue
224
+ const existingCount = existingItem?.count ?? 0
225
+ const newCount = Math.min(existingCount + 1, maxStack)
226
+ if (existingIdx >= 0) {
227
+ newSlots[existingIdx] = { index: idx, item: { ...held, count: newCount } }
228
+ } else {
229
+ newSlots.push({ index: idx, item: { ...held, count: newCount } })
230
+ }
231
+ remaining--
232
+ }
233
+ if (ws) setWindowState({ ...ws, slots: newSlots })
234
+ if (remaining <= 0) setHeldItemState(null)
235
+ else setHeldItemState({ ...held, count: remaining })
236
+ }
113
237
  }
114
238
  return []
115
239
  })
116
240
  setIsDragging(false)
117
241
  setDragButton(null)
118
- }, [dragButton])
242
+ setDragPreview(new Map())
243
+ }, [])
119
244
 
120
245
  const cancelDrag = useCallback(() => {
121
246
  setIsDragging(false)
122
247
  setDragSlots([])
123
248
  setDragButton(null)
249
+ setDragPreview(new Map())
124
250
  }, [])
125
251
 
126
252
  const getSlot = useCallback(
@@ -140,6 +266,7 @@ export function InventoryProvider({ connector, children }: InventoryProviderProp
140
266
  isDragging,
141
267
  dragSlots,
142
268
  dragButton,
269
+ dragPreview,
143
270
  startDrag,
144
271
  addDragSlot,
145
272
  endDrag,
@@ -151,6 +278,13 @@ export function InventoryProvider({ connector, children }: InventoryProviderProp
151
278
  getSlot,
152
279
  grabOffset,
153
280
  setGrabOffset,
281
+ noDragSpread,
282
+ pKeyActive,
283
+ setPKeyActive,
284
+ focusedSlot,
285
+ setFocusedSlot,
286
+ pKeyDigit,
287
+ setPKeyDigit,
154
288
  }
155
289
 
156
290
  return <InventoryContext.Provider value={value}>{children}</InventoryContext.Provider>
@@ -9,7 +9,13 @@ export const MC_GUI_BASE = MC_ASSETS_BASE
9
9
 
10
10
  export interface TextureConfig {
11
11
  baseUrl: string
12
- getItemTextureUrl(item: { type: number; name?: string }): string
12
+ /**
13
+ * Resolve item texture URL.
14
+ * When `item.textureKey` is set it is used as the path relative to the items texture root
15
+ * (e.g. `"item/dye_black"` → `<base>/item/dye_black.png`).
16
+ * Otherwise falls back to `name` then `type`.
17
+ */
18
+ getItemTextureUrl(item: { type: number; name?: string; textureKey?: string }): string
13
19
  getBlockTextureUrl(item: { type: number; name?: string }): string
14
20
  /** Supports full mc-assets paths (e.g. "1.21.11/textures/gui/container/anvil.png") */
15
21
  getGuiTextureUrl(path: string, version?: string): string
@@ -23,8 +29,9 @@ function buildDefault(base: string): TextureConfig {
23
29
  return {
24
30
  baseUrl: base,
25
31
 
26
- getItemTextureUrl({ type, name }) {
32
+ getItemTextureUrl({ type, name, textureKey }) {
27
33
  const root = isRemote ? MC_ITEMS_BASE : base
34
+ if (textureKey) return `${root}/${textureKey}.png`
28
35
  if (name) return `${root}/item/${name}.png`
29
36
  return `${root}/item/${type}.png`
30
37
  },