minecraft-inventory 0.1.5 → 0.1.7

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/README.md CHANGED
@@ -262,16 +262,16 @@ pnpm add mc-assets # optional peer dependency
262
262
 
263
263
  ```tsx
264
264
  import { TextureProvider, InventoryOverlay } from 'minecraft-inventory'
265
- import { localTexturesConfig } from './generated/localTextures'
265
+ import { localBundledTexturesConfig } from './bundledTexturesConfig'
266
266
 
267
267
  // GUI container backgrounds (chest, furnace, etc.) load from bundled mc-assets;
268
268
  // item/block textures still use the default remote URLs unless you override them.
269
- <TextureProvider config={localTexturesConfig}>
269
+ <TextureProvider config={localBundledTexturesConfig}>
270
270
  <InventoryOverlay type="chest" ... />
271
271
  </TextureProvider>
272
272
  ```
273
273
 
274
- The generated file imports every `backgroundTexture` from your registry, resolves each to `node_modules/mc-assets/dist/other-textures/<version>/...` (or `latest/` if that version is missing), and exports `allContainerPaths` (inventory name short path) plus `localTexturesConfig.getGuiTextureUrl(path)` for use with `<TextureProvider config={...}>`. Re-run `pnpm gen:textures` after changing [inventory types](#adding-new-inventory-types).
274
+ The generated file (`src/generated/localTextures.ts`) exports `bundledTextureMap`, `allTexturePaths`, and `allContainerPaths`. Use `createBundledTexturesConfig({ remoteFallback: true, bundledTextureMap? })` to get a config with `getGuiTextureUrl`, `setOverride(path, image)`, `clearOverrides()`, `setRemoteFallback(enabled)`, and `resetRenderedSlots()`. Pass short paths to `setOverride` (e.g. `gui/sprites/container/anvil/text_field_disabled.png`) the version prefix exists only for the texture import generator. Call `resetRenderedSlots()` after multiple `setOverride` calls to invalidate cached textures so slots re-request them. `localBundledTexturesConfig` is the default instance. Re-run `pnpm gen:textures` after changing [inventory types](#adding-new-inventory-types).
275
275
 
276
276
  ---
277
277
 
@@ -546,9 +546,18 @@ interface ItemStack {
546
546
  lore?: string[]
547
547
  durability?: number // Current durability value
548
548
  maxDurability?: number // Max durability (renders bar when < max)
549
+ textureKey?: string // Override texture path for getItemTextureUrl
550
+ texture?: string | HTMLImageElement // Direct texture (bypasses URL lookup)
551
+ blockTexture?: BlockTextureRender // Isometric block icon from face slices
552
+ debugKey?: string
549
553
  }
550
554
  ```
551
555
 
556
+ **Texture overrides (mineflayer `itemMapper`):**
557
+ - `texture: string` — URL or data URL, fetched and cached.
558
+ - `texture: HTMLImageElement` — Preloaded image, used directly.
559
+ - `blockTexture: { source, top, left, right }` — Composite an isometric block icon from three face slices. Each face has `slice: [x, y, w, h]` in source texture pixels. Uses a pool of aux canvases (not recreated per slot).
560
+
552
561
  ---
553
562
 
554
563
  ## Project Structure
@@ -613,6 +622,10 @@ Each Minecraft window type has a fixed slot layout defined by the server protoco
613
622
  - [`prismarine-windows`](https://github.com/PrismarineJS/prismarine-windows) for JS slot maps
614
623
  - Minecraft source: `net/minecraft/world/inventory/` for the canonical layout
615
624
 
625
+ ### Memory leak note (Tooltip / MessageFormattedString)
626
+
627
+ If opening tooltips increases memory without returning to baseline, see [docs/MEMORY_LEAK_ANALYSIS.md](docs/MEMORY_LEAK_ANALYSIS.md). The likely cause is `filter:blur(2px)` on obfuscated (§k) text creating retained compositor layers.
628
+
616
629
  ### Connector protocol
617
630
 
618
631
  When implementing a custom connector, actions have these shapes:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minecraft-inventory",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "release": {
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Centralized config for bundled GUI textures. Uses maps from generated localTextures.
3
+ * Supports overrides via short paths (e.g. "gui/sprites/container/anvil/text_field_disabled.png"),
4
+ * remote fallback toggle, and cache invalidation after overrides.
5
+ *
6
+ * Version prefix (e.g. "1.21.11/textures/") in bundled keys exists only for the texture
7
+ * import generator — setOverride accepts short paths without it.
8
+ */
9
+
10
+ import {
11
+ bundledTextureMap,
12
+ allTexturePaths,
13
+ allContainerPaths,
14
+ } from './generated/localTextures'
15
+ import type { TextureConfig } from './context/TextureContext'
16
+ import { clearTextureCache } from './cache/textureCache'
17
+
18
+ const MC_ASSETS_REMOTE =
19
+ 'https://raw.githubusercontent.com/zardoy/mc-assets/refs/heads/gh-pages'
20
+
21
+ /** Normalize path to short form: "gui/..." (strip version prefix if present). */
22
+ function toShortPath(path: string): string {
23
+ const m = path.match(/^\d[\d.]+\/textures\/(.+)$/)
24
+ return m ? m[1] : path
25
+ }
26
+
27
+ /** Default version for building mc-assets URLs when path is short. */
28
+ const DEFAULT_VERSION = '1.21.11'
29
+
30
+ export interface BundledTexturesConfigOptions {
31
+ /** When true (default), unknown paths fall back to remote mc-assets URL. */
32
+ remoteFallback?: boolean
33
+ /** Override the bundled texture map (e.g. for custom builds). */
34
+ bundledTextureMap?: Record<string, string | undefined>
35
+ }
36
+
37
+ export interface BundledTexturesConfig extends Pick<TextureConfig, 'getGuiTextureUrl'> {
38
+ /**
39
+ * Override a texture. Pass short path (e.g. "gui/sprites/container/anvil/text_field_disabled.png")
40
+ * and image URL. Version prefix is only for the import generator — not needed here.
41
+ */
42
+ setOverride(path: string, image: string): void
43
+ /** Remove all texture overrides. */
44
+ clearOverrides(): void
45
+ /** Enable or disable remote fallback for unknown paths. */
46
+ setRemoteFallback(enabled: boolean): void
47
+ /**
48
+ * Invalidate cached textures so slots re-request them. Call after multiple setOverride
49
+ * calls when you want UI to immediately reflect new overrides.
50
+ */
51
+ resetRenderedSlots(): void
52
+ }
53
+
54
+ /**
55
+ * Build a reverse map: shortPath -> bundled URL.
56
+ */
57
+ function buildShortPathToBundled(
58
+ bundled: Record<string, string | undefined>,
59
+ ): Map<string, string> {
60
+ const map = new Map<string, string>()
61
+ for (const [key, url] of Object.entries(bundled)) {
62
+ if (!url) continue
63
+ const short = toShortPath(key)
64
+ if (!map.has(short)) map.set(short, url)
65
+ }
66
+ return map
67
+ }
68
+
69
+ /**
70
+ * Create a bundled textures config with overrides and optional remote fallback.
71
+ * Pass the returned config to <TextureProvider config={...}>.
72
+ */
73
+ export function createBundledTexturesConfig(
74
+ options?: BundledTexturesConfigOptions,
75
+ ): BundledTexturesConfig {
76
+ let remoteFallbackEnabled = options?.remoteFallback ?? true
77
+ const overrides = new Map<string, string>()
78
+ const bundled = options?.bundledTextureMap ?? bundledTextureMap
79
+ const shortToBundled = buildShortPathToBundled(bundled)
80
+
81
+ function getGuiTextureUrl(path: string): string {
82
+ const shortPath = toShortPath(path)
83
+
84
+ const override = overrides.get(shortPath)
85
+ if (override) return override
86
+
87
+ const bundledUrl = bundled[path] ?? shortToBundled.get(shortPath)
88
+ if (bundledUrl) return bundledUrl
89
+
90
+ if (
91
+ remoteFallbackEnabled &&
92
+ path.endsWith('.png') &&
93
+ path.includes('/textures/')
94
+ ) {
95
+ return `${MC_ASSETS_REMOTE}/${path}`
96
+ }
97
+
98
+ if (remoteFallbackEnabled && shortPath.endsWith('.png')) {
99
+ const versioned = `${DEFAULT_VERSION}/textures/${shortPath}`
100
+ return `${MC_ASSETS_REMOTE}/${versioned}`
101
+ }
102
+
103
+ return path
104
+ }
105
+
106
+ return {
107
+ getGuiTextureUrl,
108
+ setOverride(path: string, image: string) {
109
+ overrides.set(toShortPath(path), image)
110
+ },
111
+ clearOverrides() {
112
+ overrides.clear()
113
+ },
114
+ setRemoteFallback(enabled: boolean) {
115
+ remoteFallbackEnabled = enabled
116
+ },
117
+ resetRenderedSlots() {
118
+ clearTextureCache()
119
+ },
120
+ }
121
+ }
122
+
123
+ /** Default config instance for <TextureProvider config={localBundledTexturesConfig}>. */
124
+ export const localBundledTexturesConfig = createBundledTexturesConfig()
125
+
126
+ export { allTexturePaths, allContainerPaths }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Pool of offscreen canvases for block-face compositing.
3
+ * Reuses a fixed set of canvases instead of creating new ones per render.
4
+ */
5
+
6
+ const POOL_SIZE = 8
7
+ const OUTPUT_SIZE = 32
8
+
9
+ const pool: HTMLCanvasElement[] = []
10
+ const inUse = new Set<HTMLCanvasElement>()
11
+
12
+ function getCanvas(): HTMLCanvasElement {
13
+ const free = pool.find((c) => !inUse.has(c))
14
+ if (free) {
15
+ inUse.add(free)
16
+ return free
17
+ }
18
+ if (pool.length < POOL_SIZE) {
19
+ const c = document.createElement('canvas')
20
+ c.width = OUTPUT_SIZE
21
+ c.height = OUTPUT_SIZE
22
+ pool.push(c)
23
+ inUse.add(c)
24
+ return c
25
+ }
26
+ const c = pool[pool.length - 1]
27
+ inUse.add(c)
28
+ return c
29
+ }
30
+
31
+ function releaseCanvas(c: HTMLCanvasElement): void {
32
+ inUse.delete(c)
33
+ }
34
+
35
+ /** Slice rect [x, y, width, height] */
36
+ type Slice = [number, number, number, number]
37
+
38
+ /**
39
+ * Composite top/left/right block faces into an isometric-style icon.
40
+ * Uses a pool of canvases; not recreated each time.
41
+ */
42
+ export function renderBlockIcon(
43
+ source: HTMLImageElement | string,
44
+ top: Slice,
45
+ left: Slice,
46
+ right: Slice,
47
+ ): Promise<string> {
48
+ return new Promise((resolve, reject) => {
49
+ const isUrl = typeof source === 'string'
50
+ const img = isUrl ? new Image() : (source as HTMLImageElement)
51
+
52
+ const draw = (image: HTMLImageElement) => {
53
+ let canvas: HTMLCanvasElement | null = null
54
+ try {
55
+ canvas = getCanvas()
56
+ const ctx = canvas.getContext('2d')
57
+ if (!ctx) throw new Error('No 2d context')
58
+
59
+ ctx.imageSmoothingEnabled = false
60
+ ctx.clearRect(0, 0, OUTPUT_SIZE, OUTPUT_SIZE)
61
+
62
+ const s = OUTPUT_SIZE / 2 - 2 // face size with padding (14px at 32px canvas)
63
+ const ox = (OUTPUT_SIZE - 2 * s) / 2 // horizontal offset to center
64
+ const oy = (OUTPUT_SIZE - 2 * s) / 2 // vertical offset to center
65
+ const [tx, ty, tw, th] = top
66
+ const [lx, ly, lw, lh] = left
67
+ const [rx, ry, rw, rh] = right
68
+
69
+ // Enable smoothing for isometric transforms (better diagonal edges)
70
+ ctx.imageSmoothingEnabled = true
71
+ ctx.imageSmoothingQuality = 'high'
72
+
73
+ // Isometric cube using affine transforms.
74
+ // Face vertices for a cube centered in OUTPUT_SIZE × OUTPUT_SIZE:
75
+ // Top: (s+ox, oy) → (2s+ox, s/2+oy) → (s+ox, s+oy) → (ox, s/2+oy)
76
+ // Left: (ox, s/2+oy) → (s+ox, s+oy) → (s+ox, 2s+oy) → (ox, 3s/2+oy)
77
+ // Right: (s+ox, s+oy) → (2s+ox, s/2+oy) → (2s+ox, 3s/2+oy) → (s+ox, 2s+oy)
78
+
79
+ // Top face
80
+ ctx.save()
81
+ ctx.setTransform(1, 0.5, -1, 0.5, s + ox, oy)
82
+ ctx.drawImage(image, tx, ty, tw, th, 0, 0, s, s)
83
+ ctx.restore()
84
+
85
+ // Left face (darkened)
86
+ ctx.save()
87
+ ctx.setTransform(1, 0.5, 0, 1, ox, s / 2 + oy)
88
+ ctx.drawImage(image, lx, ly, lw, lh, 0, 0, s, s)
89
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.4)'
90
+ ctx.fillRect(0, 0, s, s)
91
+ ctx.restore()
92
+
93
+ // Right face (slightly darkened)
94
+ ctx.save()
95
+ ctx.setTransform(1, -0.5, 0, 1, s + ox, s + oy)
96
+ ctx.drawImage(image, rx, ry, rw, rh, 0, 0, s, s)
97
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.2)'
98
+ ctx.fillRect(0, 0, s, s)
99
+ ctx.restore()
100
+
101
+ const dataUrl = canvas.toDataURL('image/png')
102
+ resolve(dataUrl)
103
+ } catch (e) {
104
+ reject(e)
105
+ } finally {
106
+ if (canvas) releaseCanvas(canvas)
107
+ }
108
+ }
109
+
110
+ if (isUrl) {
111
+ img.onload = () => draw(img)
112
+ img.onerror = () =>
113
+ reject(new Error('Failed to load block texture'))
114
+ img.crossOrigin = 'anonymous'
115
+ img.src = source
116
+ } else if (
117
+ (img).complete &&
118
+ (img).naturalWidth > 0
119
+ ) {
120
+ draw(img)
121
+ } else {
122
+ img.onload = () => draw(img)
123
+ img.onerror = () =>
124
+ reject(new Error('Block texture image failed'))
125
+ }
126
+ })
127
+ }
@@ -45,6 +45,16 @@ export function isTextureFailed(url: string): boolean {
45
45
  return failedUrls.has(url)
46
46
  }
47
47
 
48
+ /**
49
+ * Clear all cached texture data URLs and failed state.
50
+ * Call after setOverride in bundledTexturesConfig so slots re-request textures.
51
+ */
52
+ export function clearTextureCache(): void {
53
+ dataUrlCache.clear()
54
+ failedUrls.clear()
55
+ inflight.clear()
56
+ }
57
+
48
58
  /**
49
59
  * Hook that resolves a URL to a cached base64 data URL.
50
60
  * Returns:
@@ -52,6 +52,8 @@ export interface InventoryOverlayProps {
52
52
  debugBounds?: boolean
53
53
  /** Show red debug outline around the inventory background */
54
54
  showDebug?: boolean
55
+ /** When true, entity display area shows layout debug bounds instead of the default image. */
56
+ entityDisplayDebug?: boolean
55
57
  /** Override entity display rendering. Pass a function returning JSX, or null to hide. */
56
58
  renderEntity?: ((width: number, height: number) => React.ReactNode) | null
57
59
  /** Hide the "INV" version watermark (opt-out) */
@@ -80,6 +82,7 @@ export function InventoryOverlay({
80
82
  children,
81
83
  debugBounds = false,
82
84
  showDebug = false,
85
+ entityDisplayDebug = false,
83
86
  renderEntity,
84
87
  noWatermark = false,
85
88
  }: InventoryOverlayProps) {
@@ -242,7 +245,14 @@ export function InventoryOverlay({
242
245
  onPushFrame={handleRecipePushFrame}
243
246
  />
244
247
  ) : (
245
- <InventoryWindow type={type} title={title} properties={properties} showDebug={showDebug} renderEntity={renderEntity} />
248
+ <InventoryWindow
249
+ type={type}
250
+ title={title}
251
+ properties={properties}
252
+ showDebug={showDebug}
253
+ entityDisplayDebug={entityDisplayDebug}
254
+ renderEntity={renderEntity}
255
+ />
246
256
  )}
247
257
  </div>
248
258
  </div>
@@ -288,7 +298,7 @@ export function InventoryOverlay({
288
298
  lineHeight: 1,
289
299
  }}
290
300
  >
291
- INV 0.0.0
301
+ INV 0.1.7
292
302
  </a>
293
303
  )}
294
304
 
@@ -1,13 +1,22 @@
1
1
  import React from 'react'
2
2
  import { useScale } from '../../context/ScaleContext'
3
- import type { EntityDisplayArea } from '../../types'
3
+ import type { EntityDisplayArea, InventoryTypeDefinition } from '../../types'
4
+ import { ENTITY_PLACEHOLDER_IMAGES } from './defaultEntityImages'
4
5
 
5
6
  interface EntityDisplayProps {
6
7
  area: EntityDisplayArea
8
+ placeholder: NonNullable<InventoryTypeDefinition['entityPlaceholder']>
9
+ /** When true, draw layout debug bounds instead of the default image (ignored if renderEntity is set). */
10
+ debug?: boolean
7
11
  renderEntity?: ((width: number, height: number) => React.ReactNode) | null
8
12
  }
9
13
 
10
- export function EntityDisplay({ area, renderEntity }: EntityDisplayProps) {
14
+ export function EntityDisplay({
15
+ area,
16
+ placeholder,
17
+ debug = false,
18
+ renderEntity,
19
+ }: EntityDisplayProps) {
11
20
  const { scale } = useScale()
12
21
 
13
22
  if (renderEntity === null) return null
@@ -15,6 +24,44 @@ export function EntityDisplay({ area, renderEntity }: EntityDisplayProps) {
15
24
  const w = area.width * scale
16
25
  const h = area.height * scale
17
26
 
27
+ let content: React.ReactNode
28
+ if (renderEntity) {
29
+ content = renderEntity(w, h)
30
+ } else if (debug) {
31
+ content = (
32
+ <div
33
+ className="mc-inv-entity-display-placeholder"
34
+ style={{
35
+ width: '100%',
36
+ height: '100%',
37
+ background: 'rgba(255, 0, 0, 0.15)',
38
+ border: '1px solid rgba(255, 0, 0, 0.4)',
39
+ boxSizing: 'border-box',
40
+ }}
41
+ />
42
+ )
43
+ } else {
44
+ const image = ENTITY_PLACEHOLDER_IMAGES[placeholder]
45
+ if (!image) return null
46
+ content = (
47
+ <img
48
+ src={ENTITY_PLACEHOLDER_IMAGES[placeholder]}
49
+ alt=""
50
+ draggable={false}
51
+ className="mc-inv-entity-display-image"
52
+ style={{
53
+ display: 'block',
54
+ width: '100%',
55
+ height: '100%',
56
+ objectFit: 'contain',
57
+ pointerEvents: 'none',
58
+ userSelect: 'none',
59
+ imageRendering: 'pixelated',
60
+ }}
61
+ />
62
+ )
63
+ }
64
+
18
65
  return (
19
66
  <div
20
67
  className="mc-inv-entity-display"
@@ -27,20 +74,7 @@ export function EntityDisplay({ area, renderEntity }: EntityDisplayProps) {
27
74
  overflow: 'hidden',
28
75
  }}
29
76
  >
30
- {renderEntity ? (
31
- renderEntity(w, h)
32
- ) : (
33
- <div
34
- className="mc-inv-entity-display-placeholder"
35
- style={{
36
- width: '100%',
37
- height: '100%',
38
- background: 'rgba(255, 0, 0, 0.15)',
39
- border: '1px solid rgba(255, 0, 0, 0.4)',
40
- boxSizing: 'border-box',
41
- }}
42
- />
43
- )}
77
+ {content}
44
78
  </div>
45
79
  )
46
80
  }
@@ -37,7 +37,7 @@ export function InventoryBackground({
37
37
  const { scale } = useScale()
38
38
 
39
39
  const bgUrl = textures.getGuiTextureUrl(definition.backgroundTexture)
40
- const isStitched = definition.containerRows != null && definition.containerRows < 6
40
+ const isStitched = definition.containerRows != null && definition.containerRows <= 6
41
41
 
42
42
  const w = definition.backgroundWidth * scale
43
43
  const h = definition.backgroundHeight * scale
@@ -22,6 +22,8 @@ interface InventoryWindowProps {
22
22
  style?: React.CSSProperties
23
23
  enableKeyboardShortcuts?: boolean
24
24
  showDebug?: boolean
25
+ /** When true, entity slot shows layout debug bounds instead of the default placeholder image. */
26
+ entityDisplayDebug?: boolean
25
27
  /** Override entity display rendering. Pass a function returning JSX, or null to hide. */
26
28
  renderEntity?: ((width: number, height: number) => React.ReactNode) | null
27
29
  }
@@ -35,6 +37,7 @@ export function InventoryWindow({
35
37
  style,
36
38
  enableKeyboardShortcuts = true,
37
39
  showDebug = false,
40
+ entityDisplayDebug = false,
38
41
  renderEntity,
39
42
  }: InventoryWindowProps) {
40
43
  const def = getInventoryType(type)
@@ -104,8 +107,13 @@ export function InventoryWindow({
104
107
  ))}
105
108
 
106
109
  {/* Entity display area */}
107
- {def.entityDisplay && (
108
- <EntityDisplay area={def.entityDisplay} renderEntity={renderEntity} />
110
+ {def.entityDisplay && def.entityPlaceholder && (
111
+ <EntityDisplay
112
+ area={def.entityDisplay}
113
+ placeholder={def.entityPlaceholder}
114
+ debug={entityDisplayDebug}
115
+ renderEntity={renderEntity}
116
+ />
109
117
  )}
110
118
 
111
119
  {/* Progress bars */}
@@ -0,0 +1,13 @@
1
+ import playerImage from '../../assets/entities/player.png'
2
+ import horseImage from '../../assets/entities/horse.png'
3
+ import llamaImage from '../../assets/entities/llama.png'
4
+ import { InventoryTypeDefinition } from '../../types'
5
+
6
+ export const ENTITY_PLACEHOLDER_IMAGES: Record<
7
+ NonNullable<InventoryTypeDefinition['entityPlaceholder']>,
8
+ string | undefined
9
+ > = {
10
+ player: playerImage,
11
+ horse: horseImage,
12
+ llama: llamaImage,
13
+ }
@@ -3,6 +3,7 @@ import type { ItemStack } from '../../types'
3
3
  import { useTextures } from '../../context/TextureContext'
4
4
  import { useScale } from '../../context/ScaleContext'
5
5
  import { useDataUrl, isTextureFailed } from '../../cache/textureCache'
6
+ import { renderBlockIcon } from '../../cache/blockRenderer'
6
7
 
7
8
  interface ItemCanvasProps {
8
9
  item: ItemStack
@@ -22,28 +23,68 @@ function getDurabilityColor(current: number, max: number): string {
22
23
  return '#ff5555'
23
24
  }
24
25
 
25
- /** Renders a single item: texture as <img>, count as <span>, durability as CSS bars. */
26
- export const ItemCanvas = memo(function ItemCanvas({
27
- item,
28
- size,
29
- noCount = false,
30
- noDurability = false,
31
- className,
32
- style,
33
- }: ItemCanvasProps) {
26
+ /** Resolve item texture source: direct override, block render, or URL lookup. */
27
+ function useItemTextureSrc(item: ItemStack): {
28
+ src: string | null
29
+ failed: boolean
30
+ loading: boolean
31
+ } {
34
32
  const textures = useTextures()
35
- const { contentSize, pixelSize } = useScale()
36
- const renderSize = size ?? contentSize
37
33
 
38
- const primaryUrl = textures.getItemTextureUrl(item)
39
- // Skip the block-texture fallback when a textureKey override is already in use
40
- const fallbackUrl = !item.textureKey && item.name ? textures.getBlockTextureUrl(item) : null
34
+ // Block texture: always run effect for blockTexture case
35
+ const blockConfig = item.blockTexture ?? null
36
+ const [blockDataUrl, setBlockDataUrl] = useState<string | null>(null)
37
+ const [blockFailed, setBlockFailed] = useState(false)
38
+
39
+ useEffect(() => {
40
+ if (!blockConfig) return
41
+ let cancelled = false
42
+ renderBlockIcon(
43
+ blockConfig.source,
44
+ blockConfig.top.slice,
45
+ blockConfig.left.slice,
46
+ blockConfig.right.slice,
47
+ )
48
+ .then((url) => {
49
+ if (!cancelled) setBlockDataUrl(url)
50
+ })
51
+ .catch(() => {
52
+ if (!cancelled) setBlockFailed(true)
53
+ })
54
+ return () => {
55
+ cancelled = true
56
+ }
57
+ }, [
58
+ blockConfig?.source,
59
+ blockConfig ? String(blockConfig.top.slice) : '',
60
+ blockConfig ? String(blockConfig.left.slice) : '',
61
+ blockConfig ? String(blockConfig.right.slice) : '',
62
+ ])
63
+
64
+ // Reset block state when switching away from blockTexture
65
+ useEffect(() => {
66
+ if (!blockConfig) {
67
+ setBlockDataUrl(null)
68
+ setBlockFailed(false)
69
+ }
70
+ }, [blockConfig])
71
+
72
+ // Direct texture (string) - use cache
73
+ const directUrl =
74
+ typeof item.texture === 'string' ? item.texture : null
75
+ const directDataUrl = useDataUrl(directUrl)
41
76
 
42
- // Load primary URL as cached data URL
43
- const primaryDataUrl = useDataUrl(primaryUrl)
77
+ // Default URL lookup
78
+ const primaryUrl = blockConfig ? '' : (typeof item.texture === 'string' ? '' : textures.getItemTextureUrl(item))
79
+ const fallbackUrl =
80
+ !blockConfig &&
81
+ !item.textureKey &&
82
+ item.name
83
+ ? textures.getBlockTextureUrl(item)
84
+ : null
85
+ const primaryDataUrl = useDataUrl(primaryUrl || null)
44
86
  const primaryFailed = primaryDataUrl === null || isTextureFailed(primaryUrl)
45
87
 
46
- // Load fallback only once primary is known to have failed
47
88
  const [loadFallback, setLoadFallback] = useState(false)
48
89
  useEffect(() => {
49
90
  if (primaryFailed && fallbackUrl) setLoadFallback(true)
@@ -51,11 +92,56 @@ export const ItemCanvas = memo(function ItemCanvas({
51
92
  }, [primaryFailed, fallbackUrl])
52
93
  const fallbackDataUrl = useDataUrl(loadFallback ? fallbackUrl : null)
53
94
 
95
+ // Resolve final src by priority
96
+ if (typeof item.texture === 'string') {
97
+ return {
98
+ src: directDataUrl ?? null,
99
+ failed: directDataUrl === null || isTextureFailed(item.texture),
100
+ loading: directDataUrl === undefined,
101
+ }
102
+ }
103
+ if (item.texture instanceof HTMLImageElement) {
104
+ const img = item.texture
105
+ return {
106
+ src: img.complete && img.naturalWidth > 0 ? img.src : null,
107
+ failed: false,
108
+ loading: !img.complete,
109
+ }
110
+ }
111
+ if (blockConfig) {
112
+ return {
113
+ src: blockDataUrl,
114
+ failed: blockFailed,
115
+ loading: !blockDataUrl && !blockFailed,
116
+ }
117
+ }
118
+
54
119
  const src = loadFallback ? fallbackDataUrl : primaryDataUrl
55
120
  const failed = loadFallback
56
121
  ? (fallbackDataUrl === null || isTextureFailed(fallbackUrl ?? ''))
57
122
  : primaryFailed && !fallbackUrl
58
123
 
124
+ return {
125
+ src: src ?? null,
126
+ failed,
127
+ loading: src === undefined,
128
+ }
129
+ }
130
+
131
+ /** Renders a single item: texture as <img>, count as <span>, durability as CSS bars. */
132
+ export const ItemCanvas = memo(function ItemCanvas({
133
+ item,
134
+ size,
135
+ noCount = false,
136
+ noDurability = false,
137
+ className,
138
+ style,
139
+ }: ItemCanvasProps) {
140
+ const { contentSize, pixelSize } = useScale()
141
+ const renderSize = size ?? contentSize
142
+
143
+ const { src, failed, loading } = useItemTextureSrc(item)
144
+
59
145
  const hasDurability =
60
146
  !noDurability &&
61
147
  item.durability !== undefined &&
@@ -1,6 +1,8 @@
1
1
  .jei {
2
2
  font-family: 'Minecraft', monospace;
3
3
  image-rendering: pixelated;
4
+ height: 100%;
5
+ border-radius: 1px;
4
6
  }
5
7
 
6
8
  .searchInput::placeholder {