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.
- package/README.md +566 -0
- package/package.json +37 -0
- package/src/InventoryGUI.tsx +108 -0
- package/src/cache/textureCache.ts +95 -0
- package/src/components/CursorItem/CursorItem.tsx +94 -0
- package/src/components/CursorItem/index.ts +1 -0
- package/src/components/HUD/HUD.tsx +11 -0
- package/src/components/HUD/index.ts +1 -0
- package/src/components/Hotbar/Hotbar.tsx +180 -0
- package/src/components/Hotbar/index.ts +1 -0
- package/src/components/InventoryOverlay/InventoryOverlay.tsx +196 -0
- package/src/components/InventoryOverlay/index.ts +2 -0
- package/src/components/InventoryWindow/EnchantmentOptions.tsx +109 -0
- package/src/components/InventoryWindow/InventoryBackground.tsx +110 -0
- package/src/components/InventoryWindow/InventoryWindow.tsx +120 -0
- package/src/components/InventoryWindow/ProgressBar.tsx +78 -0
- package/src/components/InventoryWindow/VillagerTradeList.tsx +136 -0
- package/src/components/InventoryWindow/index.ts +5 -0
- package/src/components/ItemCanvas/ItemCanvas.tsx +154 -0
- package/src/components/ItemCanvas/index.ts +1 -0
- package/src/components/JEI/JEI.module.css +37 -0
- package/src/components/JEI/JEI.tsx +303 -0
- package/src/components/JEI/index.ts +2 -0
- package/src/components/RecipeGuide/RecipeInventoryView.tsx +293 -0
- package/src/components/RecipeGuide/index.ts +1 -0
- package/src/components/Slot/Slot.module.css +111 -0
- package/src/components/Slot/Slot.tsx +363 -0
- package/src/components/Slot/index.ts +1 -0
- package/src/components/Text/MessageFormatted.css +5 -0
- package/src/components/Text/MessageFormatted.tsx +79 -0
- package/src/components/Text/MessageFormattedString.tsx +74 -0
- package/src/components/Text/chatUtils.ts +172 -0
- package/src/components/Tooltip/Tooltip.module.css +56 -0
- package/src/components/Tooltip/Tooltip.tsx +130 -0
- package/src/components/Tooltip/index.ts +1 -0
- package/src/connector/demo.ts +213 -0
- package/src/connector/index.ts +4 -0
- package/src/connector/mineflayer.ts +113 -0
- package/src/connector/types.ts +41 -0
- package/src/context/InventoryContext.tsx +157 -0
- package/src/context/ScaleContext.tsx +73 -0
- package/src/context/TextureContext.tsx +70 -0
- package/src/globals.d.ts +4 -0
- package/src/hooks/useKeyboardShortcuts.ts +41 -0
- package/src/hooks/useMobile.ts +28 -0
- package/src/index.tsx +65 -0
- package/src/mount.tsx +52 -0
- package/src/registry/index.ts +21 -0
- package/src/registry/inventories.ts +612 -0
- package/src/styles/tokens.css +47 -0
- 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,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
|
+
}
|