import React, { useState, useMemo, useCallback, useEffect, useLayoutEffect, useRef } from 'react' import type { ItemStack, RecipeGuide, RecipeNavFrame, BlockTextureRender } from '../../types' import { useScale } from '../../context/ScaleContext' import { useInventoryContext } from '../../context/InventoryContext' import { useSlashFocusInput } from '../../hooks/useFocusInputShortcut' import { Slot } from '../Slot' import { StarIcon } from './StarIcon' import styles from './JEI.module.css' export interface JEIItem { type: number name: string displayName: string count?: number metadata?: number texture?: string blockTexture?: BlockTextureRender } interface JEIProps { items?: JEIItem[] position?: 'left' | 'right' /** Override number of item columns (default: ITEMS_PER_ROW=6). Set by InventoryOverlay from slot-grid calc. */ cols?: number /** Override panel width in px. If omitted, derived from cols. */ width?: number /** Max height in px for the panel. Controls rows visible. */ maxHeight?: number onItemClick?: (item: JEIItem) => void onItemMiddleClick?: (item: JEIItem) => void onItemRightClick?: (item: JEIItem) => void /** Called when R is pressed while hovering an item — return list of recipes to display */ onGetRecipes?: (item: JEIItem) => RecipeGuide[] /** Called when U is pressed while hovering an item — return list of usages to display */ onGetUsages?: (item: JEIItem) => RecipeGuide[] /** Called when R/U shortcut should push a new recipe frame (managed externally by InventoryOverlay) */ onPushRecipeFrame?: (frame: RecipeNavFrame) => void className?: string style?: React.CSSProperties } const ITEMS_PER_ROW = 6 const PADDING = 4 const FAV_KEY = 'mc-inv-jei-favorites' function getItemKey(item: JEIItem): string { return `${item.type}:${item.metadata ?? 0}:${item.name}` } function loadFavorites(): Set { try { const raw = localStorage.getItem(FAV_KEY) return new Set(raw ? (JSON.parse(raw) as string[]) : []) } catch { return new Set() } } function saveFavorites(favs: Set) { try { localStorage.setItem(FAV_KEY, JSON.stringify([...favs])) } catch {} } export function JEI({ items = [], position = 'right', cols: colsProp, width, maxHeight, onItemClick, onItemMiddleClick, onItemRightClick, onGetRecipes, onGetUsages, onPushRecipeFrame, className, style, }: JEIProps) { const { scale, contentSize } = useScale() const { hoveredSlot } = useInventoryContext() const [search, setSearch] = useState('') const [page, setPage] = useState(0) const [showAllItems, setShowAllItems] = useState(false) const [showFavorites, setShowFavorites] = useState(false) const [favorites, setFavorites] = useState>(loadFavorites) // Map from negative slot index → JEI item (to enable F-key and R/U on hover) const slotToItemRef = useRef>(new Map()) // Self-measured dimensions so the panel can be width:100% and fill its container const rootRef = useRef(null) const gridRef = useRef(null) const searchInputRef = useRef(null) useSlashFocusInput(searchInputRef, true) const [measuredCols, setMeasuredCols] = useState(ITEMS_PER_ROW) const [measuredRows, setMeasuredRows] = useState(5) useLayoutEffect(() => { const root = rootRef.current const grid = gridRef.current if (!root || !grid) return // Track latest sizes across both entries const sizes = { rootW: 0, gridH: 0 } const ro = new ResizeObserver((entries) => { for (const entry of entries) { // console.log('got size', entry.target.className, entry.contentRect.width, entry.contentRect.height) if (entry.target === (root as unknown as Element)) { sizes.rootW = entry.contentRect.width } else if (entry.target === (grid as unknown as Element)) { sizes.gridH = entry.contentRect.height } } const innerPad = PADDING * scale * 2 setMeasuredCols(Math.max(1, Math.floor((sizes.rootW - innerPad) / contentSize))) setMeasuredRows(Math.max(1, Math.floor(sizes.gridH / contentSize))) }) ro.observe(root as unknown as Element) ro.observe(grid as unknown as Element) return () => ro.disconnect() }, [scale, contentSize]) const pushRecipeFrame = useCallback((targetItem: JEIItem, mode: 'recipes' | 'usages') => { const getter = mode === 'recipes' ? onGetRecipes : onGetUsages if (!getter || !onPushRecipeFrame) return const guides = getter(targetItem) if (guides.length === 0) return onPushRecipeFrame({ item: targetItem, mode, guides, guideIndex: 0 }) }, [onGetRecipes, onGetUsages, onPushRecipeFrame]) const padding = PADDING * scale const itemSize = contentSize // cols: explicit prop > internal measurement > default const cols = colsProp ?? measuredCols // panelWidth: explicit prop > 100% (container controls width) const panelWidth = width ?? '100%' // rows: explicit maxHeight override > internal measurement const rows = maxHeight ? Math.max(1, Math.floor(maxHeight / itemSize)) : measuredRows const itemsPerPage = cols * rows const toggleFavorite = useCallback((key: string) => { setFavorites((prev) => { const next = new Set(prev) if (next.has(key)) next.delete(key) else next.add(key) saveFavorites(next) return next }) }, []) // F / R / U keyboard handlers useEffect(() => { const handler = (e: KeyboardEvent) => { // Shift+F: toggle favorites-only filter if (e.code === 'KeyF' && e.shiftKey) { setShowFavorites((v) => !v) setPage(0) return } // F: toggle favorite for hovered JEI item if (e.code === 'KeyF' && hoveredSlot !== null && hoveredSlot < 0) { const item = slotToItemRef.current.get(hoveredSlot) if (item) toggleFavorite(getItemKey(item)) return } // R / U: show recipes / usages for hovered JEI item slot if (e.code !== 'KeyR' && e.code !== 'KeyU') return const mode = e.code === 'KeyR' ? 'recipes' : 'usages' if (hoveredSlot !== null && hoveredSlot < 0) { const item = slotToItemRef.current.get(hoveredSlot) if (item) pushRecipeFrame(item, mode) } } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) }, [hoveredSlot, toggleFavorite, pushRecipeFrame]) // Expose hoveredSlot info for parent-level nested navigation // (RecipeInventoryView handles its own R/U, JEI only needs to handle its own items) const baseList = useMemo(() => { if (showFavorites) return items.filter((i) => favorites.has(getItemKey(i))) return items }, [items, showFavorites, favorites]) const filteredItems = useMemo(() => { if (!search.trim()) return showAllItems ? baseList : [] const q = search.toLowerCase() return baseList.filter( (item) => item.displayName.toLowerCase().includes(q) || item.name.toLowerCase().includes(q) || String(item.type).includes(q), ) }, [baseList, search, showAllItems]) const totalPages = Math.ceil(filteredItems.length / itemsPerPage) const visibleItems = filteredItems.slice(page * itemsPerPage, (page + 1) * itemsPerPage) const handleSearchChange = useCallback((e: React.ChangeEvent) => { setSearch(e.target.value) setPage(0) }, []) const handleWheel = useCallback( (e: WheelEvent) => { e.preventDefault() setPage((prev) => { if (e.deltaY > 0) return Math.min(prev + 1, totalPages - 1) return Math.max(prev - 1, 0) }) }, [totalPages], ) // Non-passive wheel listener so preventDefault() actually suppresses page scroll. useEffect(() => { const el = gridRef.current if (!el) return el.addEventListener('wheel', handleWheel, { passive: false }) return () => el.removeEventListener('wheel', handleWheel) }, [handleWheel]) // Build slot→item map on each render slotToItemRef.current.clear() visibleItems.forEach((jeiItem, i) => { const slotIndex = -(page * itemsPerPage + i + 1) slotToItemRef.current.set(slotIndex, jeiItem) }) const displayAllButton = !showAllItems ? ( ) : null const showDisplayAllInGrid = !showAllItems && visibleItems.length === 0 return (
{/* Search + pagination controls — background only here */}
{/* Prev / Next / Favorites below search bar */}
{page + 1} / {Math.max(1, totalPages)}
{/* Item grid — no slot backgrounds, just raw items */}
{showDisplayAllInGrid && displayAllButton && (
{displayAllButton}
)} {visibleItems.map((jeiItem, i) => { const itemStack: ItemStack = { type: jeiItem.type, name: jeiItem.name, count: jeiItem.count ?? 1, metadata: jeiItem.metadata, displayName: jeiItem.displayName, texture: jeiItem.texture, blockTexture: jeiItem.blockTexture, } const slotIndex = -(page * itemsPerPage + i + 1) const isFav = favorites.has(getItemKey(jeiItem)) return (
{ if (button === 'left' && mode === 'normal') { if (onItemClick) { onItemClick(jeiItem) } else { pushRecipeFrame(jeiItem, 'recipes') } } else if (button === 'right' && mode === 'normal') { if (onItemRightClick) { onItemRightClick(jeiItem) } else { pushRecipeFrame(jeiItem, 'usages') } } else if (button === 'middle' && onItemMiddleClick) { onItemMiddleClick(jeiItem) } }} /> {isFav && ( )}
) })}
) }