minecraft-inventory 0.1.4 → 0.1.6

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.4",
3
+ "version": "0.1.6",
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,103 @@
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 / 16
63
+ const [tx, ty, tw, th] = top
64
+ const [lx, ly, lw, lh] = left
65
+ const [rx, ry, rw, rh] = right
66
+
67
+ // Isometric layout: top face tilted, left and right as sides
68
+ ctx.save()
69
+ ctx.translate(8 * s, 2 * s)
70
+ ctx.rotate(-Math.PI / 4)
71
+ ctx.drawImage(image, tx, ty, tw, th, -4 * s, -4 * s, 8 * s, 8 * s)
72
+ ctx.restore()
73
+
74
+ ctx.drawImage(image, lx, ly, lw, lh, 0, 12 * s, 10 * s, 10 * s)
75
+ ctx.drawImage(image, rx, ry, rw, rh, 12 * s, 12 * s, 10 * s, 10 * s)
76
+
77
+ const dataUrl = canvas.toDataURL('image/png')
78
+ resolve(dataUrl)
79
+ } catch (e) {
80
+ reject(e)
81
+ } finally {
82
+ if (canvas) releaseCanvas(canvas)
83
+ }
84
+ }
85
+
86
+ if (isUrl) {
87
+ img.onload = () => draw(img)
88
+ img.onerror = () =>
89
+ reject(new Error('Failed to load block texture'))
90
+ img.crossOrigin = 'anonymous'
91
+ img.src = source
92
+ } else if (
93
+ (img).complete &&
94
+ (img).naturalWidth > 0
95
+ ) {
96
+ draw(img)
97
+ } else {
98
+ img.onload = () => draw(img)
99
+ img.onerror = () =>
100
+ reject(new Error('Block texture image failed'))
101
+ }
102
+ })
103
+ }
@@ -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:
@@ -288,7 +288,7 @@ export function InventoryOverlay({
288
288
  lineHeight: 1,
289
289
  }}
290
290
  >
291
- INV 0.0.0
291
+ INV 0.1.6
292
292
  </a>
293
293
  )}
294
294
 
@@ -1,54 +1,24 @@
1
- import React, { useEffect, useState } from 'react'
1
+ import React from 'react'
2
2
  import { useTextures } from '../../context/TextureContext'
3
3
  import { useScale } from '../../context/ScaleContext'
4
4
  import { MessageFormattedString } from '../Text/MessageFormattedString'
5
5
  import type { InventoryTypeDefinition } from '../../registry'
6
6
 
7
7
  /**
8
- * For generic_9xN (N < 6): canvas-stitch the 6-row generic_54 texture.
9
- * Takes the top (title + N rows) and bottom (player-inventory section = last 96px)
10
- * from the source and composes them into a data URL of the correct output height.
8
+ * For generic_9xN (N < 6): CSS-stitch the 6-row generic_54 texture using two
9
+ * overlapping img elements with objectPosition to clip top and bottom portions.
10
+ * No canvas or async loading needed works with any URL including remote ones.
11
11
  *
12
- * The source texture (generic_54.png, 176×222) layout:
13
- * y=0..16 — title bar (17px)
14
- * y=17..124 — 6 container rows (6×18 = 108px)
15
- * y=126..221 — player inventory section (96px)
12
+ * The source texture (generic_54.png, 176×222) fixed layout:
13
+ * y=0 ..16 — title bar (17px)
14
+ * y=17 ..124 — 6 container rows (6×18 = 108px)
15
+ * y=125 ..221 — player inventory section (97px)
16
+ *
17
+ * Output height = topH + playerH = N*18+17 + 97 = N*18+114.
18
+ * Registry backgroundHeight must equal N*18+114 for the div to match.
16
19
  */
17
- function useStitchedTexture(srcUrl: string, containerRows: number): string | null {
18
- const [dataUrl, setDataUrl] = useState<string | null>(null)
19
-
20
- useEffect(() => {
21
- if (containerRows >= 6) {
22
- setDataUrl(srcUrl)
23
- return
24
- }
25
- let cancelled = false
26
- const img = new window.Image()
27
- img.crossOrigin = 'anonymous'
28
- img.onload = () => {
29
- if (cancelled) return
30
- const srcW = 176
31
- const srcH = img.naturalHeight // 222 for generic_54
32
- const topH = containerRows * 18 + 17
33
- const playerH = 96
34
- const canvas = document.createElement('canvas')
35
- canvas.width = srcW
36
- canvas.height = topH + playerH
37
- const ctx = canvas.getContext('2d')
38
- if (!ctx) { setDataUrl(srcUrl); return }
39
- // Top: title + N container rows
40
- ctx.drawImage(img, 0, 0, srcW, topH, 0, 0, srcW, topH)
41
- // Bottom: player inventory section (last 96px of source)
42
- ctx.drawImage(img, 0, srcH - playerH, srcW, playerH, 0, topH, srcW, playerH)
43
- setDataUrl(canvas.toDataURL())
44
- }
45
- img.onerror = () => { if (!cancelled) setDataUrl(srcUrl) }
46
- img.src = srcUrl
47
- return () => { cancelled = true }
48
- }, [srcUrl, containerRows])
49
-
50
- return dataUrl
51
- }
20
+ const SRC_PLAYER_Y = 17 + 6 * 18 // 125 — fixed position where player section starts
21
+ const PLAYER_H = 222 - SRC_PLAYER_Y // 97px — height of player section in generic_54.png
52
22
 
