import React, { useState, useCallback, useEffect, useRef } from 'react' import type { RecipeNavFrame, RecipeGuide, ItemStack } from '../../types' import { ItemCanvas } from '../ItemCanvas' import { Tooltip } from '../Tooltip' import { useScale } from '../../context/ScaleContext' import { useTextures } from '../../context/TextureContext' import { getInventoryType } from '../../registry' function isTypingTarget(target: EventTarget | null): boolean { if (!target || !(target instanceof HTMLElement)) return false const tag = target.tagName if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true if (target.isContentEditable) return true return false } interface RecipeInventoryViewProps { navStack: RecipeNavFrame[] onBack: () => void onGuideIndexChange: (idx: number) => void /** Called when R or U is pressed while hovering a recipe item (nested navigation) */ onPushFrame: (item: RecipeNavFrame['item'], mode: 'recipes' | 'usages') => void } function minecraftWikiUrlForName(nameOrDisplay: string): string { const slug = nameOrDisplay.replace(/ /g, '_') return `https://minecraft.wiki/w/${encodeURIComponent(slug)}` } /** Slot item with a Tooltip, used inside the recipe view */ function RecipeItemCell({ item, x, y, size, onHover, onRecipeNavigate, }: { item: ItemStack | null x: number y: number size: number onHover: (item: ItemStack | null) => void onRecipeNavigate?: (item: ItemStack, mode: 'recipes' | 'usages') => void }) { const [hovered, setHovered] = useState(false) if (!item) { return (
) } return (
{ setHovered(true) onHover(item) }} onMouseLeave={() => { setHovered(false) onHover(null) }} onClick={(e) => { e.stopPropagation() onRecipeNavigate?.(item, 'recipes') }} onContextMenu={(e) => { e.preventDefault() e.stopPropagation() onRecipeNavigate?.(item, 'usages') }} > {hovered && }
) } /** * Renders the current recipe guide using the actual inventory background layout * (crafting_table for crafting, furnace for smelting). Replaces the main * InventoryWindow while a recipe is being browsed. */ export function RecipeInventoryView({ navStack, onBack, onGuideIndexChange, onPushFrame, }: RecipeInventoryViewProps) { const { scale, contentSize } = useScale() // Registry coords already point to item area — no border offset needed const borderPx = 0 const frame = navStack[navStack.length - 1] const guide = frame.guides[frame.guideIndex] const hoveredItemRef = useRef(null) const recipeViewRootRef = useRef(null) const handleHoverItem = useCallback((item: ItemStack | null) => { if (!item) { hoveredItemRef.current = null; return } hoveredItemRef.current = { type: item.type, name: item.name ?? '', displayName: item.displayName ?? item.name ?? `Item #${item.type}`, count: item.count, metadata: item.metadata, } }, []) const handleRecipeItemNavigate = useCallback( (item: ItemStack, mode: 'recipes' | 'usages') => { onPushFrame( { type: item.type, name: item.name ?? '', displayName: item.displayName ?? item.name ?? `Item #${item.type}`, count: item.count, metadata: item.metadata, }, mode, ) }, [onPushFrame], ) const wikiHref = minecraftWikiUrlForName(frame.item.name || frame.item.displayName || 'Unknown') // R / U for nested navigation on hovered recipe items useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.code !== 'KeyR' && e.code !== 'KeyU') return if (isTypingTarget(e.target)) return const item = hoveredItemRef.current if (!item) return onPushFrame(item, e.code === 'KeyR' ? 'recipes' : 'usages') } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) }, [onPushFrame]) // Escape / Backspace = go back one frame (Backspace on root frame closes recipe view) useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.code !== 'Escape' && e.code !== 'Backspace') return if (isTypingTarget(e.target)) return onBack() if (e.code === 'Backspace') e.preventDefault() } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) }, [onBack]) const isMultiFrame = navStack.length > 1 const isCustom = guide.type === 'custom' // Determine layout type and get definition (not needed for custom) const layoutType = guide.type === 'smelting' ? 'furnace' : 'crafting_table' const def = getInventoryType(layoutType)! // Container-only slots (no player inv / hotbar) const containerSlots = isCustom ? [] : def.slots.filter( (s) => s.group !== 'inventory' && s.group !== 'hotbar', ) // Map recipe items to slot indices const slotItems = new Map() if (guide.type === 'crafting') { slotItems.set(0, guide.result ?? null) const ingr = guide.ingredients ?? [] for (let i = 0; i < 9; i++) { slotItems.set(i + 1, ingr[i] ?? null) } } else if (guide.type === 'smelting') { slotItems.set(0, guide.input ?? null) slotItems.set(1, guide.fuel ?? null) slotItems.set(2, guide.result ?? null) } const totalGuides = frame.guides.length const navFontSize = Math.max(6, Math.round(6 * scale)) const navPx = Math.max(1, Math.round(scale)) const handleWheel = useCallback( (e: WheelEvent) => { if (totalGuides <= 1) return e.preventDefault() const idx = frame.guideIndex if (e.deltaY > 0) { onGuideIndexChange(Math.min(idx + 1, totalGuides - 1)) } else { onGuideIndexChange(Math.max(idx - 1, 0)) } }, [totalGuides, frame.guideIndex, onGuideIndexChange], ) useEffect(() => { const el = recipeViewRootRef.current if (!el || totalGuides <= 1) return el.addEventListener('wheel', handleWheel, { passive: false }) return () => el.removeEventListener('wheel', handleWheel) }, [handleWheel, totalGuides]) return (
{/* ── Navigation bar ABOVE the inventory background ── */}
{isMultiFrame ? '◀ Back' : '✕ Close'} {/* Mode label */} {frame.mode === 'recipes' ? 'Recipes for' : 'Usages of'}{' '} {frame.item.displayName} {navStack.length > 1 && ( {'▸'.repeat(navStack.length - 1)} )} e.stopPropagation()} onClick={(e) => e.stopPropagation()} style={{ color: '#3366bb', textDecoration: 'underline', flexShrink: 0, whiteSpace: 'nowrap', }} > Wiki {/* Prev / next for multiple guides */} {totalGuides > 1 && ( <> onGuideIndexChange(frame.guideIndex > 0 ? frame.guideIndex - 1 : totalGuides - 1)} >◀ {frame.guideIndex + 1} / {totalGuides} onGuideIndexChange(frame.guideIndex < totalGuides - 1 ? frame.guideIndex + 1 : 0)} >▶ )}
{isCustom ? ( /* ── Compact description card for non-craftable items ── */ ) : ( /* ── Cropped inventory background with container slots only (no player inv) ── */ )} {/* ── Hint below ── */} {!isCustom && (
Hover ingredient + R / U, or left / right-click (usages)
)}
) } /** * Cropped recipe background — shows only the container area (no player inventory). * Crafting: crops to 80px height (title bar + 3×3 grid + result). * Smelting: crops to 84px height (input + fuel + result + arrow). */ function CroppedRecipeBackground({ guide, containerSlots, slotItems, scale, navPx, contentSize, onHoverItem, onRecipeNavigate, }: { guide: RecipeGuide containerSlots: Array<{ index: number; x: number; y: number; size?: number; group?: string }> slotItems: Map scale: number navPx: number contentSize: number onHoverItem: (item: ItemStack | null) => void onRecipeNavigate?: (item: ItemStack, mode: 'recipes' | 'usages') => void }) { const textures = useTextures() const layoutType = guide.type === 'smelting' ? 'furnace' : 'crafting_table' const def = getInventoryType(layoutType)! const bgUrl = textures.getGuiTextureUrl(def.backgroundTexture) // Crop height: crafting=80, furnace=84 (matches old lib's CraftingTableGuide slice) const CROP_H = guide.type === 'smelting' ? 84 : 80 const SRC_W = def.backgroundWidth const fontSize = Math.max(6, Math.round(6 * scale)) return (
{/* Cropped background texture */}
{/* Title */}
{guide.title} {guide.description && ( ({guide.description}) )}
{/* Slot items */} {containerSlots.map((slotDef) => { const item = slotItems.get(slotDef.index) ?? null return ( ) })}
) } /** * Compact description card for non-craftable items (replaces full inventory background). * Mirrors the old canvas-based GenericDescription layout: cropped crafting_table header, * gray content area, item icon, description text, and a Minecraft Wiki link. */ function DescriptionCard({ guide, itemName, scale, }: { guide: RecipeGuide itemName: string scale: number }) { const textures = useTextures() const craftingDef = getInventoryType('crafting_table')! const bgUrl = textures.getGuiTextureUrl(craftingDef.backgroundTexture) const CARD_W = 176 const CARD_H = 120 const TITLE_H = 15 const fontSize = Math.max(6, Math.round(6 * scale)) const iconSize = 16 * scale return (
{/* Cropped crafting_table texture as background header */}
{/* Gray content overlay (below title bar) */}
{/* Title */}
{guide.title}
{/* Item icon */}
{/* Description text */} {guide.description && (
{guide.description}
)}
) } function NavBtn({ scale, onClick, disabled, title, children, }: { scale: number onClick: () => void disabled?: boolean title?: string children: React.ReactNode }) { return ( ) }