minecraft-inventory 0.1.0

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.
Files changed (51) hide show
  1. package/README.md +566 -0
  2. package/package.json +37 -0
  3. package/src/InventoryGUI.tsx +108 -0
  4. package/src/cache/textureCache.ts +95 -0
  5. package/src/components/CursorItem/CursorItem.tsx +94 -0
  6. package/src/components/CursorItem/index.ts +1 -0
  7. package/src/components/HUD/HUD.tsx +11 -0
  8. package/src/components/HUD/index.ts +1 -0
  9. package/src/components/Hotbar/Hotbar.tsx +180 -0
  10. package/src/components/Hotbar/index.ts +1 -0
  11. package/src/components/InventoryOverlay/InventoryOverlay.tsx +196 -0
  12. package/src/components/InventoryOverlay/index.ts +2 -0
  13. package/src/components/InventoryWindow/EnchantmentOptions.tsx +109 -0
  14. package/src/components/InventoryWindow/InventoryBackground.tsx +110 -0
  15. package/src/components/InventoryWindow/InventoryWindow.tsx +120 -0
  16. package/src/components/InventoryWindow/ProgressBar.tsx +78 -0
  17. package/src/components/InventoryWindow/VillagerTradeList.tsx +136 -0
  18. package/src/components/InventoryWindow/index.ts +5 -0
  19. package/src/components/ItemCanvas/ItemCanvas.tsx +154 -0
  20. package/src/components/ItemCanvas/index.ts +1 -0
  21. package/src/components/JEI/JEI.module.css +37 -0
  22. package/src/components/JEI/JEI.tsx +303 -0
  23. package/src/components/JEI/index.ts +2 -0
  24. package/src/components/RecipeGuide/RecipeInventoryView.tsx +293 -0
  25. package/src/components/RecipeGuide/index.ts +1 -0
  26. package/src/components/Slot/Slot.module.css +111 -0
  27. package/src/components/Slot/Slot.tsx +363 -0
  28. package/src/components/Slot/index.ts +1 -0
  29. package/src/components/Text/MessageFormatted.css +5 -0
  30. package/src/components/Text/MessageFormatted.tsx +79 -0
  31. package/src/components/Text/MessageFormattedString.tsx +74 -0
  32. package/src/components/Text/chatUtils.ts +172 -0
  33. package/src/components/Tooltip/Tooltip.module.css +56 -0
  34. package/src/components/Tooltip/Tooltip.tsx +130 -0
  35. package/src/components/Tooltip/index.ts +1 -0
  36. package/src/connector/demo.ts +213 -0
  37. package/src/connector/index.ts +4 -0
  38. package/src/connector/mineflayer.ts +113 -0
  39. package/src/connector/types.ts +41 -0
  40. package/src/context/InventoryContext.tsx +157 -0
  41. package/src/context/ScaleContext.tsx +73 -0
  42. package/src/context/TextureContext.tsx +70 -0
  43. package/src/globals.d.ts +4 -0
  44. package/src/hooks/useKeyboardShortcuts.ts +41 -0
  45. package/src/hooks/useMobile.ts +28 -0
  46. package/src/index.tsx +65 -0
  47. package/src/mount.tsx +52 -0
  48. package/src/registry/index.ts +21 -0
  49. package/src/registry/inventories.ts +612 -0
  50. package/src/styles/tokens.css +47 -0
  51. package/src/types.ts +176 -0