53
23
  interface InventoryBackgroundProps {
54
24
  definition: InventoryTypeDefinition
@@ -66,20 +36,24 @@ export function InventoryBackground({
66
36
  const textures = useTextures()
67
37
  const { scale } = useScale()
68
38
 
69
- const rawBgUrl = textures.getGuiTextureUrl(definition.backgroundTexture)
70
- // For generic_9xN (containerRows defined and < 6): canvas-stitch the texture
71
- const stitchedUrl = useStitchedTexture(rawBgUrl, definition.containerRows ?? 6)
72
- const bgUrl = definition.containerRows != null && definition.containerRows < 6
73
- ? stitchedUrl // may be null while stitching
74
- : rawBgUrl
39
+ const bgUrl = textures.getGuiTextureUrl(definition.backgroundTexture)
40
+ const isStitched = definition.containerRows != null && definition.containerRows <= 6
75
41
 
76
42
  const w = definition.backgroundWidth * scale
77
43
  const h = definition.backgroundHeight * scale
78
44
 
79
- // Source dimensions from definition (e.g., 176x166) — clip to this region from texture
80
45
  const srcW = definition.backgroundWidth
81
46
  const srcH = definition.backgroundHeight
82
47
 
48
+ const sharedImgStyle: React.CSSProperties = {
49
+ display: 'block',
50
+ width: srcW,
51
+ imageRendering: 'pixelated',
52
+ pointerEvents: 'none',
53
+ userSelect: 'none',
54
+ objectFit: 'none',
55
+ }
56
+
83
57
  return (
84
58
  <div
85
59
  className="mc-inv-background"
@@ -93,42 +67,55 @@ export function InventoryBackground({
93
67
  outlineOffset: 0,
94
68
  }}
95
69
  >
96
- {/* Background texture wrapper — clips source to srcW×srcH, then scales */}
97
- <div
98
- className="mc-inv-background-wrapper"
99
- style={{
100
- position: 'absolute',
101
- top: 0,
102
- left: 0,
103
- width: srcW,
104
- height: srcH,
105
- overflow: 'hidden',
106
- transform: `scale(${scale})`,
107
- transformOrigin: 'top left',
108
- }}
109
- >
110
- {/* Background texture — render at natural size, clipped by wrapper overflow */}
111
- {bgUrl && (
112
- <img
113
- className="mc-inv-background-image"
114
- src={bgUrl}
115
- alt=""
116
- aria-hidden
70
+ {isStitched ? (
71
+ /* CSS two-part stitch: clips top (title+N rows) and bottom (player section)
72
+ from the same source image using objectPosition — no canvas/async needed. */
73
+ <div
74
+ className="mc-inv-background-wrapper"
117
75
  style={{
118
- display: 'block',
76
+ position: 'absolute',
77
+ top: 0,
78
+ left: 0,
79
+ transform: `scale(${scale})`,
80
+ transformOrigin: 'top left',
81
+ }}
82
+ >
83
+ {/* Top: title bar + N container rows */}
84
+ <div style={{ width: srcW, height: definition.containerRows! * 18 + 17, overflow: 'hidden' }}>
85
+ <img className="mc-inv-background-image" src={bgUrl} alt="" aria-hidden draggable={false}
86
+ style={{ ...sharedImgStyle, objectPosition: '0 0' }} />
87
+ </div>
88
+ {/* Bottom: player inventory section starting at SRC_PLAYER_Y in source */}
89
+ <div style={{ width: srcW, height: PLAYER_H, overflow: 'hidden' }}>
90
+ <img src={bgUrl} alt="" aria-hidden draggable={false}
91
+ style={{ ...sharedImgStyle, objectPosition: `0 -${SRC_PLAYER_Y}px` }} />
92
+ </div>
93
+ </div>
94
+ ) : (
95
+ /* Standard: clip source to srcW×srcH via overflow:hidden, then scale */
96
+ <div
97
+ className="mc-inv-background-wrapper"
98
+ style={{
99
+ position: 'absolute',
100
+ top: 0,
101
+ left: 0,
119
102
  width: srcW,
120
103
  height: srcH,
121
- imageRendering: 'pixelated',
122
- pointerEvents: 'none',
123
- userSelect: 'none',
124
- // Clip to top-left srcW×srcH region (if texture is larger)
125
- objectFit: 'none',
126
- objectPosition: '0 0',
104
+ overflow: 'hidden',
105
+ transform: `scale(${scale})`,
106
+ transformOrigin: 'top left',
127
107
  }}
128
- draggable={false}
129
- />
130
- )}
131
- </div>
108
+ >
109
+ <img
110
+ className="mc-inv-background-image"
111
+ src={bgUrl}
112
+ alt=""
113
+ aria-hidden
114
+ draggable={false}
115
+ style={{ ...sharedImgStyle, objectPosition: '0 0' }}
116
+ />
117
+ </div>
118
+ )}
132
119
 
133
120
  {/* Title */}
134
121
  {title !== undefined && (
@@ -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 {