您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Redesigned chatbox for power users — and for those that just want a refresh
// ==UserScript== // @name Power Chat // @author commander // @description Redesigned chatbox for power users — and for those that just want a refresh // @namespace https://github.com/asger-finding/tanktrouble-userscripts // @version 0.1.4 // @license GPL-3.0 // @match https://tanktrouble.com/* // @match https://beta.tanktrouble.com/* // @exclude *://classic.tanktrouble.com/ // @run-at document-end // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @require https://cdn.jsdelivr.net/npm/match-sorter@6/dist/match-sorter.umd.min.js // @require https://update.greasyfork.org/scripts/482092/1309109/TankTrouble%20Development%20Library.js // @noframes // ==/UserScript== GM_addStyle(` #chat { /* Disable drop shadow filter */ filter: none; /* Transform chat location to bottom left */ inset: calc(100% - 30px) auto auto 34px !important; } /* Reverse chat messages flow */ #chat, #chat .content, #chat .body { display: flex; flex-direction: column-reverse; } #chat .body { align-items: end; background: #00000014; border-image: linear-gradient(90deg, rgb(0 0 0 / 20%), #0000) 4 7 3 / 0 0 1pt 0 / 0; border-radius: 3px; direction: rtl; margin-bottom: 4px; margin-top: 0 !important; mask-image: linear-gradient(to top, rgb(0 0 0) 70%, rgb(0 0 0 / 11%)); overflow: hidden; padding-right: 10px; pointer-events: visible; /* Scrollbar */ scrollbar-gutter: stable; top: 0 !important; } #chat .content { position: relative; width: fit-content !important; } #chat .status.button { cursor: initial; transform: translate(7px, -18px); z-index: 1; } #chat form { background: #ececec; border-image: linear-gradient(90deg, rgb(0 0 0 / 20%), #0000) 4 7 3 / 0 0 1pt 0 / 1pt; margin-left: 20px; width: 200px; } /* Disable chat message sending animation */ #chat form[style*="repeating-linear-gradient"] { background: #d0d0d0 !important; } #chat:not(.open) form { display: none; } #chat textarea { font-family: Arial, verdana; left: 5px; transition: width 0s !important; width: calc(100% - 12px); } #chat .body .chatMessage svg { border-left: 2px dotted rgb(170 170 170); padding: 2px 4px 1px; } #chat .body.dragging { border: none !important; margin-left: 20px !important; } /* Rotate and align the handle to top-right */ .handle.ui-resizable-ne[src*="resizeHandleBottomRight.png"] { height: 12px !important; position: absolute; right: 0; top: 0; transform: rotate(-90deg); width: 12px; } body:has(#chat .body.ui-resizable-resizing) .ui-resizable-handle.handle.ui-resizable-ne { display: none !important; } #chat .body:hover { overflow-y: scroll; } #chat .body .chatMessage { margin-left: ${(/Chrome.*Safari/u).test(navigator.userAgent) ? '3px' : '5px'}; direction: ltr; } #chat .body::-webkit-scrollbar { width: 3px; } #chat .body::-webkit-scrollbar-track { background: transparent; } #chat .body::-webkit-scrollbar-thumb { background: rgb(170 170 170); } #chat form .autocomplete-dropdown { background-color: #00ff02; border-radius: 3px; bottom: 0; filter: drop-shadow(0 0 3px rgb(0 0 0 / 70%)); font-family: Arial, verdana; margin-bottom: 25px; max-height: 120px; max-width: 200px; min-width: 120px; overflow-y: scroll; padding: 4px 2px; position: absolute; scrollbar-color: #00a902 transparent; scrollbar-gutter: stable; scrollbar-width: thin; white-space: nowrap; z-index: 999; } #chat form .autocomplete-dropdown div { border-bottom: 1pt dotted #00a902; cursor: pointer; display: none; margin-bottom: 2px; overflow: hidden; padding: 0 8px 2px 4px; text-overflow: ellipsis; } #chat form .autocomplete-dropdown .match { display: block; } #chat form .autocomplete-dropdown .match:not(:has(~ .match)) { border-bottom: none; padding: 0 8px 0 4px; } #chat form .autocomplete-dropdown .highlight { font-weight: bold; } #chat form .autocomplete-dropdown:hover .highlight { font-weight: normal; } #chat form .autocomplete-dropdown div:hover { font-weight: bold !important; } #chat form .autocomplete-dropdown:has(div:not(.highlight):hover) > .highlight { font-weight: normal; } #chat form .autocomplete-caret-mirror { background: transparent; color: transparent; font-family: Arial, verdana; font-size: inherit; font-weight: bold; height: 0; margin: 0 0 0 5px; opacity: 0; padding: 1px 2px; pointer-events: none; z-index: -2147483647; } `); /** * Reconfigure the chat handle to be dragging * from the south-east direction (down) * to the north-east direction (up) */ const changeHandleDirection = async() => { const { resizable } = $.fn; // Use a regular function to keep context $.fn.resizable = function(...args) { const [config] = args; // Reassign the chat handle to be north-east facing if (config.handles) { const handle = config.handles.se; if (handle === TankTrouble.ChatBox.chatBodyResizeHandle) { handle.removeClass('ui-resizable-se') .addClass('ui-resizable-ne'); config.handles.ne = handle; delete config.handles.se; // Set a taller chat maxHeight config.maxHeight = 650; } } return resizable.call(this, config); }; await Loader.whenContentInitialized(); TankTrouble.ChatBox.chatBodyResizeHandle.detach().insertAfter(TankTrouble.ChatBox.chatBody); }; /** * Hook message render functions to disable jquery .show() animation and scroll to bottom * This fixes chat messages not showing up in the reversed chat order or overflowed messages being cleared */ const fixChatRendering = () => { Loader.interceptFunction(TankTrouble.ChatBox, '_renderChatMessage', (original, ...args) => { TankTrouble.ChatBox.chatBody.scrollTop(TankTrouble.ChatBox.chatBody.height()); // Set animateHeight to false args[9] = false; original(...args); }); Loader.interceptFunction(TankTrouble.ChatBox, '_renderSystemMessage', (original, ...args) => { TankTrouble.ChatBox.chatBody.scrollTop(TankTrouble.ChatBox.chatBody.height()); // Set animateHeight to false args[3] = false; original(...args); }); }; /** * Prevent TankTrouble from clearing the chat when the client disconnects * Print message to chat when client switches server to separate conversations */ const preventServerChangeChatClear = () => { Loader.interceptFunction(TankTrouble.ChatBox, '_clearChat', (original, ...args) => { const isUnconnected = ClientManager.getClient().getState() === TTClient.STATES.UNCONNECTED; // Void the call if the client is unconnected // when the function is invoked if (isUnconnected) return null; return original(...args); }); Loader.interceptFunction(TankTrouble.ChatBox, '_updateStatusMessageAndAvailability', (original, ...args) => { const [systemMessageText, guestPlayerIds] = args; // Check for a welcome message. If match. // print a different system message if (systemMessageText === 'Welcome to TankTrouble Comms § § ') { const newServer = ClientManager.getAvailableServers()[ClientManager.multiplayerServerId]; return original(`Connected to ${ newServer.name } ${ guestPlayerIds.length ? '§ ' : '' }`, guestPlayerIds); } return original(...args); }); }; /** * Write the chat savestate to storage and return * @returns Promise for last savestate */ const initChatSavestate = async() => { // Initialize dynamic stylesheet // for user-defined chat width const inputWidth = new CSSStyleSheet(); inputWidth.insertRule('#chat form { padding-right: 12px !important; }', 0); inputWidth.insertRule('#chat form, #chat textarea { width: 208px !important; }', 1); document.adoptedStyleSheets = [inputWidth]; // Savestate hooks Loader.interceptFunction(TankTrouble.ChatBox, 'open', (original, ...args) => { GM_setValue('chat-open', true); original(...args); }); Loader.interceptFunction(TankTrouble.ChatBox, 'close', (original, ...args) => { GM_setValue('chat-open', false); original(...args); }); Loader.interceptFunction(TankTrouble.ChatBox, '_refreshChat', (original, ...args) => { original(...args); GM_setValue('chat-width', TankTrouble.ChatBox.chatBody[0].clientWidth); }); // Get savestate const shouldOpen = await GM_getValue('chat-open', true); const initialWidth = await GM_getValue('chat-width', 0); Loader.whenContentInitialized().then(() => { /* eslint-disable prefer-destructuring */ const chatBody = TankTrouble.ChatBox.chatBody[0]; const chatInput = TankTrouble.ChatBox.chatInput[0]; /* eslint-enable prefer-destructuring*/ if (shouldOpen) TankTrouble.ChatBox.open(); if (initialWidth !== 0) chatBody.style.width = `${initialWidth}px`; // Create a mutation observer that looks for // changes in the chatBody's attributes new MutationObserver(() => { const width = Number(chatBody.offsetWidth || 220); inputWidth.deleteRule(1); inputWidth.insertRule(`#chat form, #chat form textarea { width: ${width - 12}px !important; }`, 1); chatInput.dispatchEvent(new InputEvent('input')); }).observe(chatBody, { attributes: true, characterData: false }); }); }; /** * Add up/down history for sent messages * @param chatInput Input to target */ const addInputHistory = chatInput => { const messages = []; let currentInputValue = chatInput.value; // Create and initialize chat messages history iterator let i = messages.length; const iterator = (function* chatsIterator() { while (true) { const incOrDec = (yield messages[i]) === 'prev' ? -1 : 1; i = Math.min(Math.max(i + incOrDec, 0), messages.length); } }(messages)); // Initialize the generator iterator.next(); /** * Check whether or not the input has an empty selection range * @returns Selection range is 0 */ const isSelectionEmpty = () => chatInput.selectionStart === chatInput.selectionEnd; /** Handle the user triggering a submit keydown event */ const handleSubmit = () => { if (!chatInput.value) return; messages.push(chatInput.value); currentInputValue = ''; i = messages.length; }; /** Handle the user triggering an arrow up keydown event */ const handleArrowUp = () => { if (isSelectionEmpty() && chatInput.selectionStart === 0) { const { value } = iterator.next('prev'); chatInput.value = typeof value === 'undefined' ? '' : value; chatInput.setSelectionRange(chatInput.value.length, chatInput.value.length); chatInput.dispatchEvent(new InputEvent('input', { isComposing: true })); } }; /** Handle the user triggering an arrow down keydown event */ const handleArrowDown = () => { if (isSelectionEmpty() && chatInput.selectionStart === chatInput.value.length) { const { value } = iterator.next(); chatInput.value = typeof value === 'undefined' ? currentInputValue : value; chatInput.setSelectionRange(chatInput.value.length, chatInput.value.length); chatInput.dispatchEvent(new InputEvent('input', { isComposing: true })); } }; // If the user is at the top of the history, // save the chat input value as the "current" // message whenever there is a change chatInput.addEventListener('input', ({ inputType }) => { const isAtEndOfHistory = i === messages.length; const hasValueChanged = typeof inputType !== 'undefined'; if (isAtEndOfHistory && hasValueChanged) currentInputValue = chatInput.value; }); // Listen for keydown events // and trigger handlers chatInput.addEventListener('keydown', ({ key }) => { switch (key) { case 'Enter': handleSubmit(); break; case 'ArrowUp': handleArrowUp(); break; case 'ArrowDown': handleArrowDown(); break; default: break; } }); }; /** * Add auto-complete for user mentions when typing @ in the chat input * @param chatInput Chat input instance */ const addMentionAutocomplete = chatInput => { class Dropdown { options = new Map(); matches = []; /** * Setup the dropdown class * @param input Input to attach to * @param config Dropdown configuration (allow multiple of the same value, expiry time) */ constructor(input, config) { this.input = $(input); this.wrapper = $('<div class="autocomplete-dropdown" tabindex="-1"></div>').insertAfter(this.input); this.textareaMirror = $('<div class="autocomplete-caret-mirror"></div>').appendTo(this.wrapper.parent()); this.textareaMirrorInline = $('<span></span>').appendTo(this.textareaMirror); Object.assign(this, { allowRepeats: false, autofillLifetime: 10 * 60 * 100, inputHeight: 18, ...config }); this.wrapper.insertAfter(this.input); this.hide(); } #searchTerm = -1; /** * Filter the dropdown elements when searchterm is set * @param term String term to search the dropdown registry for * @returns term */ set searchTerm(term) { if (this.#searchTerm !== term) { this.#removeExpired(); const allSymbols = Array.from(this.options.keys()); this.matches = matchSorter.matchSorter(allSymbols, term, { keys: [symbol => symbol.description] }); for (const symbol of allSymbols) { const element = this.options.get(symbol).value; element.classList[this.matches.includes(symbol) ? 'add' : 'remove']('match'); } for (const symbol of this.matches) this.wrapper.append(this.options.get(symbol).value); this.#resetToFirst(); } this.#searchTerm = term; return term; } /** * Getter for `searchTerm` * @returns `searchTerm` */ get searchTerm() { return this.#searchTerm; } iterator = (function* (options, that) { let i = 0; while (true) { const symbol = that.matches[i]; const change = (yield [symbol, options.get(symbol)]) || 0; i = (i = (i + change) % Math.max(that.matches.length, 1)) < 0 ? i + that.matches.length : i; } }(this.options, this)); /** Render the dropdown if not already visible */ show() { if (this.isShowing()) return; this.#resetToFirst(); this.wrapper.show(); this.wrapper.scrollTop(0); } /** Hide the dropdown */ hide() { this.wrapper.hide(); } /** * Check if the dropdown is visible * @returns Is the dropdown showing? */ isShowing() { return this.wrapper.is(':visible'); } /** * Compute dropdown x-shift to textarea value. * * Should be called when value changes in the input field */ update() { const transformed = this.input.val() .substr(0, this.input[0].selectionStart); this.textareaMirrorInline.html(transformed); const rects = this.textareaMirrorInline[0].getBoundingClientRect(); const left = rects.right - rects.x; this.left = left + Dropdown.#toNumeric(this.input.css('left')) + Dropdown.#toNumeric(this.input.css('margin-left')) + Dropdown.#toNumeric(this.input.css('padding-left')); const isWordWrapped = this.#isWordWrapped(); const leftShift = isWordWrapped ? 0 : Math.max(0, this.left - (this.wrapper.width() / 2)); const bottomShift = this.input.outerHeight() - this.inputHeight; this.wrapper.css('margin-left', `${leftShift}px`); this.wrapper.css('margin-bottom', `${bottomShift + 25}px`); if (!this.isShowing()) this.show(); } /** * Get data for the current position * @returns Identifier and data for the current dropdown position */ getCurrent() { return this.iterator.next(0).value; } /** * Add an autocomplete option to the dropdown * @param option Option as string * @param submitCallback Event handler for mouseup * @returns Success in adding option? */ addOption(option, submitCallback) { const overrideSymbol = !this.allowRepeats && Array.from(this.options.keys()) .find(({ description }) => description === option); const symbolExists = typeof overrideSymbol === 'symbol'; if (symbolExists) return false; const symbol = Symbol(option); const element = document.createElement('div'); element.innerText = option; element.addEventListener('mouseup', evt => submitCallback(evt, evt.target.innerText)); const insert = [ symbol, { inserted: Date.now(), lifetime: this.autofillLifetime, value: element } ]; this.options.set(...insert); return true; } /** * Add an array of text options to the dropdown * @param options Options as string[] * @param submitCallback Generalized event handler for mouseup for all options */ addOptions(options, submitCallback) { for (const option of options) this.addOption(option, submitCallback); } /** * Remove option and corresponding HTMLElement from DOM * @param symbol Symbol for element to remove * @returns Was the option deleted? */ removeOption(symbol) { this.options.get(symbol)?.value.remove(); this.matches = this.matches.filter(toRemove => toRemove !== symbol); return this.options.delete(symbol); } /** * Clear all options from the dropdown * @returns Did options clear? */ clearOptions() { for (const symbol of this.options.keys()) this.removeOption(symbol); return this.options.size === 0 && this.wrapper.children().length === 0; } /** * Navigate position in the dropdown up/down * @param direction Up/down shift as number * @returns Identifier for where we navigated to */ navigate(direction) { this.wrapper.children().removeClass('highlight'); const [symbol, data] = this.iterator.next(direction).value; if (!symbol) return null; data.value.classList.add('highlight'); data.value.scrollIntoView(false); return symbol; } /** * Check if the input wraps to newline * @returns Whether the input is one or multiple lines */ #isWordWrapped() { return this.input.outerHeight() <= this.inputHeight; } /** * Reset the position to the * first item in the dropdown */ #resetToFirst() { const symbols = this.matches; const [currentSymbol] = this.iterator.next(0).value; const dist = symbols.indexOf(currentSymbol); this.navigate(-dist); } /** * Remove expired entries */ #removeExpired() { for (const [symbol, value] of this.options.entries()) { const expiry = value.inserted + value.autofillLifetime; if (Date.now() > expiry) this.removeOption(symbol); } } /** * Remove all non-numbers from string and return string as number * @param str String to parse * @returns String in number format */ static #toNumeric = str => Number(str.replace(/[^0-9.]/ug, '')); } const dropdown = new Dropdown(chatInput); /** * Get the word and start/end indexies of the input selectionEnd * @returns Object with word and range start/end */ const getIndexiesOfWordInCurrentSelection = () => { // Separate string by whitespace and // list indexies for each word in array const tokenizedQuery = chatInput.value.split(/[\s\n]/u).reduce((acc, word, index) => { const previous = acc[index - 1]; const start = index === 0 ? index : previous.range[1] + 1; const end = start + word.length; return acc.concat([ { word, range: [start, end] } ]); }, []); const currentWord = tokenizedQuery.find(({ range }) => range[0] < chatInput.selectionEnd && range[1] >= chatInput.selectionEnd); return currentWord; }; /** * Returns the user that the selection is over, from the input value, if prefixed by a @ * @returns Mention username or null */ const getUserFocusIfMention = () => { const currentWord = getIndexiesOfWordInCurrentSelection(); const [mentions] = chatInput.value.split(/\s+(?=[^@])/u); const isUserChat = mentions.startsWith('@'); if (currentWord && isUserChat) { const [, end] = currentWord.range; return end <= mentions.length ? currentWord : null; } return null; }; /** * Handle a dropdown submit event (enter, tab or click) * by autofilling the value to the input field * @param evt Event object * @param username Username to autofill */ const handleSubmit = (evt, username = dropdown.getCurrent()[0].description) => { const mention = getUserFocusIfMention(); if (mention === null) return; const [start, end] = mention.range; if (username) { const before = chatInput.value.slice(0, start); const after = chatInput.value.substring(end, chatInput.value.length); const insertSpaceAfter = !after.startsWith(' '); const beforeValue = `${ before }@${ username }${ insertSpaceAfter ? ' ' : '' }`; const cursorPosition = [beforeValue.length + 1, beforeValue.length + 1]; chatInput.value = `${ beforeValue }${ after }`; chatInput.setSelectionRange(...cursorPosition); } evt.preventDefault(); chatInput.dispatchEvent(new InputEvent('input')); }; /** * Event handler for TTClient.EVENTS.GAME_LIST_CHANGED */ const handleGameListChanged = () => { const gameStates = ClientManager.getClient().getAvailableGameStates(); for (const gameState of gameStates) { const playerStates = gameState.getPlayerStates(); for (const player of playerStates) { const playerId = player.getPlayerId(); Backend.getInstance().getPlayerDetails(result => { if (typeof result === 'object') dropdown.addOption(result.getUsername(), handleSubmit); }, () => {}, () => {}, playerId, Caches.getPlayerDetailsCache()); } } }; /** * Event handler for received chat messages * @param data Event data */ const handleNewChatMessage = data => { const involvedPlayerIds = data.involvedPlayerIds || [...data.getFrom() || [], ...data.getTo() || []]; const loggedIn = Users.getAllPlayerIds(); const foreignPlayerIds = involvedPlayerIds.filter(playerId => !loggedIn.includes(playerId)); for (const playerId of foreignPlayerIds) { Backend.getInstance().getPlayerDetails(result => { if (typeof result === 'object') dropdown.addOption(result.getUsername(), handleSubmit); }, () => {}, () => {}, playerId, Caches.getPlayerDetailsCache()); } }; chatInput.addEventListener('input', ({ isComposing }) => { if (isComposing) return; const userFocus = getUserFocusIfMention(); if (userFocus === null) { dropdown.hide(); return; } dropdown.searchTerm = userFocus.word.replace(/^@/u, ''); if (!dropdown.matches.length) { dropdown.hide(); return; } // Show UI dropdown.show(); dropdown.update(); }); // eslint-disable-next-line complexity chatInput.addEventListener('keydown', evt => { const userFocus = getUserFocusIfMention(); if (userFocus === null) return; dropdown.searchTerm = userFocus.word.replace(/^@/u, ''); if (!dropdown.matches.length) return; switch (evt.key) { case 'Enter': case 'Tab': handleSubmit(evt); break; case 'ArrowUp': dropdown.navigate(-1); evt.preventDefault(); break; case 'ArrowDown': dropdown.navigate(1); evt.preventDefault(); break; default: break; } }, false); /** * State change event handler * @param _self Self reference * @param _oldState Old client state * @param newState New client state */ const clientStateEventHandler = (_self, _oldState, newState) => { switch (newState) { case TTClient.STATES.UNCONNECTED: dropdown.clearOptions(); break; default: break; } }; /** * Event handler for new chat messages * @param _self Self reference * @param evt Event type * @param data Event data */ // eslint-disable-next-line complexity const clientEventHandler = (_self, evt, data) => { switch (evt) { case TTClient.EVENTS.GAME_LIST_CHANGED: handleGameListChanged(); break; case TTClient.EVENTS.USER_CHAT_POSTED: if (data) handleNewChatMessage(data); break; case TTClient.EVENTS.GLOBAL_CHAT_POSTED: case TTClient.EVENTS.CHAT_POSTED: if (data) handleNewChatMessage(data); break; case TTClient.EVENTS.SYSTEM_CHAT_POSTED: case TTClient.EVENTS.PLAYERS_BANNED: case TTClient.EVENTS.PLAYERS_UNBANNED: if (data) handleNewChatMessage(data); break; default: break; } }; ClientManager.getClient().addStateChangeListener(clientStateEventHandler, this); ClientManager.getClient().addEventListener(clientEventHandler, this); }; changeHandleDirection(); fixChatRendering(); initChatSavestate(); Loader.whenContentInitialized().then(() => { // eslint-disable-next-line prefer-destructuring const chatInput = TankTrouble.ChatBox.chatInput[0]; preventServerChangeChatClear(); addMentionAutocomplete(chatInput); addInputHistory(chatInput); // Allow more characters in the chat input chatInput.setAttribute('maxlength', '255'); });