diff --git a/.eslintrc.json b/.eslintrc.json index a91015d2..c9fa60e2 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -7,6 +7,7 @@ "rules": { "space-infix-ops": "error", "no-multi-spaces": "error", + "no-trailing-spaces": "error", "space-before-function-paren": "error", "space-in-parens": [ "error", diff --git a/esbuild.mjs b/esbuild.mjs index 1f075a62..3dfe9939 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -88,6 +88,7 @@ const buildOptions = { '.woff': 'dataurl', '.woff2': 'dataurl', '.ttf': 'dataurl', + '.webp': 'dataurl', }, write: false, // todo would be better to enable? diff --git a/scripts/buildNpmReact.ts b/scripts/buildNpmReact.ts index 4516f1b7..bc0fc192 100644 --- a/scripts/buildNpmReact.ts +++ b/scripts/buildNpmReact.ts @@ -53,6 +53,10 @@ fs.promises.readdir(path.resolve(__dirname, '../src/react')).then(async (files) write: false, // todo loader: { '.png': 'dataurl', + '.jpg': 'dataurl', + '.jpeg': 'dataurl', + '.webp': 'dataurl', + '.css': 'text', }, plugins: [ // on external module resolve diff --git a/src/globals.d.ts b/src/globals.d.ts index f8699f21..4abc0822 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -25,6 +25,7 @@ declare const customEvents: import('typed-emitter').default<{ gameLoaded (): void mineflayerBotCreated (): void search (q: string): void + activateItem (item: Item, slot: number, offhand: boolean): void }> declare const beforeRenderFrame: Array<() => void> diff --git a/src/localServerMultiplayer.ts b/src/localServerMultiplayer.ts index ca85c126..c8e4bc5e 100644 --- a/src/localServerMultiplayer.ts +++ b/src/localServerMultiplayer.ts @@ -52,7 +52,7 @@ export const openToWanAndCopyJoinLink = async (writeText: (text) => void, doCopy peerInstance = peer peer.on('connection', (connection) => { console.log('connection') - const serverDuplex = new CustomDuplex({}, (data) => connection.send(data)) + const serverDuplex = new CustomDuplex({}, async (data) => connection.send(data)) const client = new Client(true, localServer.options.version, undefined) client.setSocket(serverDuplex) localServer._server.emit('connection', client) diff --git a/src/react/AddServerOrConnect.tsx b/src/react/AddServerOrConnect.tsx index 535e3ee9..2c38a894 100644 --- a/src/react/AddServerOrConnect.tsx +++ b/src/react/AddServerOrConnect.tsx @@ -101,7 +101,6 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ }}> Account Override setAccountIndex(Number(value))} style={{ background: 'gray', diff --git a/src/react/Book.module.css b/src/react/Book.module.css new file mode 100644 index 00000000..d672baf6 --- /dev/null +++ b/src/react/Book.module.css @@ -0,0 +1,605 @@ +.bookWrapper * { + box-sizing: border-box; +} +.bookWrapper { + box-sizing: border-box; + position: absolute; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 0.5vh; +} +.bookContainer { + position: relative; + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + gap: 3%; + padding: 0; +} + +.bookImages { + position: relative; +} + +.outSide { + display: none; + position: absolute; + z-index: 2; + top: 0; + left: 50%; + transform: translateX(-50%); + width: 40%; + height: 100%; + margin: 0 auto; +} +.titleIcon { + display: none; + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + width: 50%; + height: 100%; + margin: 0 auto; + z-index: 2; +} +.titleContent { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + position: relative; + width: 100%; + height: 100%; + overflow: hidden; +} +.titleContent span { + font-size: 10px; +} +.titleContent input { + text-align: center; + width: 100%; + height: 10%; + font-size: 10px; + margin: 4% 0%; + padding: 2% 4%; + background-color: transparent; + color: white; + caret-color: greenyellow; + font-family: inherit; + border: 1px solid black; + padding: 8px 15px; +} + +.insideIcon { + width: 100%; + height: 20vh; +} +.insideHalfIcon { + position: absolute; + top: 0; + left: 0; + width: 50%; + height: 100%; +} + +.inside { + position: absolute; + top: 0%; + width: 90%; + height: 100%; + display: flex; + justify-content: flex-start; + align-items: flex-start; + gap: 5%; + background-color: transparent; + box-shadow: none; + padding: 3% 0%; +} +.inside.uneditable { + height: 90%; +} + +.page { + position: relative; + width: 50%; + height: 100%; + overflow-y: auto; + background-color: transparent; + overflow: hidden; +} + +.messageFormattedString { + position: relative; + width: 100%; + height: 100%; + white-space: pre-wrap; + font-family: minecraft; + font-size: 10px; + padding: 10px; +} + +.messageFormattedString > span { + text-shadow: none !important; +} + +.textArea { + position: relative; + width: 100%; + height: 100%; + border: none; + outline: none; + resize: none; + font-family: minecraft; + font-size: 10px; + background-color: transparent; + box-shadow: none; + overflow: hidden; + text-overflow: ellipsis; +} +.textArea > span { + animation: blink 1s step-end infinite; + border-bottom: 2px solid white; +} + +@keyframes blink { + from, + to { + border-color: transparent; + } + 50% { + border-color: #fff; + } +} + +.controlPrev, .controlNext, +.controlPrev::before, .controlNext::before, +.controlPrev::after, .controlNext::after { + border: none !important; + background-color: transparent !important; + text-shadow: none !important; + width: auto !important; +} + +.inside Button.controlPrev { + position: absolute !important; + background-image: url('./book_icons/prev.webp'); + background-size: contain; + background-repeat: no-repeat; + background-position: center; + left: 2%; + bottom: 6%; + margin: 0; + padding: 4%; +} +.inside Button.controlPrev:active { + background-image: url('./book_icons/prev-click.webp') !important; +} +.inside Button.controlNext { + position: absolute !important; + background-image: url('./book_icons/next.webp') !important; + background-size: contain; + background-repeat: no-repeat; + background-position: center; + right: 2%; + bottom: 6%; + margin: 0; + padding: 4%; +} +.inside Button.controlNext:active { + background-image: url('./book_icons/next-click.webp') !important; +} + +.actions { + display: flex; + flex-wrap: wrap; + width: 50%; + gap: 0.5vw; + row-gap: 0.5vw; + align-items: center; + justify-content: center; +} + +.actions button { + cursor: pointer; + font-family: minecraft; + width: 45%; +} + +/* Animations */ + +@keyframes titleAnimation { + 0% { + transform: translateX(-50%) rotateY(85deg); + } + 100% { + transform: translateX(-50%) rotateY(0); + } +} + +@keyframes titleContentAnimation { + 0% { + transform: translateX(-50%) rotateY(85deg); + } + 100% { + transform: translateX(-50%) rotateY(0); + } +} + +@keyframes insideAnimation { + 0% { + clip-path: inset(0% 0% 0% 0%); + transform: translateX(0%); + } + 25% { + transform: translateX(5%); + } + 50% { + clip-path: inset(0% 0% 0% 50%); + transform: translateX(0%); + } + 99% { + opacity: 1; + } + 100% { + clip-path: inset(0% 0% 0% 75%); + transform: translateX(-25%); + opacity: 0; + } +} + +@keyframes pageAnimation { + 0% { + transform: translateX(0) rotateY(0); + } + 99% { + display: flex; + } + 100% { + transform: translateX(55%) rotateY(90deg); + padding: 0%; + display: none; + } +} +@keyframes pageTextAnimation { + 0% { + transform: translateX(0) rotateY(0); + } + 100% { + transform: translateX(65%) rotateY(88deg); + display: none; + } +} +@keyframes pageSecondTextAnimation { + 0% { + transform: translateX(0%); + } + 25% { + transform: translateX(10%); + } + 50% { + transform: translateX(0%); + display: flex; + } + 100% { + transform: translateX(-50%); + display: none; + } +} + +.pageAnimation { + animation: pageAnimation .15s forwards; +} + +.titleAnimation { + display: flex; + animation: titleAnimation .3s forwards; +} + +.titleContentAnimation { + display: flex; + animation: titleContentAnimation .3s forwards; +} + +.insideAnimation { + animation: insideAnimation .3s forwards; +} + +.pageTextAnimation { + animation: pageTextAnimation .15s forwards; +} + +.pageSecondTextAnimation { + animation: pageSecondTextAnimation .3s forwards; +} + +.hidden { + display: none !important; +} + +/* Animation Reverse */ + +@keyframes titleAnimationReverse { + 0% { + transform: translateX(-50%) rotateY(0); + } + 50% { + transform: translateX(0%) + } + 100% { + transform: translateX(-50%) rotateY(-90deg); + display: none; + } +} + +@keyframes titleContentAnimationReverse { + 0% { + transform: translateX(-50%) rotateY(0); + } + 50% { + transform: translateX(10%) + } + 100% { + transform: translateX(-50%) rotateY(-90deg); + display: none; + } +} + +@keyframes insideAnimationReverse { + 0% { + clip-path: inset(0% 0% 0% 50%); + transform: translateX(0%); + opacity: 1; + } + 50% { + clip-path: inset(0% 0% 0% 50%); + } + 100% { + clip-path: inset(0% 0% 0% 0%); + transform: translateX(0%); + } +} + +@keyframes pageAnimationReverse { + 0% { + transform: translateX(55%) rotateY(-90deg); + padding: 0%; + display: none; + } + 1% { + display: flex; + } + 100% { + transform: translateX(0) rotateY(0); + } +} + +@keyframes pageTextAnimationReverse { + 0% { + transform: translateX(65%) rotateY(88deg); + display: none; + } + 100% { + transform: translateX(0) rotateY(0); + } +} + +@keyframes pageSecondTextAnimationReverse { + 0% { + transform: translateX(-50%); + display: none; + } + 50% { + transform: translateX(0%); + display: flex; + } + 75% { + transform: translateX(10%); + } + 100% { + transform: translateX(0%); + } +} + +@keyframes pageButtonAnimationReverse { + 0% { + opacity: 0; + } + 99% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +.titleAnimationReverse { + display: flex; + animation: titleAnimationReverse .3s forwards; +} + +.titleContentAnimationReverse { + display: flex; + animation: titleContentAnimationReverse .3s forwards; +} + +.insideAnimationReverse { + animation: insideAnimationReverse .3s forwards; +} + +.pageAnimationReverse { + animation: pageAnimationReverse .15s forwards; +} + +.pageTextAnimationReverse { + animation: pageTextAnimationReverse .15s forwards; +} + +.pageSecondTextAnimationReverse { + animation: pageSecondTextAnimationReverse .3s forwards; +} + +.pageButtonAnimationReverse { + animation: pageButtonAnimationReverse .3s forwards +} + +@media screen and (min-width: 972px) and (max-width: 1024px) { + .textArea, .text { + font-size: 10px; + } +} + +@media screen and (max-width: 972px) { + .outSide { + width: 80%; + padding: 4% 0%; + } + .titleIcon { + width: 100%; + } + .titleContent span { + font-size: 10px; + } + .titleContent input { + font-size: 10px; + } + + .insideIcon { + width: 100%; + height: 40vh; + } + .inside { + padding: 9% 8%; + } + .insideHalfIcon { + display: none; + } + + .page { + width: 100%; + height: 100%; + } + .inside Button.controlPrev { + left: 10%; + bottom: 6%; + padding: 6%; + } + .inside Button.controlNext { + right: 15%; + bottom: 6%; + padding: 6%; + } + + .textArea, .text { + font-size: 10px; + } + /* Animations width < 768px */ + @keyframes titleAnimation { + 0% { + transform: translateX(-0%) rotateY(90deg); + } + 100% { + transform: translateX(-50%) rotateY(0); + } + } + @keyframes titleContentAnimation { + 0% { + transform: translateX(-0%) rotateY(90deg); + } + 100% { + transform: translateX(-50%) rotateY(0); + } + } + @keyframes insideAnimation { + 0% { + transform: translateX(0) rotateY(0); + } + 100% { + transform: translateX(50%) rotateY(-90deg); + } + } + + @keyframes pageTextAnimation { + 0% { + transform: translateX(0) rotateY(0); + } + 100% { + transform: translateX(50%) rotateY(-90deg); + display: none; + } + } + + /* Animations Reverse width < 768px */ + @keyframes titleAnimationReverse { + 0% { + transform: translateX(-60%) rotateY(0); + } + 75% { + opacity: 1; + } + 100% { + opacity: 0; + transform: translateX(0%) rotateY(90deg); + } + } + @keyframes titleContentAnimationReverse { + 0% { + transform: translateX(-60%) rotateY(0); + } + 100% { + transform: translateX(0%) rotateY(90deg); + } + } + @keyframes insideAnimationReverse { + 0% { + z-index: 5; + transform: translateX(50%) rotateY(-90deg); + } + 100% { + transform: translateX(0) rotateY(0); + } + } + + @keyframes pageTextAnimationReverse { + 0% { + transform: translateX(25%) rotateY(-20deg); + } + 100% { + transform: translateX(0) rotateY(0); + display: flex; + } + } +} + +@media screen and (max-width: 591px) { + .textArea .text { + font-size: 14px; + } +} + +@media screen and (max-height: 768px) { + .textArea, .text { + font-size: 5px; + } + .outSide { + padding: 4% 0%; + } + .titleContent span { + font-size: 5px; + } +} +@media screen and (max-height: 632px) { + .titleContent span { + font-size: 5px; + } +} +@media screen and (max-height: 392px) { + .insideIcon { + height: 40vh; + } +} diff --git a/src/react/Book.stories.tsx b/src/react/Book.stories.tsx new file mode 100644 index 00000000..a90762c2 --- /dev/null +++ b/src/react/Book.stories.tsx @@ -0,0 +1,25 @@ +import { Meta, Story } from '@storybook/react' +import Book, { BookProps } from './Book' + +export default { + title: 'Components/Book', + component: Book, +} as Meta + +const Template: Story = (args) => + +export const Default = Template.bind({}) +Default.args = { + textPages: [ + 'Page 1: This is some text for page 1.', + 'Page 2: This is some text for page 2.', + 'Page 3: This is some text for page 3.', + 'Page 4: This is some text for page 4.', + 'Page 5: This is some text for page 5.', + ], + editable: true, + onSign: (pages, title) => console.log('Signed with pages:', pages, 'Title:', title), + onEdit: (pages) => console.log('Edit with pages:', pages), + onClose: () => console.log('Closed book'), + author: 'Author' +} diff --git a/src/react/Book.tsx b/src/react/Book.tsx new file mode 100644 index 00000000..5093b76b --- /dev/null +++ b/src/react/Book.tsx @@ -0,0 +1,322 @@ +import React, { useState, useRef, useEffect, useCallback } from 'react' +import insideIcon from './book_icons/book.webp' +import insideHalfIcon from './book_icons/book-half.webp' +import singlePageInsideIcon from './book_icons/notebook.webp' +import titleIcon from './book_icons/title.webp' +import styles from './Book.module.css' +import Button from './Button' +import MessageFormattedString from './MessageFormattedString' + +export interface BookProps { + textPages: string[] + editable: boolean + onSign: (textPages: string[], title: string) => void + onEdit: (textPages: string[]) => void + onClose: () => void + author: string +} + +const Book: React.FC = ({ textPages, editable, onSign, onEdit, onClose, author }) => { + const [pages, setPages] = useState(textPages) + const [currentPage, setCurrentPage] = useState(0) + const [isSinglePage, setIsSinglePage] = useState(window.innerWidth < 972) + const [insideImage, setInsideImage] = useState(window.innerWidth < 972 ? singlePageInsideIcon : insideIcon) + const [animateInsideIcon, setAnimateInsideIcon] = useState(0) + const [animatePageIcon, setAnimatePageIcon] = useState(0) + const [animateTitleIcon, setAnimateTitleIcon] = useState(0) + const [signClickedOnce, setSignClickedOnce] = useState(false) + const textAreaRefs = useRef([]) + const inputRef = useRef(null) + + const handleResize = useCallback(() => { + const isSingle = window.innerWidth < 972 + setIsSinglePage(isSingle) + setInsideImage(isSingle ? singlePageInsideIcon : insideIcon) + }, []) + + useEffect(() => { + handleResize() + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + }, [handleResize]) + + useEffect(() => { + const index = currentPage * (isSinglePage ? 1 : 2) + if (textAreaRefs.current[index]) textAreaRefs.current[index].focus() + }, [currentPage, isSinglePage]) + + useEffect(() => { + if (signClickedOnce) { + setTimeout(() => { + inputRef.current!.focus() + }, 300) // wait for animation + } + }, [signClickedOnce]) + + const handlePageChange = (direction: number) => { + setCurrentPage((prevPage) => + Math.min(Math.max(prevPage + direction, 0), Math.ceil(pages.length / (isSinglePage ? 1 : 2)) - 1) + ) + } + + const updatePage = (index, text) => { + setPages((prevPages) => { + const updatedPages = [...prevPages] + updatedPages[index] = text + return updatedPages + }) + } + + const handleTextChange = (e, pageIndex) => { + const text = e.target.value + updatePage(pageIndex, text) + + const nextPageIndex = pageIndex + 1 + const isMaxLengthReached = text.length >= e.target.maxLength + + if (isMaxLengthReached) { + if (nextPageIndex < pages.length) { + setCurrentPage(Math.floor(nextPageIndex / (isSinglePage ? 1 : 2))) + } else { + setPages((prevPages) => [...prevPages, '']) + setCurrentPage(Math.floor(nextPageIndex / (isSinglePage ? 1 : 2))) + } + textAreaRefs.current[nextPageIndex]?.focus() + } else if (text === '' && pageIndex > 0 && e.nativeEvent.inputType === 'deleteContentBackward') { + setCurrentPage(Math.floor((pageIndex - 1) / (isSinglePage ? 1 : 2))) + textAreaRefs.current[pageIndex - 1]?.focus() + } + } + + useEffect(() => { + const index = currentPage * (isSinglePage ? 1 : 2) + textAreaRefs.current[index]?.focus() + }, [currentPage, isSinglePage]) + + const handlePaste = (e: React.ClipboardEvent, pageIndex: number) => { + const pasteText = e.clipboardData.getData('text') + const updatedPages = [...pages] + const currentText = updatedPages[pageIndex] + const selectionStart = e.currentTarget.selectionStart || 0 + const selectionEnd = e.currentTarget.selectionEnd || 0 + + const newText = currentText.slice(0, selectionStart) + pasteText + currentText.slice(selectionEnd) + updatedPages[pageIndex] = newText + setPages(updatedPages) + + if (newText.length > e.currentTarget.maxLength) { + const remainingText = newText.slice(e.currentTarget.maxLength) + updatedPages[pageIndex] = newText.slice(0, e.currentTarget.maxLength) + setPages(updatedPages) + + const nextPageIndex = pageIndex + 1 + + if (nextPageIndex < pages.length) { + handlePasteRemainingText(remainingText, nextPageIndex) + } else { + setPages((prevPages) => [...prevPages, remainingText]) + setCurrentPage(Math.floor(nextPageIndex / (isSinglePage ? 1 : 2))) + focusOnTextArea(nextPageIndex) + } + } + } + + const handlePasteRemainingText = (remainingText: string, nextPageIndex: number) => { + const updatedPages = [...pages] + updatedPages[nextPageIndex] = remainingText + setPages(updatedPages) + focusOnTextArea(nextPageIndex) + } + + const focusOnTextArea = (index: number) => { + setTimeout(() => { + textAreaRefs.current[index]?.focus() + }, 0) + } + + const handleSign = useCallback(() => { + if (editable && signClickedOnce) { + const title = inputRef.current?.value || '' + onSign(pages, title) + } + setSignClickedOnce(true) + setAnimatePageIcon(1) + setAnimateInsideIcon(1) + setTimeout(() => { + setAnimateTitleIcon(1) + }, 150) + }, [pages, onSign, editable, signClickedOnce]) + + const handleEdit = useCallback(() => { + setSignClickedOnce(false) + onEdit(pages) + }, [pages, onEdit]) + + const handleCancel = useCallback(() => { + if (signClickedOnce) { + setSignClickedOnce(false) + setAnimateTitleIcon(2) + setTimeout(() => { + setAnimateInsideIcon(2) + setTimeout(() => { + setAnimatePageIcon(2) + }, 150) + }, 150) + } else { + onClose() + } + }, [signClickedOnce, onClose]) + + const setRef = (index: number) => (el: HTMLTextAreaElement | null) => { + textAreaRefs.current[index] = el! + } + + const getAnimationClass = (animationState, baseClass) => { + switch (animationState) { + case 1: + return `${baseClass} ${styles.pageAnimation}` + case 2: + return `${baseClass} ${styles.pageAnimationReverse}` + default: + return baseClass + } + } + + const renderPage = (index) => ( + + {editable ? ( + { + e.stopPropagation() // allow to open system context menu on text area for better UX + }} + ref={setRef(index)} + value={pages[index]} + onChange={(e) => handleTextChange(e, index)} + onPaste={(e) => handlePaste(e, index)} + className={getAnimationClass(animatePageIcon, styles.textArea)} + maxLength={500} + /> + ) : ( + + + + )} + + ) + + return ( + + + + + + + {renderPage(currentPage * (isSinglePage ? 1 : 2))} + {!isSinglePage && (currentPage * 2 + 1) < pages.length && renderPage(currentPage * 2 + 1)} + handlePageChange(-1)} + disabled={currentPage === 0} + > + {' '} + + handlePageChange(1)} + disabled={(currentPage + 1) * (isSinglePage ? 1 : 2) >= pages.length} + > + {' '} + + + + {editable ? ( + + + { + e.preventDefault() + handleSign() + }}> + + {/* for some reason this is needed to make Enter work on android chrome */} + + + + + + + ) : ( + + + + + + )} + + + + {editable && ( + + {signClickedOnce ? 'Sign and Save' : 'Sign'} + + )} + + {editable && !signClickedOnce && Sign} + {editable && !signClickedOnce && Edit} + {signClickedOnce ? 'Cancel' : 'Close'} + + + ) +} + +export default Book diff --git a/src/react/BookProvider.tsx b/src/react/BookProvider.tsx new file mode 100644 index 00000000..db68d95a --- /dev/null +++ b/src/react/BookProvider.tsx @@ -0,0 +1,110 @@ +import { versionToNumber } from 'prismarine-viewer/viewer/prepare/utils' +import nbt from 'prismarine-nbt' +import { useEffect, useState } from 'react' +import { useSnapshot } from 'valtio' +import mojangson from 'mojangson' +import { activeModalStack, hideCurrentModal, showModal } from '../globalState' +import Book from './Book' +import { useIsModalActive } from './utilsApp' + +interface OpenedBook { + pages: string[] + title: string + author: string + isEditable: boolean +} + +export default () => { + const modalActive = useIsModalActive('book') + // const modalStack = useSnapshot(activeModalStack) + const [openedBook, setOpenedBook] = useState(null) + + const signEditBook = (pages: string[], title: string | undefined) => { + if (versionToNumber(bot.version) < versionToNumber('1.17.2')) { + if (title === undefined) { + void bot.writeBook(bot.inventory.hotbarStart + bot.quickBarSlot, pages) + } else { + //@ts-expect-error + // slot, pages, author, title + bot.signBook(bot.inventory.hotbarStart + bot.quickBarSlot, pages, title, bot.username) + } + hideCurrentModal() + return + } + const currentSlot = bot.quickBarSlot + // mineflayer has wrong implementation of this action after 1.17.2 + if (title === undefined) { + bot._client.write('edit_book', { + hand: currentSlot, + pages + }) + } else { + bot._client.write('edit_book', { + hand: currentSlot, + pages, + title + }) + } + hideCurrentModal() + } + + // test: /give @p minecraft:written_book{pages:['{"text":"§4This is red text. §lThis is bold text."}'],title:"Book",author:"Author"} + useEffect(() => { + const openBookWithNbt = () => { + if (activeModalStack.at(-1)?.reactType === 'book') { + hideCurrentModal() + } + const book = bot.inventory.slots[bot.inventory.hotbarStart + bot.quickBarSlot] + if (!book?.nbt) return + // {"type":"compound","name":"","value":{"title":{"type":"string","value":"yes"},"author":{"type":"string","value":"bot"},"pages":{"type":"list","value":{"type":"string","value":["{\"text\":\"1\"}","{\"text\":\"4\"}","{\"text\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}"]} + const parsedData = nbt.simplify(book.nbt as any) + if (!parsedData.pages) return + const pages = parsedData.pages.map((page) => { + if (book.name !== 'written_book') return page + const parsedPage = mojangson.simplify(mojangson.parse(page)) + return parsedPage.text ?? page + }) + // const {title, author} = parsedData + setOpenedBook({ + pages, + title: parsedData.title ?? '', + author: parsedData.author ?? '', + isEditable: book.name === 'writable_book', + }) + showModal({ reactType: 'book' }) + } + customEvents.on('activateItem', (item) => { + if (item.name === 'writable_book') { + if (item.nbt?.value.pages) { + openBookWithNbt() + } else { + setOpenedBook({ + pages: [], + title: '', + author: '', + isEditable: true, + }) + showModal({ reactType: 'book' }) + } + } + }) + bot._client.on('open_book', openBookWithNbt) + }, []) + + useEffect(() => { + if (modalActive) return + setOpenedBook(null) + }, [modalActive]) + + if (!openedBook) return null + return signEditBook(pages, title)} + onEdit={(pages) => signEditBook(pages, undefined)} + onClose={() => { + hideCurrentModal() + }} + author={bot.username} + /> +} diff --git a/src/react/MessageFormatted.tsx b/src/react/MessageFormatted.tsx index 6b9620ca..ebfaf2ed 100644 --- a/src/react/MessageFormatted.tsx +++ b/src/react/MessageFormatted.tsx @@ -83,9 +83,9 @@ export const MessagePart = ({ part, ...props }: { part: MessageFormatPart } & Co return {text} } -export default ({ parts }: { parts: readonly MessageFormatPart[] }) => { +export default ({ parts, className }: { parts: readonly MessageFormatPart[], className?: string }) => { return ( - + {parts.map((part, i) => )} ) diff --git a/src/react/MessageFormattedString.stories.tsx b/src/react/MessageFormattedString.stories.tsx index 91c87346..f644e2c4 100644 --- a/src/react/MessageFormattedString.stories.tsx +++ b/src/react/MessageFormattedString.stories.tsx @@ -7,11 +7,12 @@ const meta: Meta = { } export default meta -type Story = StoryObj; +type Story = StoryObj export const Primary: Story = { args: { // red text using minecraft styling symbol - message: '\u00A7cYou died!' + message: '\u00A7cYou died!', + fallbackColor: 'white' }, } diff --git a/src/react/MessageFormattedString.tsx b/src/react/MessageFormattedString.tsx index d1f8a10f..32b8fad6 100644 --- a/src/react/MessageFormattedString.tsx +++ b/src/react/MessageFormattedString.tsx @@ -4,16 +4,26 @@ import { formatMessage } from '../botUtils' import MessageFormatted from './MessageFormatted' /** like MessageFormatted, but receives raw string or json instead, uses window.loadedData */ -export default ({ message }: { message: string | Record | null }) => { +export default ({ message, fallbackColor, className }: { + message: string | Record | null, + fallbackColor?: string, + className?: string +}) => { const messageJson = useMemo(() => { if (!message) return null try { - return formatMessage(typeof message === 'string' ? fromFormattedString(message) : message) + const texts = formatMessage(typeof message === 'string' ? fromFormattedString(message) : message) + return texts.map(text => { + return { + ...text, + color: text.color ?? fallbackColor, + } + }) } catch (err) { console.error(err) // todo ensure its being logged return null } }, [message]) - return messageJson ? : null + return messageJson ? : null } diff --git a/src/react/PlayerListOverlayProvider.tsx b/src/react/PlayerListOverlayProvider.tsx index 42e8c735..b97befa6 100644 --- a/src/react/PlayerListOverlayProvider.tsx +++ b/src/react/PlayerListOverlayProvider.tsx @@ -81,10 +81,10 @@ export default () => { if (!isOpen) return null - return diff --git a/src/react/XPBar.tsx b/src/react/XPBar.tsx index 1b3a7a85..5de4c61a 100644 --- a/src/react/XPBar.tsx +++ b/src/react/XPBar.tsx @@ -3,9 +3,9 @@ import styles from './XPBar.module.css' export default ({ progress, level, gamemode }: { progress: number; level: number, gamemode: string }) => ( - @@ -13,4 +13,3 @@ export default ({ progress, level, gamemode }: { progress: number; level: number ) - diff --git a/src/react/book_icons/book-half.webp b/src/react/book_icons/book-half.webp new file mode 100644 index 00000000..8a906586 Binary files /dev/null and b/src/react/book_icons/book-half.webp differ diff --git a/src/react/book_icons/book.webp b/src/react/book_icons/book.webp new file mode 100644 index 00000000..d3701652 Binary files /dev/null and b/src/react/book_icons/book.webp differ diff --git a/src/react/book_icons/next-click.webp b/src/react/book_icons/next-click.webp new file mode 100644 index 00000000..065ee0d3 Binary files /dev/null and b/src/react/book_icons/next-click.webp differ diff --git a/src/react/book_icons/next.webp b/src/react/book_icons/next.webp new file mode 100644 index 00000000..06f5ab1a Binary files /dev/null and b/src/react/book_icons/next.webp differ diff --git a/src/react/book_icons/notebook.webp b/src/react/book_icons/notebook.webp new file mode 100644 index 00000000..ce3b32c3 Binary files /dev/null and b/src/react/book_icons/notebook.webp differ diff --git a/src/react/book_icons/prev-click.webp b/src/react/book_icons/prev-click.webp new file mode 100644 index 00000000..7ff61b57 Binary files /dev/null and b/src/react/book_icons/prev-click.webp differ diff --git a/src/react/book_icons/prev.webp b/src/react/book_icons/prev.webp new file mode 100644 index 00000000..b0b81025 Binary files /dev/null and b/src/react/book_icons/prev.webp differ diff --git a/src/react/book_icons/title.webp b/src/react/book_icons/title.webp new file mode 100644 index 00000000..703157e4 Binary files /dev/null and b/src/react/book_icons/title.webp differ diff --git a/src/react/globals.d.ts b/src/react/globals.d.ts index 53f2208e..d9f38fc1 100644 --- a/src/react/globals.d.ts +++ b/src/react/globals.d.ts @@ -29,6 +29,10 @@ declare module '*.svg' { const svg: string export default svg } +declare module '*.webp' { + const svg: string + export default svg +} interface PromiseConstructor { withResolvers (): { diff --git a/src/react/singleplayer.module.css b/src/react/singleplayer.module.css index c7ec49f6..0e69ea46 100644 --- a/src/react/singleplayer.module.css +++ b/src/react/singleplayer.module.css @@ -50,8 +50,8 @@ white-space: pre; } .world_info_description_line { - color: #999; - white-space: nowrap; + color: #999; + white-space: nowrap; } .world_image { height: 100%; diff --git a/src/reactUi.tsx b/src/reactUi.tsx index 34a567cd..655a6029 100644 --- a/src/reactUi.tsx +++ b/src/reactUi.tsx @@ -41,6 +41,7 @@ import HeldMapUi from './react/HeldMapUi' import BedTime from './react/BedTime' import NoModalFoundProvider from './react/NoModalFoundProvider' import SignInMessageProvider from './react/SignInMessageProvider' +import BookProvider from './react/BookProvider' const RobustPortal = ({ children, to }) => { return createPortal({children}, to) @@ -116,6 +117,7 @@ const InGameUi = () => { + diff --git a/src/worldInteractions.ts b/src/worldInteractions.ts index 5ce428d2..e523c74b 100644 --- a/src/worldInteractions.ts +++ b/src/worldInteractions.ts @@ -289,6 +289,7 @@ class WorldInteraction { } else if (!stop) { const offhand = activate ? false : activatableItems(bot.inventory.slots[45]?.name ?? '') bot.activateItem(offhand) // todo offhand + customEvents.emit('activateItem', offhand ? bot.inventory.slots[45] : bot.heldItem, offhand ? 45 : bot.quickBarSlot, offhand) itemBeingUsed.name = (offhand ? bot.inventory.slots[45]?.name : bot.heldItem?.name) ?? null itemBeingUsed.hand = offhand ? 1 : 0 }