@@ -0,0 +1,363 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react'
2
+ import type { ItemStack } from '../../types'
3
+ import { useInventoryContext } from '../../context/InventoryContext'
4
+ import { useScale } from '../../context/ScaleContext'
5
+ import { ItemCanvas } from '../ItemCanvas'
6
+ import { Tooltip } from '../Tooltip'
7
+ import { useMobile } from '../../hooks/useMobile'
8
+ import styles from './Slot.module.css'
9
+
10
+ interface SlotProps {
11
+ index: number
12
+ item: ItemStack | null
13
+ size?: number
14
+ highlighted?: boolean
15
+ disabled?: boolean
16
+ resultSlot?: boolean
17
+ label?: string
18
+ className?: string
19
+ style?: React.CSSProperties
20
+ /** Remove slot background/border (e.g. for JEI items) */
21
+ noBackground?: boolean
22
+ /** Override default click behavior - when provided, calls this instead of sendAction */
23
+ onClickOverride?: (button: 'left' | 'right' | 'middle', mode: 'normal' | 'shift' | 'double') => void
24
+ }
25
+
26
+ export function Slot({
27
+ index,
28
+ item,
29
+ size,
30
+ highlighted,
31
+ disabled,
32
+ resultSlot,
33
+ label,
34
+ className,
35
+ style,
36
+ noBackground,
37
+ onClickOverride,
38
+ }: SlotProps) {
39
+ const {
40
+ heldItem,
41
+ sendAction,
42
+ isDragging,
43
+ dragSlots,
44
+ dragButton,
45
+ startDrag,
46
+ addDragSlot,
47
+ endDrag,
48
+ hoveredSlot,
49
+ setHoveredSlot,
50
+ activeNumberKey,
51
+ } = useInventoryContext()
52
+
53
+ const { contentSize } = useScale()
54
+ const isMobile = useMobile()
55
+ const slotRef = useRef<HTMLDivElement>(null)
56
+ const mousePos = useRef({ x: 0, y: 0 })
57
+ const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 })
58
+ const [showTooltip, setShowTooltip] = useState(false)
59
+ const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
60
+ // Slot div = item content area. Slot is already positioned inside the texture border by InventoryWindow.
61
+ const renderSize = size ?? contentSize
62
+
63
+ const isHovered = hoveredSlot === index
64
+ const isDragTarget = dragSlots.includes(index)
65
+
66
+ // Keyboard number key while hovering
67
+ useEffect(() => {
68
+ if (!isHovered || activeNumberKey === null || isMobile) return
69
+ sendAction({ type: 'hotbar-swap', slotIndex: index, hotbarSlot: activeNumberKey })
70
+ }, [activeNumberKey, isHovered, index, sendAction, isMobile])
71
+
72
+ const handleMouseEnter = useCallback((e: React.MouseEvent) => {
73
+ if (isMobile) return
74
+ setHoveredSlot(index)
75
+ setTooltipPos({ x: e.clientX, y: e.clientY })
76
+ setShowTooltip(true)
77
+ if (isDragging) addDragSlot(index)
78
+ }, [isMobile, index, setHoveredSlot, isDragging, addDragSlot])
79
+
80
+ const handleMouseLeave = useCallback(() => {
81
+ if (isMobile) return
82
+ setHoveredSlot(null)
83
+ setShowTooltip(false)
84
+ }, [isMobile, setHoveredSlot])
85
+
86
+ const handleMouseMove = useCallback((e: React.MouseEvent) => {
87
+ if (isMobile) return
88
+ mousePos.current = { x: e.clientX, y: e.clientY }
89
+ setTooltipPos({ x: e.clientX, y: e.clientY })
90
+ }, [isMobile])
91
+
92
+ const handleMouseDown = useCallback(
93
+ (e: React.MouseEvent) => {
94
+ if (isMobile || disabled) return
95
+ e.preventDefault()
96
+ const button = e.button === 2 ? 'right' : e.button === 1 ? 'middle' : 'left'
97
+ if (button === 'middle') {
98
+ sendAction({ type: 'click', slotIndex: index, button: 'middle', mode: 'middle' })
99
+ return
100
+ }
101
+ if (heldItem && (button === 'left' || button === 'right')) {
102
+ startDrag(index, button)
103
+ }
104
+ },
105
+ [isMobile, disabled, heldItem, index, sendAction, startDrag],
106
+ )
107
+
108
+ const handleMouseUp = useCallback(
109
+ (e: React.MouseEvent) => {
110
+ if (isMobile || disabled) return
111
+ e.preventDefault()
112
+ const button = e.button === 2 ? 'right' : e.button === 1 ? 'middle' : 'left'
113
+ if (isDragging && dragSlots.length > 1) {
114
+ endDrag()
115
+ return
116
+ }
117
+ const mode = e.shiftKey ? 'shift' : 'normal'
118
+ if (onClickOverride) {
119
+ onClickOverride(button, mode)
120
+ } else {
121
+ // Hide tooltip immediately when picking up or placing an item
122
+ if (button === 'left' || button === 'right') setShowTooltip(false)
123
+ sendAction({ type: 'click', slotIndex: index, button, mode })
124
+ }
125
+ if (isDragging) endDrag()
126
+ },
127
+ [isMobile, disabled, isDragging, dragSlots.length, sendAction, index, endDrag, onClickOverride],
128
+ )
129
+
130
+ const handleDoubleClick = useCallback(
131
+ (e: React.MouseEvent) => {
132
+ if (isMobile || disabled) return
133
+ e.preventDefault()
134
+ if (onClickOverride) {
135
+ onClickOverride('left', 'double')
136
+ } else {
137
+ sendAction({ type: 'click', slotIndex: index, button: 'left', mode: 'double' })
138
+ }
139
+ },
140
+ [isMobile, disabled, sendAction, index, onClickOverride],
141
+ )
142
+
143
+ const handleContextMenu = useCallback((e: React.MouseEvent) => {
144
+ e.preventDefault()
145
+ }, [])
146
+
147
+ const handleWheel = useCallback(
148
+ (e: React.WheelEvent) => {
149
+ if (isMobile || disabled) return
150
+ if (!item && !heldItem) return
151
+ e.preventDefault()
152
+ if (onClickOverride) {
153
+ onClickOverride('right', 'normal')
154
+ } else {
155
+ if (e.deltaY < 0 && item) {
156
+ sendAction({ type: 'click', slotIndex: index, button: 'right', mode: 'normal' })
157
+ } else if (e.deltaY > 0 && heldItem) {
158
+ sendAction({ type: 'click', slotIndex: index, button: 'right', mode: 'normal' })
159
+ }
160
+ }
161
+ },
162
+ [isMobile, disabled, item, heldItem, sendAction, index, onClickOverride],
163
+ )
164
+
165
+ // Mobile touch handlers
166
+ const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null)
167
+
168
+ const handleTouchStart = useCallback(
169
+ (e: React.TouchEvent) => {
170
+ if (!isMobile) return
171
+ const touch = e.touches[0]
172
+ touchStartRef.current = { x: touch.clientX, y: touch.clientY, time: Date.now() }
173
+ },
174
+ [isMobile],
175
+ )
176
+
177
+ const handleTouchEnd = useCallback(
178
+ (e: React.TouchEvent) => {
179
+ if (!isMobile || disabled) return
180
+ const start = touchStartRef.current
181
+ if (!start) return
182
+ touchStartRef.current = null
183
+ const touch = e.changedTouches[0]
184
+ if (Math.abs(touch.clientX - start.x) > 10 || Math.abs(touch.clientY - start.y) > 10) return
185
+
186
+ if (heldItem) {
187
+ sendAction({ type: 'click', slotIndex: index, button: 'left', mode: 'normal' })
188
+ } else if (item) {
189
+ const rect = slotRef.current?.getBoundingClientRect()
190
+ if (rect) setTooltipPos({ x: rect.right, y: rect.top })
191
+ setMobileMenuOpen(true)
192
+ setShowTooltip(true)
193
+ }
194
+ },
195
+ [isMobile, disabled, heldItem, item, sendAction, index],
196
+ )
197
+
198
+ const handleMobilePickAll = useCallback(() => {
199
+ setMobileMenuOpen(false)
200
+ setShowTooltip(false)
201
+ sendAction({ type: 'click', slotIndex: index, button: 'left', mode: 'normal' })
202
+ }, [sendAction, index])
203
+
204
+ const handleMobilePickHalf = useCallback(() => {
205
+ setMobileMenuOpen(false)
206
+ setShowTooltip(false)
207
+ sendAction({ type: 'click', slotIndex: index, button: 'right', mode: 'normal' })
208
+ }, [sendAction, index])
209
+
210
+ const handleMobilePickCustom = useCallback(() => {
211
+ if (!item) return
212
+ const input = window.prompt(`Pick amount (max ${item.count}):`, String(item.count))
213
+ const amount = parseInt(input ?? '', 10)
214
+ if (isNaN(amount) || amount <= 0) return
215
+ setMobileMenuOpen(false)
216
+ setShowTooltip(false)
217
+ for (let i = 0; i < Math.min(amount, item.count); i++) {
218
+ sendAction({ type: 'click', slotIndex: index, button: 'right', mode: 'normal' })
219
+ }
220
+ }, [item, sendAction, index])
221
+
222
+ const handleMobileDrop = useCallback(() => {
223
+ setMobileMenuOpen(false)
224
+ setShowTooltip(false)
225
+ sendAction({ type: 'drop', slotIndex: index, all: true })
226
+ }, [sendAction, index])
227
+
228
+ const closeMobileMenu = useCallback(() => {
229
+ setMobileMenuOpen(false)
230
+ setShowTooltip(false)
231
+ }, [])
232
+
233
+ return (
234
+ <div
235
+ ref={slotRef}
236
+ className={[
237
+ noBackground ? styles.slotBare : styles.slot,
238
+ highlighted && styles.highlighted,
239
+ disabled && styles.disabled,
240
+ resultSlot && styles.resultSlot,
241
+ isHovered && !isMobile && styles.hovered,
242
+ isDragTarget && styles.dragTarget,
243
+ className,
244
+ ]
245
+ .filter(Boolean)
246
+ .join(' ')}
247
+ style={{
248
+ width: renderSize,
249
+ height: renderSize,
250
+ position: 'relative',
251
+ flexShrink: 0,
252
+ ...style,
253
+ }}
254
+ onMouseEnter={handleMouseEnter}
255
+ onMouseLeave={handleMouseLeave}
256
+ onMouseMove={handleMouseMove}
257
+ onMouseDown={handleMouseDown}
258
+ onMouseUp={handleMouseUp}
259
+ onDoubleClick={handleDoubleClick}
260
+ onContextMenu={handleContextMenu}
261
+ onWheel={handleWheel}
262
+ onTouchStart={handleTouchStart}
263
+ onTouchEnd={handleTouchEnd}
264
+ aria-label={
265
+ label ??
266
+ (item
267
+ ? `Slot ${index}: ${item.displayName ?? item.name ?? item.type} ×${item.count}`
268
+ : `Slot ${index} (empty)`)
269
+ }
270
+ >
271
+ {item && (
272
+ <ItemCanvas
273
+ item={item}
274
+ size={renderSize}
275
+ style={{ position: 'absolute', top: 0, left: 0, pointerEvents: 'none' }}
276
+ />
277
+ )}
278
+
279
+ {!item && label && (
280
+ <div className={styles.emptyLabel} style={{ fontSize: Math.round(renderSize * 0.35) }}>
281
+ {label}
282
+ </div>
283
+ )}
284
+
285
+ {item && showTooltip && !mobileMenuOpen && (
286
+ <Tooltip item={item} mouseX={tooltipPos.x} mouseY={tooltipPos.y} visible />
287
+ )}
288
+
289
+ {isMobile && mobileMenuOpen && item && (
290
+ <>
291
+ <div className={styles.mobileOverlay} onClick={closeMobileMenu} />
292
+ <MobileSlotMenu
293
+ item={item}
294
+ x={tooltipPos.x}
295
+ y={tooltipPos.y}
296
+ onPickAll={handleMobilePickAll}
297
+ onPickHalf={handleMobilePickHalf}
298
+ onPickCustom={handleMobilePickCustom}
299
+ onDrop={handleMobileDrop}
300
+ onClose={closeMobileMenu}
301
+ />
302
+ </>
303
+ )}
304
+ </div>
305
+ )
306
+ }
307
+
308
+ interface MobileSlotMenuProps {
309
+ item: ItemStack
310
+ x: number
311
+ y: number
312
+ onPickAll(): void
313
+ onPickHalf(): void
314
+ onPickCustom(): void
315
+ onDrop(): void
316
+ onClose(): void
317
+ }
318
+
319
+ function MobileSlotMenu({ item, x, y, onPickAll, onPickHalf, onPickCustom, onDrop, onClose }: MobileSlotMenuProps) {
320
+ const { scale } = useScale()
321
+ const menuRef = useRef<HTMLDivElement>(null)
322
+ const [pos, setPos] = useState({ left: x, top: y })
323
+
324
+ useEffect(() => {
325
+ if (!menuRef.current) return
326
+ const mw = menuRef.current.offsetWidth
327
+ const mh = menuRef.current.offsetHeight
328
+ const vw = window.innerWidth
329
+ const vh = window.innerHeight
330
+ let left = x + 8
331
+ let top = y
332
+ if (left + mw > vw - 4) left = x - mw - 8
333
+ if (top + mh > vh - 4) top = vh - mh - 4
334
+ if (top < 4) top = 4
335
+ setPos({ left, top })
336
+ }, [x, y])
337
+
338
+ return (
339
+ <div
340
+ ref={menuRef}
341
+ className={styles.mobileMenu}
342
+ style={{
343
+ position: 'fixed',
344
+ left: pos.left,
345
+ top: pos.top,
346
+ zIndex: 10001,
347
+ fontSize: Math.round(9 * scale),
348
+ padding: 6 * scale,
349
+ gap: 4 * scale,
350
+ minWidth: 100 * scale,
351
+ }}
352
+ >
353
+ <div className={styles.mobileMenuTitle}>
354
+ {item.displayName ?? item.name ?? `Item #${item.type}`} ×{item.count}
355
+ </div>
356
+ <button className={styles.mobileBtn} onClick={onPickAll}>Take All ({item.count})</button>
357
+ <button className={styles.mobileBtn} onClick={onPickHalf}>Take Half ({Math.ceil(item.count / 2)})</button>
358
+ <button className={styles.mobileBtn} onClick={onPickCustom}>Take Amount…</button>
359
+ <button className={[styles.mobileBtn, styles.mobileBtnDanger].join(' ')} onClick={onDrop}>Drop</button>
360
+ <button className={styles.mobileBtn} onClick={onClose}>Cancel</button>
361
+ </div>
362
+ )
363
+ }
@@ -0,0 +1 @@
1
+ export { Slot } from './Slot'
@@ -0,0 +1,5 @@
1
+ /* base global styles */
2
+
3
+ .formatted-message {
4
+ /* text-shadow: 1px 1px 0px #3f3f3f; */
5
+ }
@@ -0,0 +1,79 @@
1
+ import { ComponentProps } from 'react'
2
+ import { MessageFormatOptions, MessageFormatPart } from './chatUtils'
3
+ import './MessageFormatted.css'
4
+
5
+ export const MessagePart = ({ part, formatOptions, ...props }: { part: MessageFormatPart, formatOptions?: MessageFormatOptions } & ComponentProps<'span'>) => {
6
+
7
+ const { color: _color, italic, bold, underlined, strikethrough, text, clickEvent, hoverEvent, obfuscated } = part
8
+ const color = _color ?? 'white'
9
+
10
+ const applyStyles = [
11
+ colorF(color.toLowerCase()) + ((formatOptions?.doShadow ?? true) ? `; text-shadow: 1px 1px 0px ${getColorShadow(colorF(color.toLowerCase()).replace('color:', ''))}` : ''),
12
+ italic && messageFormatStylesMap.italic,
13
+ bold && messageFormatStylesMap.bold,
14
+ italic && messageFormatStylesMap.italic,
15
+ underlined && messageFormatStylesMap.underlined,
16
+ strikethrough && messageFormatStylesMap.strikethrough,
17
+ obfuscated && messageFormatStylesMap.obfuscated
18
+ ].filter(a => a !== false && a !== undefined).filter(Boolean)
19
+
20
+ return <span style={parseInlineStyle(applyStyles.join(';'))} {...props}>{text}</span>
21
+ }
22
+
23
+ export default ({ parts, className, formatOptions }: { parts: readonly MessageFormatPart[], className?: string, formatOptions?: MessageFormatOptions }) => {
24
+ return (
25
+ <span className={`formatted-message ${className ?? ''}`}>
26
+ {parts.map((part, i) => <MessagePart key={i} part={part} formatOptions={formatOptions} />)}
27
+ </span>
28
+ )
29
+ }
30
+
31
+ const colorF = (color) => {
32
+ return color.trim().startsWith('#') ? `color:${color}` : messageFormatStylesMap[color] ?? undefined
33
+ }
34
+
35
+ export function getColorShadow (hex, dim = 0.25) {
36
+ const color = parseInt(hex.replace('#', ''), 16)
37
+
38
+ const r = Math.trunc((color >> 16 & 0xFF) * dim)
39
+ const g = Math.trunc((color >> 8 & 0xFF) * dim)
40
+ const b = Math.trunc((color & 0xFF) * dim)
41
+
42
+ const f = (c) => ('00' + c.toString(16)).slice(-2)
43
+ return `#${f(r)}${f(g)}${f(b)}`
44
+ }
45
+
46
+ export function parseInlineStyle (style: string): Record<string, any> {
47
+ const obj: Record<string, any> = {}
48
+ for (const rule of style.split(';')) {
49
+ const [prop, value] = rule.split(':')
50
+ const cssInJsProp = prop.trim().replaceAll(/-./g, (x) => x.toUpperCase()[1])
51
+ obj[cssInJsProp] = value.trim()
52
+ }
53
+ return obj
54
+ }
55
+
56
+ export const messageFormatStylesMap = {
57
+ black: 'color:color(display-p3 0 0 0)',
58
+ dark_blue: 'color:color(display-p3 0 0 0.6667)',
59
+ dark_green: 'color:color(display-p3 0 0.6667 0)',
60
+ dark_aqua: 'color:color(display-p3 0 0.6667 0.6667)',
61
+ dark_red: 'color:color(display-p3 0.6667 0 0)',
62
+ dark_purple: 'color:color(display-p3 0.6667 0 0.6667)',
63
+ gold: 'color:color(display-p3 1 0.6667 0)',
64
+ gray: 'color:color(display-p3 0.6667 0.6667 0.6667)',
65
+ dark_gray: 'color:color(display-p3 0.3333 0.3333 0.3333)',
66
+ blue: 'color:color(display-p3 0.3333 0.3333 1)',
67
+ green: 'color:color(display-p3 0.3333 1 0.3333)',
68
+ aqua: 'color:color(display-p3 0.3333 1 1)',
69
+ red: 'color:color(display-p3 1 0.3333 0.3333)',
70
+ light_purple: 'color:color(display-p3 1 0.3333 1)',
71
+ yellow: 'color:color(display-p3 1 1 0.3333)',
72
+ white: 'color:color(display-p3 1 1 1)',
73
+ bold: 'font-weight:900',
74
+ strikethrough: 'text-decoration:line-through',
75
+ underlined: 'text-decoration:underline',
76
+ italic: 'font-style:italic',
77
+ obfuscated: 'filter:blur(2px)',
78
+ clickEvent: 'cursor:pointer',
79
+ }
@@ -0,0 +1,74 @@
1
+ import React from 'react'
2
+ import MessageFormatted from './MessageFormatted'
3
+ import type { MessageFormatPart } from './chatUtils'
4
+
5
+ /**
6
+ * Map §-format color codes to Minecraft named colors.
7
+ * Supports color codes (§0-§9, §a-§f) and formatting (§l, §o, §n, §m, §k, §r).
8
+ */
9
+ const CODE_COLORS: Record<string, string> = {
10
+ '0': 'black', '1': 'dark_blue', '2': 'dark_green', '3': 'dark_aqua',
11
+ '4': 'dark_red', '5': 'dark_purple', '6': 'gold', '7': 'gray',
12
+ '8': 'dark_gray', '9': 'blue', 'a': 'green', 'b': 'aqua',
13
+ 'c': 'red', 'd': 'light_purple','e': 'yellow', 'f': 'white',
14
+ }
15
+
16
+ function parseSectionCodes(text: string): MessageFormatPart[] {
17
+ const parts: MessageFormatPart[] = []
18
+ const regex = /§([0-9a-fk-orA-FK-OR])|([^§]+)/g
19
+ let color: string | undefined
20
+ let bold = false, italic = false, underlined = false
21
+ let strikethrough = false, obfuscated = false
22
+ let match: RegExpExecArray | null
23
+ while ((match = regex.exec(text)) !== null) {
24
+ const code = match[1]?.toLowerCase()
25
+ const chunk = match[2]
26
+ if (code !== undefined) {
27
+ if (CODE_COLORS[code]) {
28
+ color = CODE_COLORS[code]
29
+ bold = italic = underlined = strikethrough = obfuscated = false
30
+ } else if (code === 'l') bold = true
31
+ else if (code === 'o') italic = true
32
+ else if (code === 'n') underlined = true
33
+ else if (code === 'm') strikethrough = true
34
+ else if (code === 'k') obfuscated = true
35
+ else if (code === 'r') {
36
+ color = undefined
37
+ bold = italic = underlined = strikethrough = obfuscated = false
38
+ }
39
+ } else if (chunk) {
40
+ parts.push({ text: chunk, color, bold, italic, underlined, strikethrough, obfuscated })
41
+ }
42
+ }
43
+ return parts
44
+ }
45
+
46
+ interface Props {
47
+ text: string | undefined | null
48
+ className?: string
49
+ /** Wrap in a specific element; defaults to the MessageFormatted span wrapper */
50
+ fallbackColor?: string
51
+ }
52
+
53
+ /**
54
+ * Renders a Minecraft text string that may contain §-formatting codes.
55
+ * For plain strings without §, renders as a simple <span>.
56
+ * Used for inventory titles, tooltip names, and lore lines.
57
+ */
58
+ export function MessageFormattedString({ text, className, fallbackColor }: Props) {
59
+ if (!text) return null
60
+
61
+ if (!text.includes('§')) {
62
+ return (
63
+ <span
64
+ className={className}
65
+ style={fallbackColor ? { color: fallbackColor } : undefined}
66
+ >
67
+ {text}
68
+ </span>
69
+ )
70
+ }
71
+
72
+ const parts = parseSectionCodes(text)
73
+ return <MessageFormatted parts={parts} className={className} />
74
+ }
@@ -0,0 +1,172 @@
1
+ // this should actually be moved to mineflayer / renderer
2
+
3
+ import { fromFormattedString, TextComponent } from '@xmcl/text-component'
4
+ import type { IndexedData } from 'minecraft-data'
5
+
6
+ export interface MessageFormatOptions {
7
+ doShadow?: boolean
8
+ }
9
+
10
+ export type MessageFormatPart = Pick<TextComponent, 'hoverEvent' | 'clickEvent'> & {
11
+ text: string
12
+ color?: string
13
+ bold?: boolean
14
+ italic?: boolean
15
+ underlined?: boolean
16
+ strikethrough?: boolean
17
+ obfuscated?: boolean
18
+ }
19
+
20
+ type MessageInput = {
21
+ text?: string
22
+ translate?: string
23
+ with?: Array<MessageInput | string>
24
+ color?: string
25
+ bold?: boolean
26
+ italic?: boolean
27
+ underlined?: boolean
28
+ strikethrough?: boolean
29
+ obfuscated?: boolean
30
+ extra?: MessageInput[]
31
+ json?: any
32
+ }
33
+
34
+ const global = globalThis as any
35
+
36
+ // todo move to sign-renderer, replace with prismarine-chat, fix mcData issue!
37
+ export const formatMessage = (message: MessageInput, mcData: IndexedData = global.loadedData) => {
38
+ let msglist: MessageFormatPart[] = []
39
+
40
+ const readMsg = (msg: MessageInput) => {
41
+ const styles = {
42
+ color: msg.color,
43
+ bold: !!msg.bold,
44
+ italic: !!msg.italic,
45
+ underlined: !!msg.underlined,
46
+ strikethrough: !!msg.strikethrough,
47
+ obfuscated: !!msg.obfuscated
48
+ }
49
+
50
+ if (!msg.text && typeof msg.json?.[''] === 'string') msg.text = msg.json['']
51
+ if (msg.text) {
52
+ msglist.push({
53
+ ...msg,
54
+ text: msg.text,
55
+ ...styles
56
+ })
57
+ } else if (msg.translate) {
58
+ const tText = mcData?.language[msg.translate] ?? msg.translate
59
+
60
+ if (msg.with) {
61
+ const splitted = tText.split(/%s|%\d+\$s/g)
62
+
63
+ let i = 0
64
+ for (const [j, part] of splitted.entries()) {
65
+ msglist.push({ text: part, ...styles })
66
+
67
+ if (j + 1 < splitted.length) {
68
+ if (msg.with[i]) {
69
+ const msgWith = msg.with[i]
70
+ if (typeof msgWith === 'string') {
71
+ readMsg({
72
+ ...styles,
73
+ text: msgWith
74
+ })
75
+ } else {
76
+ readMsg({
77
+ ...styles,
78
+ ...msgWith
79
+ })
80
+ }
81
+ }
82
+ i++
83
+ }
84
+ }
85
+ } else {
86
+ msglist.push({
87
+ ...msg,
88
+ text: tText,
89
+ ...styles
90
+ })
91
+ }
92
+ }
93
+
94
+ if (msg.extra) {
95
+ for (let ex of msg.extra) {
96
+ if (typeof ex === 'string') {
97
+ ex = { text: ex }
98
+ }
99
+ readMsg({ ...styles, ...ex })
100
+ }
101
+ }
102
+ }
103
+
104
+ readMsg(message)
105
+
106
+ const flat = (msg) => {
107
+ return [msg, msg.extra?.flatMap(flat) ?? []]
108
+ }
109
+
110
+ msglist = msglist.map(msg => {
111
+ // normalize §
112
+ if (!msg.text.includes?.('§')) return msg
113
+ const newMsg = fromFormattedString(msg.text)
114
+ return flat(newMsg)
115
+ }).flat(Infinity)
116
+
117
+ return msglist
118
+ }
119
+
120
+ export const messageToString = (message: MessageInput | string) => {
121
+ if (typeof message === 'string') {
122
+ return message
123
+ }
124
+ const msglist = formatMessage(message)
125
+ return msglist.map(msg => msg.text).join('')
126
+ }
127
+
128
+ const blockToItemRemaps = {
129
+ water: 'water_bucket',
130
+ lava: 'lava_bucket',
131
+ redstone_wire: 'redstone',
132
+ tripwire: 'tripwire_hook'
133
+ }
134
+
135
+ export const getItemFromBlock = (block: any) => {
136
+ const item = global.loadedData.itemsByName[blockToItemRemaps[block.name] ?? block.name]
137
+ return item
138
+ }
139
+
140
+ export function isAllowedChatCharacter (char: string): boolean {
141
+ // if (char.length !== 1) {
142
+ // throw new Error('Input must be a single character')
143
+ // }
144
+
145
+ const charCode = char.codePointAt(0)!
146
+ return charCode !== 167 && charCode >= 32 && charCode !== 127
147
+ }
148
+
149
+ export const isStringAllowed = (str: string) => {
150
+ const invalidChars = new Set<string>()
151
+ for (const [i, char] of [...str].entries()) {
152
+ const isSurrogatePair = str.codePointAt(i) !== str['charCodeAt'](i)
153
+ if (isSurrogatePair) continue
154
+
155
+ if (!isAllowedChatCharacter(char)) {
156
+ invalidChars.add(char)
157
+ }
158
+ }
159
+
160
+ const valid = invalidChars.size === 0
161
+ if (valid) {
162
+ return {
163
+ valid: true
164
+ }
165
+ }
166
+
167
+ return {
168
+ valid,
169
+ clean: [...str].filter(c => !invalidChars.has(c)).join(''),
170
+ invalid: [...invalidChars]
171
+ }
172
+ }