import React, { useCallback, useEffect, useState } from 'react' import { useCloseOnWClick } from '../../hooks/useCloseOnWClick' import { useInventoryContext } from '../../context/InventoryContext' import { useScale } from '../../context/ScaleContext' import { getInventoryType } from '../../registry' import { InventoryWindow } from '../InventoryWindow' import { CursorItem } from '../CursorItem' import { JEI } from '../JEI' import type { JEIItem } from '../JEI' import { Notes } from '../Notes' import type { Note } from '../Notes' import { RecipeInventoryView } from '../RecipeGuide' import type { RecipeGuide, RecipeNavFrame } from '../../types' export interface InventoryOverlayProps { type: string title?: string /** * Extra properties forwarded to InventoryWindow. For the 'hotbar' type these * control special features: `showOffhand` (1/0) and `container` (1/0). */ properties?: Record /** Hide semi-transparent backdrop behind inventory */ noBackdrop?: boolean /** Backdrop color (default: 'rgba(0,0,0,0.5)') */ backdropColor?: string /** Called when clicking outside the inventory (or pressing Esc) */ onClose?: () => void /** Show JEI sidebar */ showJEI?: boolean jeiItems?: JEIItem[] jeiPosition?: 'left' | 'right' jeiOnGetRecipes?: (item: JEIItem) => RecipeGuide[] jeiOnGetUsages?: (item: JEIItem) => RecipeGuide[] jeiOnItemClick?: (item: JEIItem) => void jeiOnItemRightClick?: (item: JEIItem) => void /** Enable Notes sidebar (uses localStorage by default if callbacks not provided) */ enableNotes?: boolean /** Callback to get notes. If not provided and enableNotes is true, uses localStorage. */ notesOnGet?: () => Note[] | Promise /** Callback to save notes. If not provided and enableNotes is true, uses localStorage. */ notesOnSave?: (notes: Note[]) => void | Promise /** Storage key for localStorage (default: 'mc-inv-notes') */ notesStorageKey?: string /** * Content shown in the left side-panel area (e.g. custom panels). * Width is auto-computed as (overlayWidth - inventoryWidth) / 2 - padding. * Note: If enableNotes is true, Notes will be shown in addition to leftPanel. */ leftPanel?: React.ReactNode className?: string style?: React.CSSProperties children?: React.ReactNode /** Show yellow debug border around the overlay area */ debugBounds?: boolean /** Show red debug outline around the inventory background */ showDebug?: boolean /** When true, entity display area shows layout debug bounds instead of the default image. */ entityDisplayDebug?: boolean /** Override entity display rendering. Pass a function returning JSX, or null to hide. */ renderEntity?: ((width: number, height: number) => React.ReactNode) | null /** Hide the "INV" version watermark (opt-out) */ noWatermark?: boolean /** Called when the gear button is clicked. When provided, a small settings gear appears in the overlay. */ onOpenSettings?: () => void } export function InventoryOverlay({ type, title, properties, noBackdrop = false, backdropColor = 'rgba(0,0,0,0.5)', onClose, showJEI = false, jeiItems = [], jeiPosition = 'right', jeiOnGetRecipes, jeiOnGetUsages, jeiOnItemClick, jeiOnItemRightClick, enableNotes = false, notesOnGet, notesOnSave, notesStorageKey, leftPanel, className, style, children, debugBounds = false, showDebug = false, entityDisplayDebug = false, renderEntity, noWatermark = false, onOpenSettings, }: InventoryOverlayProps) { const { heldItem, sendAction, setHeldItem, focusedSlot, setFocusedSlot, hoveredSlot, getSlot } = useInventoryContext() const { scale } = useScale() const [settingsHovered, setSettingsHovered] = useState(false) useCloseOnWClick(onClose) const def = getInventoryType(type) const invUnscaledW = def?.backgroundWidth ?? 176 const sideGapPx = 5 * scale // gap from overlay edge and from inventory edge // Full height for side panels = overlay height minus edge gaps const sidePanelHeight = `calc(100% - ${sideGapPx * 2}px)` // Recipe navigation stack — when non-empty, RecipeInventoryView replaces InventoryWindow const [recipeNavStack, setRecipeNavStack] = useState([]) const pushRecipeFrame = useCallback((frame: RecipeNavFrame) => { setRecipeNavStack((s) => [...s, frame]) }, []) const popRecipeFrame = useCallback(() => { setRecipeNavStack((s) => s.slice(0, -1)) }, []) const handleGuideIndexChange = useCallback((idx: number) => { setRecipeNavStack((s) => { const next = [...s] next[next.length - 1] = { ...next[next.length - 1], guideIndex: idx } return next }) }, []) // Called by RecipeInventoryView when R/U is pressed over a recipe ingredient const handleRecipePushFrame = useCallback((item: RecipeNavFrame['item'], mode: 'recipes' | 'usages') => { const getter = mode === 'recipes' ? jeiOnGetRecipes : jeiOnGetUsages if (!getter) return const jeiItem: JEIItem = { type: item.type, name: item.name, displayName: item.displayName, count: item.count, metadata: item.metadata } const guides = getter(jeiItem) if (guides.length === 0) return pushRecipeFrame({ item, mode, guides, guideIndex: 0 }) }, [jeiOnGetRecipes, jeiOnGetUsages, pushRecipeFrame]) // R / U while hovering a regular inventory slot (not JEI, not recipe view) useEffect(() => { if (!jeiOnGetRecipes && !jeiOnGetUsages) return const handler = (e: KeyboardEvent) => { if (e.code !== 'KeyR' && e.code !== 'KeyU') return const target = e.target as Element | null const tagName = (target as { tagName?: string })?.tagName ?? '' if (tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT' || (target as { isContentEditable?: boolean })?.isContentEditable) return if (recipeNavStack.length > 0) return // RecipeInventoryView handles its own R/U if (hoveredSlot === null || hoveredSlot < 0) return // JEI slots handled by JEI.tsx const slot = getSlot(hoveredSlot) if (!slot?.item) return const item = slot.item handleRecipePushFrame( { type: item.type, name: item.name ?? '', displayName: item.displayName ?? item.name ?? `Item #${item.type}`, count: item.count, metadata: item.metadata }, e.code === 'KeyR' ? 'recipes' : 'usages', ) } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) }, [jeiOnGetRecipes, jeiOnGetUsages, recipeNavStack.length, hoveredSlot, getSlot, handleRecipePushFrame]) // Fires for any click that isn't stopped by an interactive panel (inventory, hotbar, JEI, etc.) const handleBackdropClick = useCallback((e: React.MouseEvent) => { // Only handle left (drop all) and right (drop one) mouse buttons if (e.button !== 0 && e.button !== 2) return if (heldItem) { const dropAll = e.button === 0 // LMB = drop all, RMB = drop one sendAction({ type: 'drop', slotIndex: -1, all: dropAll }) if (dropAll) { setHeldItem(null) } else { // Right click: drop one, keep rest on cursor if (heldItem.count > 1) { setHeldItem({ ...heldItem, count: heldItem.count - 1 }) } else { setHeldItem(null) } } // Also clear focused slot if any if (focusedSlot !== null) setFocusedSlot(null) } else if (focusedSlot !== null) { setFocusedSlot(null) } else { onClose?.() } }, [heldItem, sendAction, setHeldItem, onClose, focusedSlot, setFocusedSlot]) const jeiPanel = showJEI ? ( ) : null const notesPanel = enableNotes ? ( ) : null // Full height side panels; width fills the space between overlay edge and inventory center const sidePanelBase: React.CSSProperties = { position: 'absolute', top: sideGapPx, bottom: sideGapPx, width: `calc(100% / 2 - ${invUnscaledW * scale}px / 2 - ${sideGapPx}px * 2)`, height: sidePanelHeight, zIndex: 5, display: 'flex', flexDirection: 'column', } return ( <>
e.preventDefault()} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', background: noBackdrop ? 'transparent' : backdropColor, cursor: 'default', zIndex: 1, ...style, }} > {/* ── Left side panel (leftPanel prop, Notes, and/or JEI-left) ── */} {(leftPanel || enableNotes || (showJEI && jeiPosition === 'left')) && (
{enableNotes && (
{notesPanel}
)} {leftPanel && (
e.stopPropagation()} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }} > {leftPanel}
)} {showJEI && jeiPosition === 'left' && (
e.stopPropagation()} onClick={(e) => e.stopPropagation()} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', width: '100%' }} > {jeiPanel}
)}
)} {/* ── Inventory centered anchor — flex centering, no transform ── */}
{/* Inventory / Recipe view — clicks clear focused slot; slots stop propagation themselves */}
e.stopPropagation()} onClick={(e) => { e.stopPropagation() // Clicking the inventory background (not a slot) clears focused slot if (focusedSlot !== null) setFocusedSlot(null) }} > {recipeNavStack.length > 0 ? ( ) : ( )}
{/* ── Right side panel (JEI-right) ── */} {showJEI && jeiPosition === 'right' && (
e.stopPropagation()} onClick={(e) => e.stopPropagation()} style={{ ...sidePanelBase, right: sideGapPx, pointerEvents: 'auto' }} >
{jeiPanel}
)} {/* Extra children (overlay-level) */} {children} {/* Watermark */} {!noWatermark && ( e.stopPropagation()} onClick={(e) => e.stopPropagation()} style={{ position: 'absolute', top: 4, left: 4, fontSize: 9, fontFamily: "'Minecraftia', 'Minecraft', monospace", color: 'rgba(255,255,255,0.3)', textDecoration: 'none', pointerEvents: 'auto', zIndex: 1, lineHeight: 1, }} > INV 0.1.45 )} {/* Settings gear button */} {onOpenSettings && ( )} {/* Debug bounds */} {debugBounds && (
)}
) }