minecraft-inventory 0.1.43 → 0.1.45

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minecraft-inventory",
3
- "version": "0.1.43",
3
+ "version": "0.1.45",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "release": {
@@ -353,7 +353,7 @@ export function InventoryOverlay({
353
353
  lineHeight: 1,
354
354
  }}
355
355
  >
356
- INV 0.1.43
356
+ INV 0.1.45
357
357
  </a>
358
358
  )}
359
359
 
@@ -130,7 +130,7 @@ export function HotbarExtras({ showOffhand, container, offhandItem }: HotbarExtr
130
130
  />
131
131
  {/* Item at slot 45 (offhand) */}
132
132
  <div style={{ position: 'absolute', top: 4 * scale, left: 3 * scale }}>
133
- <Slot index={45} item={offhandItem} size={16 * scale} noBackground />
133
+ <Slot index={45} item={offhandItem} size={16 * scale} noBackground disableFocusSwap />
134
134
  </div>
135
135
  </div>
136
136
  )}
@@ -47,8 +47,9 @@ export function InventoryWindow({
47
47
  const { scale, slotSize, contentSize } = useScale()
48
48
  // borderPx removed from slot positioning: registry coords already point to the item area
49
49
  const borderPx = 0
50
+ const isHotbar = type === 'hotbar'
50
51
 
51
- useKeyboardShortcuts(enableKeyboardShortcuts)
52
+ useKeyboardShortcuts(enableKeyboardShortcuts && !isHotbar)
52
53
 
53
54
  const effectiveSlots = useMemo(() => {
54
55
  if (slotsProp) return slotsProp
@@ -73,7 +74,6 @@ export function InventoryWindow({
73
74
  const effectiveTitle = (type === 'player' || isBeacon) ? undefined : (title ?? windowState?.title ?? def.title)
74
75
  const isVillager = type === 'villager'
75
76
  const isEnchanting = def?.name === 'enchanting_table'
76
- const isHotbar = type === 'hotbar'
77
77
  const isAnvil = def?.name === 'anvil'
78
78
 
79
79
  const resolveItem = (slotIndex: number) => {
@@ -98,7 +98,6 @@ export function InventoryWindow({
98
98
  // Offset by borderPx to land inside the texture's slot cell (skip the 1px border)
99
99
  left: slotDef.x * scale + borderPx,
100
100
  top: slotDef.y * scale + borderPx,
101
- ...(isHotbar ? { pointerEvents: 'none' as const } : {}),
102
101
  }}
103
102
  >
104
103
  <Slot
@@ -107,6 +106,7 @@ export function InventoryWindow({
107
106
  size={slotDef.size ? slotDef.size * scale - 2 * borderPx : contentSize}
108
107
  resultSlot={slotDef.resultSlot}
109
108
  label={slotDef.label}
109
+ disableFocusSwap={isHotbar}
110
110
  />
111
111
  </div>
112
112
  ))}
@@ -22,6 +22,11 @@ interface RecipeInventoryViewProps {
22
22
  onPushFrame: (item: RecipeNavFrame['item'], mode: 'recipes' | 'usages') => void
23
23
  }
24
24
 
25
+ function minecraftWikiUrlForName(nameOrDisplay: string): string {
26
+ const slug = nameOrDisplay.replace(/ /g, '_')
27
+ return `https://minecraft.wiki/w/${encodeURIComponent(slug)}`
28
+ }
29
+
25
30
  /** Slot item with a Tooltip, used inside the recipe view */
26
31
  function RecipeItemCell({
27
32
  item,
@@ -29,12 +34,14 @@ function RecipeItemCell({
29
34
  y,
30
35
  size,
31
36
  onHover,
37
+ onRecipeNavigate,
32
38
  }: {
33
39
  item: ItemStack | null
34
40
  x: number
35
41
  y: number
36
42
  size: number
37
43
  onHover: (item: ItemStack | null) => void
44
+ onRecipeNavigate?: (item: ItemStack, mode: 'recipes' | 'usages') => void
38
45
  }) {
39
46
  const [hovered, setHovered] = useState(false)
40
47
 
@@ -63,6 +70,15 @@ function RecipeItemCell({
63
70
  setHovered(false)
64
71
  onHover(null)
65
72
  }}
73
+ onClick={(e) => {
74
+ e.stopPropagation()
75
+ onRecipeNavigate?.(item, 'recipes')
76
+ }}
77
+ onContextMenu={(e) => {
78
+ e.preventDefault()
79
+ e.stopPropagation()
80
+ onRecipeNavigate?.(item, 'usages')
81
+ }}
66
82
  >
67
83
  <ItemCanvas item={item} size={size} style={{ position: 'absolute', top: 0, left: 0, pointerEvents: 'none' }} />
68
84
  {hovered && <Tooltip item={item} visible />}
@@ -100,6 +116,24 @@ export function RecipeInventoryView({
100
116
  }
101
117
  }, [])
102
118
 
119
+ const handleRecipeItemNavigate = useCallback(
120
+ (item: ItemStack, mode: 'recipes' | 'usages') => {
121
+ onPushFrame(
122
+ {
123
+ type: item.type,
124
+ name: item.name ?? '',
125
+ displayName: item.displayName ?? item.name ?? `Item #${item.type}`,
126
+ count: item.count,
127
+ metadata: item.metadata,
128
+ },
129
+ mode,
130
+ )
131
+ },
132
+ [onPushFrame],
133
+ )
134
+
135
+ const wikiHref = minecraftWikiUrlForName(frame.item.name || frame.item.displayName || 'Unknown')
136
+
103
137
  // R / U for nested navigation on hovered recipe items
104
138
  useEffect(() => {
105
139
  const handler = (e: KeyboardEvent) => {
@@ -215,6 +249,22 @@ export function RecipeInventoryView({
215
249
  )}
216
250
  </span>
217
251
 
252
+ <a
253
+ href={wikiHref}
254
+ target="_blank"
255
+ rel="noopener noreferrer"
256
+ onMouseDown={(e) => e.stopPropagation()}
257
+ onClick={(e) => e.stopPropagation()}
258
+ style={{
259
+ color: '#3366bb',
260
+ textDecoration: 'underline',
261
+ flexShrink: 0,
262
+ whiteSpace: 'nowrap',
263
+ }}
264
+ >
265
+ Wiki
266
+ </a>
267
+
218
268
  {/* Prev / next for multiple guides */}
219
269
  {totalGuides > 1 && (
220
270
  <>
@@ -250,6 +300,7 @@ export function RecipeInventoryView({
250
300
  navPx={navPx}
251
301
  contentSize={contentSize}
252
302
  onHoverItem={handleHoverItem}
303
+ onRecipeNavigate={handleRecipeItemNavigate}
253
304
  />
254
305
  )}
255
306
 
@@ -263,7 +314,7 @@ export function RecipeInventoryView({
263
314
  color: 'rgba(80,80,80,0.7)',
264
315
  pointerEvents: 'none',
265
316
  }}>
266
- Hover ingredient + R / U for nested lookup
317
+ Hover ingredient + R / U, or left / right-click (usages)
267
318
  </div>
268
319
  )}
269
320
  </div>
@@ -283,6 +334,7 @@ function CroppedRecipeBackground({
283
334
  navPx,
284
335
  contentSize,
285
336
  onHoverItem,
337
+ onRecipeNavigate,
286
338
  }: {
287
339
  guide: RecipeGuide
288
340
  containerSlots: Array<{ index: number; x: number; y: number; size?: number; group?: string }>
@@ -291,6 +343,7 @@ function CroppedRecipeBackground({
291
343
  navPx: number
292
344
  contentSize: number
293
345
  onHoverItem: (item: ItemStack | null) => void
346
+ onRecipeNavigate?: (item: ItemStack, mode: 'recipes' | 'usages') => void
294
347
  }) {
295
348
  const textures = useTextures()
296
349
  const layoutType = guide.type === 'smelting' ? 'furnace' : 'crafting_table'
@@ -375,6 +428,7 @@ function CroppedRecipeBackground({
375
428
  y={slotDef.y * scale}
376
429
  size={slotDef.size ? slotDef.size * scale - 2 * navPx : contentSize}
377
430
  onHover={onHoverItem}
431
+ onRecipeNavigate={onRecipeNavigate}
378
432
  />
379
433
  )
380
434
  })}
@@ -406,8 +460,6 @@ function DescriptionCard({
406
460
  const fontSize = Math.max(6, Math.round(6 * scale))
407
461
  const iconSize = 16 * scale
408
462
 
409
- const wikiUrl = `https://minecraft.wiki/w/${encodeURIComponent(itemName.replace(/ /g, '_'))}`
410
-
411
463
  return (
412
464
  <div
413
465
  className="mc-inv-description-card"
@@ -504,25 +556,6 @@ function DescriptionCard({
504
556
  {guide.description}
505
557
  </div>
506
558
  )}
507
-
508
- {/* Minecraft Wiki link */}
509
- <a
510
- href={wikiUrl}
511
- target="_blank"
512
- rel="noopener noreferrer"
513
- style={{
514
- position: 'absolute',
515
- bottom: 4 * scale,
516
- right: 8 * scale,
517
- fontSize,
518
- fontFamily: "'Minecraftia', 'Minecraft', monospace",
519
- color: '#3366bb',
520
- textDecoration: 'underline',
521
- cursor: 'pointer',
522
- }}
523
- >
524
- Minecraft Wiki
525
- </a>
526
559
  </div>
527
560
  )
528
561
  }
@@ -75,12 +75,9 @@
75
75
  z-index: 10001;
76
76
  }
77
77
 
78
- .mobileMenuTitle {
79
- color: #ffffff;
80
- font-weight: bold;
81
- padding-bottom: 4px;
82
- border-bottom: 1px solid #555555;
78
+ .mobileMenuInfo {
83
79
  margin-bottom: 4px;
80
+ flex-shrink: 0;
84
81
  }
85
82
 
86
83
  .mobileBtn {
@@ -4,9 +4,15 @@ import { useInventoryContext } from '../../context/InventoryContext'
4
4
  import { useScale } from '../../context/ScaleContext'
5
5
  import { ItemCanvas } from '../ItemCanvas'
6
6
  import { Tooltip } from '../Tooltip'
7
+ import { ItemTooltipBody } from '../Tooltip/ItemTooltipBody'
8
+ import tooltipStyles from '../Tooltip/Tooltip.module.css'
7
9
  import { useMobile } from '../../hooks/useMobile'
8
10
  import styles from './Slot.module.css'
9
11
 
12
+ /** Hotbar HUD long-press: first threshold drops one item; hold longer for whole stack. */
13
+ const HOTBAR_LONG_PRESS_DROP_ONE_MS = 420
14
+ const HOTBAR_LONG_PRESS_DROP_ALL_EXTRA_MS = 600
15
+
10
16
  interface SlotProps {
11
17
  index: number
12
18
  item: ItemStack | null
@@ -19,6 +25,8 @@ interface SlotProps {
19
25
  style?: React.CSSProperties
20
26
  /** Remove slot background/border (e.g. for JEI items) */
21
27
  noBackground?: boolean
28
+ /** When true, skip P-key / focus-swap UI and mobile two-tap swap (e.g. standalone hotbar HUD). */
29
+ disableFocusSwap?: boolean
22
30
  /** Override default click behavior - when provided, calls this instead of sendAction */
23
31
  onClickOverride?: (button: 'left' | 'right' | 'middle', mode: 'normal' | 'shift' | 'double') => void
24
32
  }
@@ -34,6 +42,7 @@ export function Slot({
34
42
  className,
35
43
  style,
36
44
  noBackground,
45
+ disableFocusSwap = false,
37
46
  onClickOverride,
38
47
  }: SlotProps) {
39
48
  const {
@@ -96,6 +105,18 @@ export function Slot({
96
105
  }
97
106
  }, [label, item, renderSize])
98
107
 
108
+ // Mobile touch — timer ref must exist before cleanup effect below
109
+ const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null)
110
+ const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
111
+ const longPressFiredRef = useRef(false)
112
+
113
+ const cancelLongPress = useCallback(() => {
114
+ if (longPressTimerRef.current) {
115
+ clearTimeout(longPressTimerRef.current)
116
+ longPressTimerRef.current = null
117
+ }
118
+ }, [])
119
+
99
120
  // Clean up long press timer on unmount
100
121
  useEffect(() => {
101
122
  return () => {
@@ -106,15 +127,14 @@ export function Slot({
106
127
  const isHovered = hoveredSlot === index
107
128
  const isDragTarget = dragSlots.includes(index)
108
129
  const dragPreviewEntry = dragPreview.get(index)
109
- const isFocused = focusedSlot === index
110
- const showPKeyNumber = pKeyActive && index >= 0 && index <= 99
111
- const isInFocusSwapMode = focusedSlot !== null || pKeyActive
130
+ const isFocused = !disableFocusSwap && focusedSlot === index
131
+ const showPKeyNumber = !disableFocusSwap && pKeyActive && index >= 0 && index <= 99
112
132
 
113
- // Keyboard number key while hovering
133
+ // Keyboard number key while hovering (disabled on hotbar HUD)
114
134
  useEffect(() => {
115
- if (!isHovered || activeNumberKey === null || isMobile) return
135
+ if (!isHovered || activeNumberKey === null || isMobile || disableFocusSwap) return
116
136
  sendAction({ type: 'hotbar-swap', slotIndex: index, hotbarSlot: activeNumberKey })
117
- }, [activeNumberKey, isHovered, index, sendAction, isMobile])
137
+ }, [activeNumberKey, isHovered, index, sendAction, isMobile, disableFocusSwap])
118
138
 
119
139
  const handleMouseEnter = useCallback((e: React.MouseEvent) => {
120
140
  if (isMobile) return
@@ -136,7 +156,9 @@ export function Slot({
136
156
  dragEndedRef.current = false
137
157
  const button = e.button === 2 ? 'right' : e.button === 1 ? 'middle' : 'left'
138
158
  if (button === 'middle') {
139
- sendAction({ type: 'click', slotIndex: index, button: 'middle', mode: 'middle' })
159
+ if (!disableFocusSwap) {
160
+ sendAction({ type: 'click', slotIndex: index, button: 'middle', mode: 'middle' })
161
+ }
140
162
  return
141
163
  }
142
164
  if (heldItem && (button === 'left' || button === 'right')) {
@@ -145,7 +167,7 @@ export function Slot({
145
167
  startDrag(index, button)
146
168
  }
147
169
  },
148
- [isMobile, disabled, heldItem, index, sendAction, startDrag, dragEndedRef],
170
+ [isMobile, disabled, disableFocusSwap, heldItem, index, sendAction, startDrag, dragEndedRef],
149
171
  )
150
172
 
151
173
  const handleMouseUp = useCallback(
@@ -164,8 +186,8 @@ export function Slot({
164
186
  // without this guard they fall through to the click path below.
165
187
  if (dragEndedRef.current) return
166
188
 
167
- // Focus/swap logic — active in P mode OR when a slot is already focused
168
- if (button === 'left' && (pKeyActive || focusedSlot !== null)) {
189
+ // Focus/swap logic — active in P mode OR when a slot is already focused (disabled for hotbar HUD)
190
+ if (!disableFocusSwap && button === 'left' && (pKeyActive || focusedSlot !== null)) {
169
191
  if (pKeyActive) setPKeyActive(false)
170
192
  if (focusedSlot === null) {
171
193
  setFocusedSlot(index)
@@ -191,6 +213,21 @@ export function Slot({
191
213
  lastClickTimeRef.current = now
192
214
 
193
215
  const mode = e.shiftKey ? 'shift' : 'normal'
216
+
217
+ if (disableFocusSwap && !onClickOverride) {
218
+ if (button === 'middle') {
219
+ if (isDragging) endDrag()
220
+ return
221
+ }
222
+ if (!heldItem && index >= 36 && index <= 44) {
223
+ if (button === 'left' && mode === 'normal') {
224
+ sendAction({ type: 'hotbar-select', slotIndex: index })
225
+ }
226
+ if (isDragging) endDrag()
227
+ return
228
+ }
229
+ }
230
+
194
231
  if (onClickOverride) {
195
232
  onClickOverride(button, mode)
196
233
  } else {
@@ -203,7 +240,7 @@ export function Slot({
203
240
  }
204
241
  if (isDragging) endDrag()
205
242
  },
206
- [isMobile, disabled, isDragging, dragSlots.length, sendAction, index, endDrag, onClickOverride, resultSlot, heldItem, item, pKeyActive, setPKeyActive, focusedSlot, setFocusedSlot, dragEndedRef],
243
+ [isMobile, disabled, disableFocusSwap, isDragging, dragSlots.length, sendAction, index, endDrag, onClickOverride, resultSlot, heldItem, item, pKeyActive, setPKeyActive, focusedSlot, setFocusedSlot, dragEndedRef],
207
244
  )
208
245
 
209
246
  const handleDoubleClick = useCallback(
@@ -211,13 +248,14 @@ export function Slot({
211
248
  if (isMobile || disabled) return
212
249
  e.preventDefault()
213
250
  cancelDrag()
251
+ if (disableFocusSwap && !onClickOverride) return
214
252
  if (onClickOverride) {
215
253
  onClickOverride('left', 'double')
216
254
  } else {
217
255
  sendAction({ type: 'click', slotIndex: index, button: 'left', mode: 'double' })
218
256
  }
219
257
  },
220
- [isMobile, disabled, sendAction, index, onClickOverride, cancelDrag],
258
+ [isMobile, disabled, disableFocusSwap, sendAction, index, onClickOverride, cancelDrag],
221
259
  )
222
260
 
223
261
  const handleContextMenu = useCallback((e: React.MouseEvent) => {
@@ -226,7 +264,7 @@ export function Slot({
226
264
 
227
265
  const handleWheel = useCallback(
228
266
  (e: WheelEvent) => {
229
- if (isMobile || disabled) return
267
+ if (isMobile || disabled || disableFocusSwap) return
230
268
  if (onClickOverride) return // JEI slots: let parent handle wheel for pagination
231
269
  if (!item && !heldItem) return
232
270
  e.preventDefault()
@@ -236,7 +274,7 @@ export function Slot({
236
274
  sendAction({ type: 'click', slotIndex: index, button: 'right', mode: 'normal' })
237
275
  }
238
276
  },
239
- [isMobile, disabled, item, heldItem, sendAction, index, onClickOverride],
277
+ [isMobile, disabled, disableFocusSwap, item, heldItem, sendAction, index, onClickOverride],
240
278
  )
241
279
 
242
280
  // Attach wheel listener as non-passive so preventDefault() is effective.
@@ -249,18 +287,6 @@ export function Slot({
249
287
  return () => el.removeEventListener('wheel', handleWheel)
250
288
  }, [handleWheel])
251
289
 
252
- // Mobile touch handlers
253
- const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null)
254
- const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
255
- const longPressFiredRef = useRef(false)
256
-
257
- const cancelLongPress = useCallback(() => {
258
- if (longPressTimerRef.current) {
259
- clearTimeout(longPressTimerRef.current)
260
- longPressTimerRef.current = null
261
- }
262
- }, [])
263
-
264
290
  const handleTouchStart = useCallback(
265
291
  (e: React.TouchEvent) => {
266
292
  if (!isMobile) return
@@ -268,6 +294,18 @@ export function Slot({
268
294
  touchStartRef.current = { x: touch.clientX, y: touch.clientY, time: Date.now() }
269
295
  longPressFiredRef.current = false
270
296
  cancelLongPress()
297
+ // Hotbar HUD: staged long-press drops (no radial menu)
298
+ if (disableFocusSwap && item && !heldItem && !disabled) {
299
+ longPressTimerRef.current = setTimeout(() => {
300
+ longPressFiredRef.current = true
301
+ sendAction({ type: 'drop', slotIndex: index, all: false })
302
+ longPressTimerRef.current = setTimeout(() => {
303
+ sendAction({ type: 'drop', slotIndex: index, all: true })
304
+ longPressTimerRef.current = null
305
+ }, HOTBAR_LONG_PRESS_DROP_ALL_EXTRA_MS)
306
+ }, HOTBAR_LONG_PRESS_DROP_ONE_MS)
307
+ return
308
+ }
271
309
  // Long press: open mobile menu after 400ms if item exists and no held item
272
310
  if (item && !heldItem && !disabled) {
273
311
  const startX = touch.clientX
@@ -279,7 +317,7 @@ export function Slot({
279
317
  }, 400)
280
318
  }
281
319
  },
282
- [isMobile, item, heldItem, disabled, cancelLongPress],
320
+ [isMobile, item, heldItem, disabled, cancelLongPress, disableFocusSwap, sendAction, index],
283
321
  )
284
322
 
285
323
  const handleTouchMove = useCallback(
@@ -318,7 +356,7 @@ export function Slot({
318
356
  // Without this, the click bubbles to the inventory window div which clears focusedSlot.
319
357
  e.preventDefault()
320
358
 
321
- if (pKeyActive) setPKeyActive(false)
359
+ if (pKeyActive && !disableFocusSwap) setPKeyActive(false)
322
360
 
323
361
  // JEI / recipe / custom slots: same handler as desktop onMouseUp (not focus/swap).
324
362
  if (onClickOverride) {
@@ -333,6 +371,16 @@ export function Slot({
333
371
  return
334
372
  }
335
373
 
374
+ if (disableFocusSwap) {
375
+ if (focusedSlot !== null) setFocusedSlot(null)
376
+ if (index >= 36 && index <= 44 && !heldItem) {
377
+ sendAction({ type: 'hotbar-select', slotIndex: index })
378
+ } else {
379
+ sendAction({ type: 'click', slotIndex: index, button: 'left', mode: 'normal' })
380
+ }
381
+ return
382
+ }
383
+
336
384
  // On mobile, tapping always uses the focus/swap mechanism:
337
385
  // first tap focuses, second tap on a different slot swaps, same slot clears.
338
386
  if (focusedSlot === null) {
@@ -346,22 +394,22 @@ export function Slot({
346
394
  setFocusedSlot(null)
347
395
  }
348
396
  },
349
- [isMobile, disabled, heldItem, sendAction, index, pKeyActive, setPKeyActive, focusedSlot, setFocusedSlot, onClickOverride, cancelLongPress, mobileMenuOpen],
397
+ [isMobile, disabled, disableFocusSwap, heldItem, sendAction, index, pKeyActive, setPKeyActive, focusedSlot, setFocusedSlot, onClickOverride, cancelLongPress, mobileMenuOpen],
350
398
  )
351
399
 
352
400
  const handleMobilePickAll = useCallback(() => {
353
401
  setMobileMenuOpen(false)
354
402
  setShowTooltip(false)
355
- setFocusedSlot(index)
403
+ if (!disableFocusSwap) setFocusedSlot(index)
356
404
  sendAction({ type: 'click', slotIndex: index, button: 'left', mode: 'normal' })
357
- }, [sendAction, index, setFocusedSlot])
405
+ }, [sendAction, index, setFocusedSlot, disableFocusSwap])
358
406
 
359
407
  const handleMobilePickHalf = useCallback(() => {
360
408
  setMobileMenuOpen(false)
361
409
  setShowTooltip(false)
362
- setFocusedSlot(index)
410
+ if (!disableFocusSwap) setFocusedSlot(index)
363
411
  sendAction({ type: 'click', slotIndex: index, button: 'right', mode: 'normal' })
364
- }, [sendAction, index, setFocusedSlot])
412
+ }, [sendAction, index, setFocusedSlot, disableFocusSwap])
365
413
 
366
414
  const handleMobileDropOne = useCallback(() => {
367
415
  setMobileMenuOpen(false)
@@ -560,7 +608,7 @@ function MobileSlotMenu({ item, x, y, onPickAll, onPickHalf, onDropOne, onDropAl
560
608
  if (top + mh > vh - 4) top = vh - mh - 4
561
609
  if (top < 4) top = 4
562
610
  setPos({ left, top })
563
- }, [x, y])
611
+ }, [x, y, item])
564
612
 
565
613
  // Wrapper to handle both touch and click, preventing event bubbling to the slot
566
614
  const touchBtn = (handler: () => void) => ({
@@ -585,8 +633,17 @@ function MobileSlotMenu({ item, x, y, onPickAll, onPickHalf, onDropOne, onDropAl
585
633
  minWidth: 100 * scale,
586
634
  }}
587
635
  >
588
- <div className={styles.mobileMenuTitle}>
589
- {item.displayName ?? item.name ?? `Item #${item.type}`} ×{item.count}
636
+ <div
637
+ className={[tooltipStyles.tooltip, styles.mobileMenuInfo].join(' ')}
638
+ style={{
639
+ fontSize: Math.round(8 * scale),
640
+ padding: Math.round(3 * scale),
641
+ gap: Math.round(0.5 * scale),
642
+ width: 'max-content',
643
+ maxWidth: 'min(90vw, 280px)',
644
+ }}
645
+ >
646
+ <ItemTooltipBody item={item} />
590
647
  </div>
591
648
  <button className={styles.mobileBtn} {...touchBtn(onPickAll)}>Select All ({item.count})</button>
592
649
  <button className={styles.mobileBtn} {...touchBtn(onPickHalf)}>Pick Half ({Math.ceil(item.count / 2)})</button>
@@ -0,0 +1,69 @@
1
+ import React from 'react'
2
+ import type { ItemStack } from '../../types'
3
+ import { MessageFormattedString } from '../Text/MessageFormattedString'
4
+ import styles from './Tooltip.module.css'
5
+
6
+ function getRarityColor(item: ItemStack): string {
7
+ if (item.enchantments && item.enchantments.length > 0) return '#8080ff'
8
+ return '#ffffff'
9
+ }
10
+
11
+ function formatEnchantment(e: { name: string; level: number }): string {
12
+ const roman = ['', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X']
13
+ return e.level <= 10 ? `${e.name} ${roman[e.level]}` : `${e.name} ${e.level}`
14
+ }
15
+
16
+ export function getItemTooltipDisplayName(item: ItemStack): string {
17
+ return item.displayName ?? (item.name ? item.name.replace(/_/g, ' ') : `Item #${item.type}`)
18
+ }
19
+
20
+ /** Item name, enchantments, lore, durability, and type id — shared by desktop tooltip and mobile menu. */
21
+ export function ItemTooltipBody({ item }: { item: ItemStack }) {
22
+ const nameColor = getRarityColor(item)
23
+ const hasDurability =
24
+ item.durability !== undefined &&
25
+ item.maxDurability !== undefined &&
26
+ item.durability < item.maxDurability
27
+
28
+ return (
29
+ <>
30
+ <div className={styles.name} style={{ color: nameColor }}>
31
+ <MessageFormattedString
32
+ text={getItemTooltipDisplayName(item)}
33
+ fallbackColor={nameColor}
34
+ />
35
+ </div>
36
+
37
+ {item.enchantments && item.enchantments.length > 0 && (
38
+ <div className={styles.section}>
39
+ {item.enchantments.map((e, i) => (
40
+ <div key={i} className={styles.enchantment}>
41
+ {formatEnchantment(e)}
42
+ </div>
43
+ ))}
44
+ </div>
45
+ )}
46
+
47
+ {item.lore && item.lore.length > 0 && (
48
+ <div className={styles.lore}>
49
+ {item.lore.map((line, i) => (
50
+ <div key={i}>
51
+ <MessageFormattedString text={line} />
52
+ </div>
53
+ ))}
54
+ </div>
55
+ )}
56
+
57
+ {hasDurability && (
58
+ <div className={styles.durability}>
59
+ Durability: {item.durability} / {item.maxDurability}
60
+ </div>
61
+ )}
62
+
63
+ <div className={styles.typeId}>
64
+ {item.name ?? `#${item.type}`}
65
+ {item.metadata !== undefined && item.metadata > 0 ? `:${item.metadata}` : ''}
66
+ </div>
67
+ </>
68
+ )
69
+ }
@@ -2,8 +2,8 @@ import React, { useRef, useEffect } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import type { ItemStack } from '../../types'
4
4
  import { useScale } from '../../context/ScaleContext'
5
- import { MessageFormattedString } from '../Text/MessageFormattedString'
6
5
  import { globalMouse } from '../../utils/globalMouse'
6
+ import { ItemTooltipBody } from './ItemTooltipBody'
7
7
  import styles from './Tooltip.module.css'
8
8
 
9
9
  interface TooltipProps {
@@ -11,16 +11,6 @@ interface TooltipProps {
11
11
  visible: boolean
12
12
  }
13
13
 
14
- function getRarityColor(item: ItemStack): string {
15
- if (item.enchantments && item.enchantments.length > 0) return '#8080ff'
16
- return '#ffffff'
17
- }
18
-
19
- function formatEnchantment(e: { name: string; level: number }): string {
20
- const roman = ['', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X']
21
- return e.level <= 10 ? `${e.name} ${roman[e.level]}` : `${e.name} ${e.level}`
22
- }
23
-
24
14
  export function Tooltip({ item, visible }: TooltipProps) {
25
15
  const { scale } = useScale()
26
16
  const ref = useRef<HTMLDivElement>(null)
@@ -63,12 +53,6 @@ export function Tooltip({ item, visible }: TooltipProps) {
63
53
 
64
54
  if (!visible) return null
65
55
 
66
- const nameColor = getRarityColor(item)
67
- const hasDurability =
68
- item.durability !== undefined &&
69
- item.maxDurability !== undefined &&
70
- item.durability < item.maxDurability
71
-
72
56
  const fs = Math.round(8 * scale)
73
57
  const pad = Math.round(3 * scale)
74
58
  const gap2 = Math.round(0.5 * scale)
@@ -91,43 +75,7 @@ export function Tooltip({ item, visible }: TooltipProps) {
91
75
  pointerEvents: 'none',
92
76
  }}
93
77
  >
94
- <div className={styles.name} style={{ color: nameColor }}>
95
- <MessageFormattedString
96
- text={item.displayName ?? (item.name ? item.name.replace(/_/g, ' ') : `Item #${item.type}`)}
97
- fallbackColor={nameColor}
98
- />
99
- </div>
100
-
101
- {item.enchantments && item.enchantments.length > 0 && (
102
- <div className={styles.section}>
103
- {item.enchantments.map((e, i) => (
104
- <div key={i} className={styles.enchantment}>
105
- {formatEnchantment(e)}
106
- </div>
107
- ))}
108
- </div>
109
- )}
110
-
111
- {item.lore && item.lore.length > 0 && (
112
- <div className={styles.lore}>
113
- {item.lore.map((line, i) => (
114
- <div key={i}>
115
- <MessageFormattedString text={line} />
116
- </div>
117
- ))}
118
- </div>
119
- )}
120
-
121
- {hasDurability && (
122
- <div className={styles.durability}>
123
- Durability: {item.durability} / {item.maxDurability}
124
- </div>
125
- )}
126
-
127
- <div className={styles.typeId}>
128
- {item.name ?? `#${item.type}`}
129
- {item.metadata !== undefined && item.metadata > 0 ? `:${item.metadata}` : ''}
130
- </div>
78
+ <ItemTooltipBody item={item} />
131
79
  </div>
132
80
  )
133
81
 
@@ -39,6 +39,8 @@ function describeAction(action: InventoryAction): string {
39
39
  return `Set beacon effects: ${action.primaryEffect} / ${action.secondaryEffect}`
40
40
  case 'hotbar-swap':
41
41
  return `Swap slot ${action.slotIndex} with hotbar ${action.hotbarSlot}`
42
+ case 'hotbar-select':
43
+ return `Select hotbar slot (index ${action.slotIndex})`
42
44
  default:
43
45
  return JSON.stringify(action)
44
46
  }
@@ -88,12 +90,34 @@ export function createDemoConnector(options: DemoConnectorOptions): InventoryCon
88
90
  if (actionLog.length > 100) actionLog.splice(100)
89
91
  options.onAction?.(entry)
90
92
 
93
+ if (action.type === 'hotbar-select') {
94
+ if (action.slotIndex >= 36 && action.slotIndex <= 44) {
95
+ playerState = { ...playerState, activeHotbarSlot: action.slotIndex - 36 }
96
+ emit({ type: 'playerUpdate', state: playerState })
97
+ }
98
+ return
99
+ }
100
+
91
101
  // Demo: simulate simple click behavior
92
102
  if (action.type === 'click' && windowState) {
93
103
  const slots = [...windowState.slots]
94
104
  const slotState = slots.find((s) => s.index === action.slotIndex)
95
105
  const held = windowState.heldItem
96
106
 
107
+ // Hotbar HUD: empty-hand click on main bar = select only (no pick)
108
+ if (
109
+ windowState.type === 'hotbar' &&
110
+ action.button === 'left' &&
111
+ action.mode === 'normal' &&
112
+ !held &&
113
+ action.slotIndex >= 36 &&
114
+ action.slotIndex <= 44
115
+ ) {
116
+ playerState = { ...playerState, activeHotbarSlot: action.slotIndex - 36 }
117
+ emit({ type: 'playerUpdate', state: playerState })
118
+ return
119
+ }
120
+
97
121
  if (action.button === 'left' && action.mode === 'normal') {
98
122
  if (held && slotState) {
99
123
  const idx = slots.indexOf(slotState)
@@ -603,6 +603,22 @@ export function createMineflayerConnector(bot: MineflayerBot, options?: Mineflay
603
603
  logActionEvent('connector.action.success', action)
604
604
  return
605
605
  }
606
+
607
+ if (action.type === 'hotbar-select') {
608
+ if (!hotbarOnly) {
609
+ logActionEvent('connector.action.skipped', action, { reason: 'not_hotbar_only' })
610
+ return
611
+ }
612
+ if (action.slotIndex < 36 || action.slotIndex > 44) {
613
+ logActionEvent('connector.action.skipped', action, { reason: 'invalid_hotbar_slot' })
614
+ return
615
+ }
616
+ const extBot = bot as MineflayerBot & { setQuickBarSlot?: (i: number) => void }
617
+ extBot.setQuickBarSlot?.(action.slotIndex - 36)
618
+ onSetSlot()
619
+ logActionEvent('connector.action.success', action)
620
+ return
621
+ }
606
622
 
607
623
  const win = bot.currentWindow
608
624
 
@@ -142,7 +142,7 @@ export function summarizeWindowState(state: InventoryWindowState | null | undefi
142
142
  }
143
143
 
144
144
  export function getActionSlotIndexes(action: InventoryAction): number[] | undefined {
145
- if (action.type === 'click' || action.type === 'drop' || action.type === 'hotbar-swap') return [action.slotIndex]
145
+ if (action.type === 'click' || action.type === 'drop' || action.type === 'hotbar-swap' || action.type === 'hotbar-select') return [action.slotIndex]
146
146
  if (action.type === 'drag') return [...action.slots]
147
147
  return undefined
148
148
  }
package/src/types.ts CHANGED
@@ -90,6 +90,8 @@ export type InventoryAction =
90
90
  | { type: 'enchant'; enchantIndex: number }
91
91
  | { type: 'beacon'; primaryEffect: number; secondaryEffect: number }
92
92
  | { type: 'hotbar-swap'; slotIndex: number; hotbarSlot: number }
93
+ /** Standalone hotbar HUD: change selected slot only (no window pick/swap). Slot index 36–44. */
94
+ | { type: 'hotbar-select'; slotIndex: number }
93
95
  /** Emitted by the Hotbar "open inventory" button; integrations (e.g. mineflayer) handle this. */
94
96
  | { type: 'open-inventory' }
95
97