您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Various UI mods for Hordes.io.
当前为
// ==UserScript== // @name Hordes UI Mod // @version 0.170 // @description Various UI mods for Hordes.io. // @author Sakaiyo // @match https://hordes.io/play // @grant GM_addStyle // @namespace https://greasyfork.org/users/160017 // ==/UserScript== /** * TODO: Implement chat tabs * TODO: Implement inventory sorting * TODO: (Maybe) Improved healer party frames * TODO: FIX BUG: Add support for resizing map back to saved position after minimizing it, from maximized position * TODO: (Maybe): Add toggleable option to include chat messages to right of party frame * TODO: Remove all reliance on svelte- classes, likely breaks with updates * TODO: Add cooldown on skills (leverage skill icon URL, have a map for each skill icon mapping to its respective cooldown) */ (function() { 'use strict'; // If this version is different from the user's stored state, // e.g. they have upgraded the version of this script and there are breaking changes, // then their stored state will be deleted. const BREAKING_VERSION = 1; const VERSION = '0.170'; // Should match version in UserScript description // The width+height of the maximized chat, so we don't save map size when it's maximized // TODO: FIX BUG: This is NOT everyones max size. INSTEAD OF USING A STATIC SIZE, we should detect large instant resizes // Should also do this to avoid saving when minimizing menu after maximizing const CHAT_MAXIMIZED_SIZE = 692; const STORAGE_STATE_KEY = 'hordesio-uimodsakaiyo-state'; const CHAT_GM_CLASS = 'js-chat-gm'; let state = { breakingVersion: BREAKING_VERSION, chat: { GM: true, }, windowsPos: {}, blockList: {}, friendsList: {}, mapOpacity: 70, // e.g. 70 = opacity: 0.7 }; // tempState is saved only between page refreshes. const tempState = { // The last name clicked in chat chatName: null }; // UPDATING STYLES BELOW - Must be invoked in main function GM_addStyle(` /* Transparent chat bg color */ .frame.svelte-1vrlsr3 { background: rgba(0,0,0,0.4); } /* Our mod's chat message color */ .textuimod { color: #00dd33; } /* Allows windows to be moved */ .window { position: relative; } /* Allows last clicked window to appear above all other windows */ .js-is-top { z-index: 9998 !important; } .panel.context:not(.commandlist) { z-index: 9999 !important; } /* The item icon being dragged in the inventory */ .container.svelte-120o2pb { z-index: 9999 !important; } /* All purpose hidden class */ .js-hidden { display: none; } /* Custom chat context menu, invisible by default */ .js-chat-context-menu { display: none; } .js-chat-context-menu .name { color: white; padding: 2px 4px; } /* Allow names in chat to be clicked */ #chat .name { pointer-events: all !important; } /* Custom chat filter colors */ .js-chat-gm { color: #a6dcd5; } /* Class that hides chat lines */ .js-line-hidden, .js-line-blocked { display: none; } /* Enable chat & map resize */ .js-chat-resize { resize: both; overflow: auto; } .js-map-resize:hover { resize: both; overflow: auto; direction: rtl; } /* The browser resize icon */ *::-webkit-resizer { background: linear-gradient(to right, rgba(51, 77, 80, 0), rgba(203, 202, 165, 0.5)); border-radius: 8px; box-shadow: 0 1px 1px rgba(0,0,0,1); } *::-moz-resizer { background: linear-gradient(to right, rgba(51, 77, 80, 0), rgba(203, 202, 165, 0.5)); border-radius: 8px; box-shadow: 0 1px 1px rgba(0,0,0,1); } .js-map-opacity { position: absolute; top: 46px; right: 12px; z-index: 999; width: 100px; height: 100px; text-align: right; display: none; pointer-events: all; } .js-map-opacity:hover { display: block; } .js-map-opacity button { border-radius: 10px; font-size: 18px; padding: 0 5px; background: rgba(0,0,0,0.4); border: 0; color: white; font-weight: bold; pointer: cursor; } /* On hover of map, show opacity controls */ .js-map:hover .js-map-opacity { display: block; } /* Custom css for settings page, duplicates preexisting settings pane grid */ .uimod-settings { display: grid; grid-template-columns: 2fr 3fr; grid-gap: 8px; align-items: center; max-height: 390px; margin: 0 20px; overflow-y: auto; } /* Friends list CSS, similar to settings but supports 4 columns */ .uimod-friends { display: grid; grid-template-columns: 2fr 2fr 2fr 1fr; grid-gap: 8px; align-items: center; max-height: 390px; margin: 0 20px; overflow-y: auto; } /* Our custom window, closely mirrors main settings window */ .uimod-custom-window { position: absolute; top: 100px; left: 50%; transform: translate(-50%, 0); min-width: 350px; max-width: 600px; width: 90%; height: 80%; min-height: 350px; max-height: 500px; z-index: 9; padding: 0px 10px 5px; } `); const modHelpers = { // Automated chat command helpers // (We've been OK'd to do these by the dev - all automation like this should receive approval from the dev) whisperPlayer: playerName => { enterTextIntoChat(`/whisper ${tempState.chatName} `); }, partyPlayer: playerName => { enterTextIntoChat(`/partyinvite ${tempState.chatName}`); submitChat(); }, // Filters all chat based on custom filters filterAllChat: () => { // Blocked user filter Object.keys(state.blockList).forEach(blockedName => { // Get the `.name` elements from the blocked user, if we haven't already hidden their messages const $blockedChatNames = Array.from(document.querySelectorAll(`[data-chat-name="${blockedName}"]:not(.js-line-blocked)`)); // Hide each of their messages $blockedChatNames.forEach($name => { // Add the class name to $name so we can track whether it's been hidden in our CSS selector $blockedChatNames $name.classList.add('js-line-blocked'); const $line = $name.parentNode.parentNode.parentNode; // Add the class name to $line so we can visibly hide the entire chat line $line.classList.add('js-line-blocked'); }); }) // Custom channel filter Object.keys(state.chat).forEach(channel => { Array.from(document.querySelectorAll(`.text${channel}.content`)).forEach($textItem => { const $line = $textItem.parentNode.parentNode; $line.classList.toggle('js-line-hidden', !state.chat[channel]); }); }); }, // Makes chat context menu visible and appear under the mouse showChatContextMenu: (name, mousePos) => { // Right before we show the context menu, we want to handle showing/hiding Friend/Unfriend const $contextMenu = document.querySelector('.js-chat-context-menu'); $contextMenu.querySelector('[name="friend"]').classList.toggle('js-hidden', !!state.friendsList[name]); $contextMenu.querySelector('[name="unfriend"]').classList.toggle('js-hidden', !state.friendsList[name]); $contextMenu.querySelector('.js-name').textContent = name; $contextMenu.setAttribute('style', `display: block; left: ${mousePos.x}px; top: ${mousePos.y}px;`); }, // Close chat context menu if clicking outside of it closeChatContextMenu: clickEvent => { const $target = clickEvent.target; // If clicking on name or directly on context menu, don't close it // Still closes if clicking on context menu item if ($target.classList.contains('js-is-context-menu-initd') || $target.classList.contains('js-chat-context-menu')) { return; } const $contextMenu = document.querySelector('.js-chat-context-menu'); $contextMenu.setAttribute('style', 'display: none'); }, friendPlayer: playerName => { if (state.friendsList[playerName]) { return; } state.friendsList[playerName] = true; modHelpers.addChatMessage(`${playerName} has been added to your friends list.`); save(); }, unfriendPlayer: playerName => { if (!state.friendsList[playerName]) { return; } delete state.friendsList[playerName]; modHelpers.addChatMessage(`${playerName} is no longer on your friends list.`); save(); }, // Adds player to block list, to be filtered out of chat blockPlayer: playerName => { if (state.blockList[playerName]) { return; } state.blockList[playerName] = true; modHelpers.filterAllChat(); modHelpers.addChatMessage(`${playerName} has been blocked.`) save(); }, // Removes player from block list and makes their messages visible again unblockPlayer: playerName => { delete state.blockList[playerName]; modHelpers.addChatMessage(`${playerName} has been unblocked.`); save(); // Make messages visible again const $chatNames = Array.from(document.querySelectorAll(`.js-line-blocked[data-chat-name="${playerName}"]`)); $chatNames.forEach($name => { $name.classList.remove('js-line-blocked'); const $line = $name.parentNode.parentNode.parentNode; $line.classList.remove('js-line-blocked'); }); }, // Pushes message to chat // TODO: The margins for the message are off slightly compared to other messages - why? addChatMessage: text => { const newMessageHTML = ` <div class="linewrap svelte-1vrlsr3"> <span class="time svelte-1vrlsr3">00.00</span> <span class="textuimod content svelte-1vrlsr3"> <span class="capitalize channel svelte-1vrlsr3">UIMod</span> </span> <span class="svelte-1vrlsr3">${text}</span> </div> `; const element = makeElement({ element: 'article', class: 'line svelte-1vrlsr3', content: newMessageHTML}); document.querySelector('#chat').appendChild(element); }, }; // MAIN MODS BELOW const mods = [ // Creates DOM elements for custom chat filters function newChatFilters() { const $channelselect = document.querySelector('.channelselect'); if (!document.querySelector(`.${CHAT_GM_CLASS}`)) { const $gm = makeElement({ element: 'small', class: `btn border black ${CHAT_GM_CLASS} ${state.chat.GM ? '' : 'textgrey'}`, content: 'GM' }); $channelselect.appendChild($gm); } }, // Wire up new chat buttons to toggle in state+ui function newChatFilterButtons() { const $chatGM = document.querySelector(`.${CHAT_GM_CLASS}`); $chatGM.addEventListener('click', () => { state.chat.GM = !state.chat.GM; $chatGM.classList.toggle('textgrey', !state.chat.GM); modHelpers.filterAllChat(); save(); }); }, // Filter out chat in UI based on chat buttons state function filterChatObserver() { const chatObserver = new MutationObserver(modHelpers.filterAllChat); chatObserver.observe(document.querySelector('#chat'), { attributes: true, childList: true }); }, // Drag all windows by their header function draggableUIWindows() { Array.from(document.querySelectorAll('.window:not(.js-can-move)')).forEach($window => { $window.classList.add('js-can-move'); dragElement($window, $window.querySelector('.titleframe')); }); }, // Save dragged UI windows position to state function saveDraggedUIWindows() { Array.from(document.querySelectorAll('.window:not(.js-window-is-saving)')).forEach($window => { $window.classList.add('js-window-is-saving'); const $draggableTarget = $window.querySelector('.titleframe'); const windowName = $draggableTarget.querySelector('[name="title"]').textContent; $draggableTarget.addEventListener('mouseup', () => { state.windowsPos[windowName] = $window.getAttribute('style'); save(); }); }); }, // Loads draggable UI windows position from state function loadDraggedUIWindowsPositions() { Array.from(document.querySelectorAll('.window:not(.js-has-loaded-pos)')).forEach($window => { $window.classList.add('js-has-loaded-pos'); const windowName = $window.querySelector('[name="title"]').textContent; const pos = state.windowsPos[windowName]; if (pos) { $window.setAttribute('style', pos); } }); }, // Makes chat resizable function resizableChat() { // Add the appropriate classes const $chatContainer = document.querySelector('#chat').parentNode; $chatContainer.classList.add('js-chat-resize'); // Load initial chat and map size if (state.chatWidth && state.chatHeight) { $chatContainer.style.width = state.chatWidth; $chatContainer.style.height = state.chatHeight; } // Save chat size on resize const resizeObserverChat = new ResizeObserver(() => { const chatWidthStr = window.getComputedStyle($chatContainer, null).getPropertyValue('width'); const chatHeightStr = window.getComputedStyle($chatContainer, null).getPropertyValue('height'); state.chatWidth = chatWidthStr; state.chatHeight = chatHeightStr; save(); }); resizeObserverChat.observe($chatContainer); }, // Makes map resizable function resizeableMap() { const $map = document.querySelector('.container canvas').parentNode; const $canvas = $map.querySelector('canvas'); $map.classList.add('js-map-resize'); const onMapResize = () => { // Get real values of map height/width, excluding padding/margin/etc const mapWidthStr = window.getComputedStyle($map, null).getPropertyValue('width'); const mapHeightStr = window.getComputedStyle($map, null).getPropertyValue('height'); const mapWidth = Number(mapWidthStr.slice(0, -2)); const mapHeight = Number(mapHeightStr.slice(0, -2)); // If height/width are 0 or unset, don't resize canvas if (!mapWidth || !mapHeight) { return; } if ($canvas.width !== mapWidth) { $canvas.width = mapWidth; } if ($canvas.height !== mapHeight) { $canvas.height = mapHeight; } // Save map size on resize, unless map has been maximized by user if (mapWidth !== CHAT_MAXIMIZED_SIZE && mapHeight !== CHAT_MAXIMIZED_SIZE) { state.mapWidth = mapWidthStr; state.mapHeight = mapHeightStr; save(); } }; if (state.mapWidth && state.mapHeight) { $map.style.width = state.mapWidth; $map.style.height = state.mapHeight; onMapResize(); // Update canvas size on initial load of saved map size } // On resize of map, resize canvas to match const resizeObserverMap = new ResizeObserver(onMapResize); resizeObserverMap.observe($map); // We need to observe canvas resizes to tell when the user presses M to open the big map // At that point, we resize the map to match the canvas const triggerResize = () => { // Get real values of map height/width, excluding padding/margin/etc const mapWidthStr = window.getComputedStyle($map, null).getPropertyValue('width'); const mapHeightStr = window.getComputedStyle($map, null).getPropertyValue('height'); const mapWidth = Number(mapWidthStr.slice(0, -2)); const mapHeight = Number(mapHeightStr.slice(0, -2)); // If height/width are 0 or unset, we don't care about resizing yet if (!mapWidth || !mapHeight) { return; } if ($canvas.width !== mapWidth) { $map.style.width = `${$canvas.width}px`; } if ($canvas.height !== mapHeight) { $map.style.height = `${$canvas.height}px`; } }; // We debounce the canvas resize, so it doesn't resize every single // pixel you move when resizing the DOM. If this were to happen, // resizing would constantly be interrupted. You'd have to resize a tiny bit, // lift left click, left click again to resize a tiny bit more, etc. // Resizing is smooth when we debounce this canvas. const debouncedTriggerResize = debounce(triggerResize, 50); const resizeObserverCanvas = new ResizeObserver(debouncedTriggerResize); resizeObserverCanvas.observe($canvas); }, function mapOpacityControls() { const $map = document.querySelector('.container canvas'); if (!$map.parentNode.classList.contains('js-map')) { $map.parentNode.classList.add('js-map'); } const $mapContainer = document.querySelector('.js-map'); // On load, update map opacity to match state // We modify the opacity of the canvas and the background color alpha of the parent container // We do this to allow our opacity buttons to be visible on hover with 100% opacity // (A surprisingly difficult enough task to require this implementation) const updateMapOpacity = () => { $map.setAttribute('style', `opacity: ${String(state.mapOpacity / 100)}`); const mapContainerBgColor = window.getComputedStyle($mapContainer, null).getPropertyValue('background-color'); // Credit for this regexp + This opacity+rgba dual implementation: https://stackoverflow.com/questions/16065998/replacing-changing-alpha-in-rgba-javascript const newBgColor = mapContainerBgColor.replace(/[\d\.]+\)$/g, `${state.mapOpacity / 100})`); $mapContainer.setAttribute('style', `background-color: ${newBgColor}`); }; updateMapOpacity(); const $opacityButtons = makeElement({ element: 'div', class: 'js-map-opacity', content: `<button class="js-map-opacity-add">+</button><button class="js-map-opacity-minus">-</button>`, }); // Add it right before the map container div $map.parentNode.insertBefore($opacityButtons, $map); const $addBtn = document.querySelector('.js-map-opacity-add'); const $minusBtn = document.querySelector('.js-map-opacity-minus'); // Hide the buttons if map opacity is maxed/minimum if (state.mapOpacity === 100) { $addBtn.setAttribute('style', 'visibility: hidden'); } if (state.mapOpacity === 0) { $minusBtn.setAttribute('style', 'visibility: hidden'); } // Wire it up $addBtn.addEventListener('click', clickEvent => { // Update opacity state.mapOpacity += 10; save(); updateMapOpacity(); // Hide this button if the opacity is max if (state.mapOpacity === 100) { const $btn = clickEvent.target; // We use visibility to hide the button but keep its position in the UI $btn.setAttribute('style', 'visibility: hidden'); } // If map opacity is not the lowest, then make minus button visible if (state.mapOpacity !== 0) { $minusBtn.setAttribute('style', 'visibility: visible'); } save(); }); $minusBtn.addEventListener('click', clickEvent => { // Update opacity state.mapOpacity -= 10; save(); updateMapOpacity(); // Hide this button if the opacity is lowest if (state.mapOpacity === 0) { const $btn = clickEvent.target; $btn.setAttribute('style', 'visibility: hidden'); } // If map opacity is not the max, then make add button visible if (state.mapOpacity !== 100) { $addBtn.setAttribute('style', 'visibility: visible'); } }); }, // The last clicked UI window displays above all other UI windows // This is useful when, for example, your inventory is near the market window, // and you want the window and the tooltips to display above the market window. function selectedWindowIsTop() { Array.from(document.querySelectorAll('.window:not(.js-is-top-initd)')).forEach($window => { $window.classList.add('js-is-top-initd'); $window.addEventListener('mousedown', () => { // First, make the other is-top window not is-top const $otherWindowContainer = document.querySelector('.js-is-top'); if ($otherWindowContainer) { $otherWindowContainer.classList.remove('js-is-top'); } // Then, make our window's container (the z-index container) is-top $window.parentNode.classList.add('js-is-top'); }); }); }, // The F icon and the UI that appears when you click it function customFriendsList() { var friendsIconElement = makeElement({ element: 'div', class: 'btn border black js-friends-list-icon', content: 'F', }); // Add the icon to the right of Elixir icon const $elixirIcon = document.querySelector('#sysgem'); $elixirIcon.parentNode.insertBefore(friendsIconElement, $elixirIcon.nextSibling); // Create the friends list UI document.querySelector('.js-friends-list-icon').addEventListener('click', () => { if (document.querySelector('.js-friends-list')) { // Don't open the friends list twice. return; } let friendsListHTML = ''; Object.keys(state.friendsList).sort().forEach(friendName => { friendsListHTML += ` <div data-player-name="${friendName}">${friendName}</div> <div class="btn blue js-whisper-player" data-player-name="${friendName}">Whisper</div> <div class="btn blue js-party-player" data-player-name="${friendName}">Party invite</div> <div class="btn orange js-unfriend-player" data-player-name="${friendName}">Remove</div> `; }); const customFriendsWindowHTML = ` <h3 class="textprimary">Friends list</h3> <div class="uimod-friends">${friendsListHTML}</div> <p></p> <div class="btn purp js-close-custom-friends-list">Close</div> `; const $customFriendsList = makeElement({ element: 'div', class: 'menu panel-black js-friends-list uimod-custom-window', content: customFriendsWindowHTML, }); document.body.appendChild($customFriendsList); // Wire up the buttons Array.from(document.querySelectorAll('.js-whisper-player')).forEach($button => { $button.addEventListener('click', clickEvent => { const name = clickEvent.target.getAttribute('data-player-name'); modHelpers.whisperPlayer(name); }); }); Array.from(document.querySelectorAll('.js-party-player')).forEach($button => { $button.addEventListener('click', clickEvent => { const name = clickEvent.target.getAttribute('data-player-name'); modHelpers.partyPlayer(name); }); }); Array.from(document.querySelectorAll('.js-unfriend-player')).forEach($button => { $button.addEventListener('click', clickEvent => { const name = clickEvent.target.getAttribute('data-player-name'); modHelpers.unfriendPlayer(name); // Remove the blocked player from the list Array.from(document.querySelectorAll(`.js-friends-list [data-player-name="${name}"]`)).forEach($element => { $element.parentNode.removeChild($element); }); }); }); // The close button for our custom UI document.querySelector('.js-close-custom-friends-list').addEventListener('click', () => { const $friendsListWindow = document.querySelector('.js-friends-list'); $friendsListWindow.parentNode.removeChild($friendsListWindow); }); }); }, // Custom settings UI, currently just Blocked players function customSettings() { const $settings = document.querySelector('.divide:not(.js-settings-initd)'); if (!$settings) { return; } $settings.classList.add('js-settings-initd'); const $settingsChoiceList = $settings.querySelector('.choice').parentNode; $settingsChoiceList.appendChild(makeElement({ element: 'div', class: 'choice js-blocked-players', content: 'Blocked players', })); // Upon click, we display our custom settings window UI document.querySelector('.js-blocked-players').addEventListener('click', () => { let blockedPlayersHTML = ''; Object.keys(state.blockList).sort().forEach(blockedName => { blockedPlayersHTML += ` <div data-player-name="${blockedName}">${blockedName}</div> <div class="btn orange js-unblock-player" data-player-name="${blockedName}">Unblock player</div> `; }); const customSettingsHTML = ` <h3 class="textprimary">Blocked players</h3> <div class="settings uimod-settings">${blockedPlayersHTML}</div> <p></p> <div class="btn purp js-close-custom-settings">Close</div> `; const $customSettings = makeElement({ element: 'div', class: 'menu panel-black js-custom-settings uimod-custom-window', content: customSettingsHTML, }); document.body.appendChild($customSettings); // Wire up all the unblock buttons Array.from(document.querySelectorAll('.js-unblock-player')).forEach($button => { $button.addEventListener('click', clickEvent => { const name = clickEvent.target.getAttribute('data-player-name'); modHelpers.unblockPlayer(name); // Remove the blocked player from the list Array.from(document.querySelectorAll(`.js-custom-settings [data-player-name="${name}"]`)).forEach($element => { $element.parentNode.removeChild($element); }); }); }); // And the close button for our custom UI document.querySelector('.js-close-custom-settings').addEventListener('click', () => { const $customSettingsWindow = document.querySelector('.js-custom-settings'); $customSettingsWindow.parentNode.removeChild($customSettingsWindow); }); }); }, // This creates the initial chat context menu (which starts as hidden) function createChatContextMenu() { if (document.querySelector('.js-chat-context-menu')) { return; } let contextMenuHTML = ` <div class="js-name">...</div> <div class="choice" name="party">Party invite</div> <div class="choice" name="whisper">Whisper</div> <div class="choice" name="friend">Friend</div> <div class="choice" name="unfriend">Unfriend</div> <div class="choice" name="block">Block</div> ` document.body.appendChild(makeElement({ element: 'div', class: 'panel context border grey js-chat-context-menu', content: contextMenuHTML, })); const $chatContextMenu = document.querySelector('.js-chat-context-menu'); $chatContextMenu.querySelector('[name="party"]').addEventListener('click', () => { modHelpers.partyPlayer(tempState.chatName); }); $chatContextMenu.querySelector('[name="whisper"]').addEventListener('click', () => { modHelpers.whisperPlayer(tempState.chatName); }); $chatContextMenu.querySelector('[name="friend"]').addEventListener('click', () => { modHelpers.friendPlayer(tempState.chatName); }); $chatContextMenu.querySelector('[name="unfriend"]').addEventListener('click', () => { modHelpers.unfriendPlayer(tempState.chatName); }); $chatContextMenu.querySelector('[name="block"]').addEventListener('click', () => { modHelpers.blockPlayer(tempState.chatName); }); }, // This opens a context menu when you click a user's name in chat function chatContextMenu() { Array.from(document.querySelectorAll('.name:not(.js-is-context-menu-initd)')).forEach($name => { $name.classList.add('js-is-context-menu-initd'); // Add name to element so we can target it in CSS when filtering chat for block list $name.setAttribute('data-chat-name', $name.textContent); const showContextMenu = clickEvent => { // TODO: Is there a way to pass the name to showChatContextMenumethod, instead of storing in tempState? tempState.chatName = $name.textContent; modHelpers.showChatContextMenu($name.textContent, {x: clickEvent.pageX, y: clickEvent.pageY}); }; $name.addEventListener('click', showContextMenu); }); }, ]; // Add new DOM, load our stored state, wire it up, then continuously rerun specific methods whenever UI changes function initialize() { // If the Hordes.io tab isn't active for long enough, it reloads the entire page, clearing this mod // We check for that and reinitialize the mod if that happens const $layout = document.querySelector('.layout'); if ($layout.classList.contains('uimod-initd')) { return; } modHelpers.addChatMessage(`Hordes UI Mod v${VERSION} by Sakaiyo has been initialized.`); $layout.classList.add('uimod-initd') load(); mods.forEach(mod => mod()); // Continuously re-run specific mods methods that need to be executed on UI change const rerunObserver = new MutationObserver(() => { // If new window appears, e.g. even if window is closed and reopened, we need to rewire it // Fun fact: Some windows always exist in the DOM, even when hidden, e.g. Inventory // But some windows only exist in the DOM when open, e.g. Interaction const modsToRerun = [ 'saveDraggedUIWindows', 'draggableUIWindows', 'loadDraggedUIWindowsPositions', 'selectedWindowIsTop', 'customSettings', ]; modsToRerun.forEach(modName => { mods.find(mod => mod.name === modName)(); }); }); rerunObserver.observe(document.querySelector('.layout > .container'), { attributes: false, childList: true, }); // Rerun only on chat const chatRerunObserver = new MutationObserver(() => { mods.find(mod => mod.name === 'chatContextMenu')(); modHelpers.filterAllChat(); }); chatRerunObserver.observe(document.querySelector('#chat'), { attributes: false, childList: true, }); // Event listeners for document.body might be kept when the game reloads, so don't reinitialize them if (!document.body.classList.contains('js-uimod-initd')) { document.body.classList.add('js-uimod-initd'); // Close chat context menu when clicking outside of it document.body.addEventListener('click', modHelpers.closeChatContextMenu); } } // Initialize mods once UI DOM has loaded const pageObserver = new MutationObserver(() => { const isUiLoaded = !!document.querySelector('.layout'); if (isUiLoaded) { initialize(); } }); pageObserver.observe(document.body, { attributes: true, childList: true }) // UTIL METHODS // Save to in-memory state and localStorage to retain on refresh function save(items) { localStorage.setItem(STORAGE_STATE_KEY, JSON.stringify(state)); } // Load localStorage state if it exists // NOTE: If user is trying to load unsupported version of stored state, // e.g. they just upgraded to breaking version, then we delete their stored state function load() { const storedStateJson = localStorage.getItem(STORAGE_STATE_KEY) if (storedStateJson) { const storedState = JSON.parse(storedStateJson); if (storedState.breakingVersion !== BREAKING_VERSION) { localStorage.setItem(STORAGE_STATE_KEY, JSON.stringify(state)); return; } state = { ...state, ...storedState, }; } } // Nicer impl to create elements in one method call function makeElement(args) { const $node = document.createElement(args.element); if (args.class) { $node.className = args.class; } if (args.content) { $node.innerHTML = args.content; } if (args.src) { $node.src = args.src; } return $node; } function enterTextIntoChat(text) { // Open chat input const enterEvent = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, keyCode: 13 }); document.body.dispatchEvent(enterEvent); // Place text into chat const $input = document.querySelector('#chatinput input'); $input.value = text; // Get chat input to recognize slash commands and change the channel // by triggering the `input` event. // (Did some debugging to figure out the channel only changes when the // svelte `input` event listener exists.) const inputEvent = new KeyboardEvent('input', { bubbles: true, cancelable: true }) $input.dispatchEvent(inputEvent); } function submitChat() { const $input = document.querySelector('#chatinput input'); const kbEvent = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, keyCode: 13 }); $input.dispatchEvent(kbEvent); } // Credit: https://stackoverflow.com/a/14234618 (Has been slightly modified) // $draggedElement is the item that will be dragged. // $dragTrigger is the element that must be held down to drag $draggedElement function dragElement($draggedElement, $dragTrigger) { let offset = [0,0]; let isDown = false; $dragTrigger.addEventListener('mousedown', function(e) { isDown = true; offset = [ $draggedElement.offsetLeft - e.clientX, $draggedElement.offsetTop - e.clientY ]; }, true); document.addEventListener('mouseup', function() { isDown = false; }, true); document.addEventListener('mousemove', function(e) { event.preventDefault(); if (isDown) { $draggedElement.style.left = (e.clientX + offset[0]) + 'px'; $draggedElement.style.top = (e.clientY + offset[1]) + 'px'; } }, true); } // Credit: David Walsh function debounce(func, wait, immediate) { var timeout; return function() { var context = this, args = arguments; var later = function() { timeout = null; if (!immediate) func.apply(context, args); }; var callNow = immediate && !timeout; clearTimeout(timeout); timeout = setTimeout(later, wait); if (callNow) func.apply(context, args); }; } })();