Hordes UI Mod

Various UI mods for Hordes.io.

当前为 2019-12-24 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Hordes UI Mod
  3. // @version 0.180
  4. // @description Various UI mods for Hordes.io.
  5. // @author Sakaiyo
  6. // @match https://hordes.io/play
  7. // @grant GM_addStyle
  8. // @namespace https://greasyfork.org/users/160017
  9. // ==/UserScript==
  10. /**
  11. * TODO: Implement chat tabs
  12. * TODO: Implement inventory sorting
  13. * TODO: (Maybe) Improved healer party frames
  14. * TODO: FIX BUG: Add support for resizing map back to saved position after minimizing it, from maximized position
  15. * TODO: (Maybe): Add toggleable option to include chat messages to right of party frame
  16. * TODO: Remove all reliance on svelte- classes, likely breaks with updates
  17. * TODO: Add cooldown on skills (leverage skill icon URL, have a map for each skill icon mapping to its respective cooldown)
  18. * TODO: Clicking names in party to add as friends
  19. * TODO: (MAYBE, Confirm if dev is ok w it) Ctrl clicking item in inventory when Merchant is open to paste item name into search field, and search
  20. * TODO: (MAYBE, confirm if dev is ok w it) Ctrl clicking item to copy details so user can paste in chat
  21. */
  22. (function() {
  23. 'use strict';
  24.  
  25. // If this version is different from the user's stored state,
  26. // e.g. they have upgraded the version of this script and there are breaking changes,
  27. // then their stored state will be deleted.
  28. const BREAKING_VERSION = 1;
  29. const VERSION = '0.180'; // Should match version in UserScript description
  30.  
  31. const STORAGE_STATE_KEY = 'hordesio-uimodsakaiyo-state';
  32. const CHAT_GM_CLASS = 'js-chat-gm';
  33.  
  34. let state = {
  35. breakingVersion: BREAKING_VERSION,
  36. chat: {
  37. GM: true,
  38. },
  39. windowsPos: {},
  40. blockList: {},
  41. friendsList: {},
  42. mapOpacity: 70, // e.g. 70 = opacity: 0.7
  43. friendNotes: {},
  44. };
  45. // tempState is saved only between page refreshes.
  46. const tempState = {
  47. // The last name clicked in chat
  48. chatName: null,
  49. lastMapWidth: 0,
  50. lastMapHeight: 0,
  51. };
  52.  
  53. // UPDATING STYLES BELOW - Must be invoked in main function
  54. GM_addStyle(`
  55. /* Transparent chat bg color */
  56. .frame.svelte-1vrlsr3 {
  57. background: rgba(0,0,0,0.4);
  58. }
  59.  
  60. /* Our mod's chat message color */
  61. .textuimod {
  62. color: #00dd33;
  63. }
  64.  
  65. /* Allows windows to be moved */
  66. .window {
  67. position: relative;
  68. }
  69.  
  70. /* Allows last clicked window to appear above all other windows */
  71. .js-is-top {
  72. z-index: 9998 !important;
  73. }
  74. .panel.context:not(.commandlist) {
  75. z-index: 9999 !important;
  76. }
  77. /* The item icon being dragged in the inventory */
  78. .container.svelte-120o2pb {
  79. z-index: 9999 !important;
  80. }
  81.  
  82. /* All purpose hidden class */
  83. .js-hidden {
  84. display: none;
  85. }
  86.  
  87. /* Custom chat context menu, invisible by default */
  88. .js-chat-context-menu {
  89. display: none;
  90. }
  91.  
  92. .js-chat-context-menu .name {
  93. color: white;
  94. padding: 2px 4px;
  95. }
  96.  
  97. /* Allow names in chat to be clicked */
  98. #chat .name,
  99. .textwhisper .textf1 {
  100. pointer-events: all !important;
  101. }
  102.  
  103. /* Custom chat filter colors */
  104. .js-chat-gm {
  105. color: #a6dcd5;
  106. }
  107.  
  108. /* Class that hides chat lines */
  109. .js-line-hidden,
  110. .js-line-blocked {
  111. display: none;
  112. }
  113.  
  114. /* Enable chat & map resize */
  115. .js-chat-resize {
  116. resize: both;
  117. overflow: auto;
  118. }
  119. .js-map-resize:hover {
  120. resize: both;
  121. overflow: auto;
  122. direction: rtl;
  123. }
  124.  
  125. /* The browser resize icon */
  126. *::-webkit-resizer {
  127. background: linear-gradient(to right, rgba(51, 77, 80, 0), rgba(203, 202, 165, 0.5));
  128. border-radius: 8px;
  129. box-shadow: 0 1px 1px rgba(0,0,0,1);
  130. }
  131. *::-moz-resizer {
  132. background: linear-gradient(to right, rgba(51, 77, 80, 0), rgba(203, 202, 165, 0.5));
  133. border-radius: 8px;
  134. box-shadow: 0 1px 1px rgba(0,0,0,1);
  135. }
  136.  
  137. .js-map-btns {
  138. position: absolute;
  139. top: 46px;
  140. right: 12px;
  141. z-index: 999;
  142. width: 100px;
  143. height: 100px;
  144. text-align: right;
  145. display: none;
  146. pointer-events: all;
  147. }
  148. .js-map-btns:hover {
  149. display: block;
  150. }
  151. .js-map-btns button {
  152. border-radius: 10px;
  153. font-size: 18px;
  154. padding: 0 5px;
  155. background: rgba(0,0,0,0.4);
  156. border: 0;
  157. color: white;
  158. font-weight: bold;
  159. pointer: cursor;
  160. }
  161. /* On hover of map, show opacity controls */
  162. .js-map:hover .js-map-btns {
  163. display: block;
  164. }
  165.  
  166. /* Custom css for settings page, duplicates preexisting settings pane grid */
  167. .uimod-settings {
  168. display: grid;
  169. grid-template-columns: 2fr 3fr;
  170. grid-gap: 8px;
  171. align-items: center;
  172. max-height: 390px;
  173. margin: 0 20px;
  174. overflow-y: auto;
  175. }
  176. /* Friends list CSS, similar to settings but supports 4 columns */
  177. .uimod-friends {
  178. display: grid;
  179. grid-template-columns: 2fr 1.1fr 1.5fr 0.33fr 3fr;
  180. grid-gap: 8px;
  181. align-items: center;
  182. max-height: 390px;
  183. margin: 0 20px;
  184. overflow-y: auto;
  185. }
  186. /* Our custom window, closely mirrors main settings window */
  187. .uimod-custom-window {
  188. position: absolute;
  189. top: 100px;
  190. left: 50%;
  191. transform: translate(-50%, 0);
  192. min-width: 350px;
  193. max-width: 600px;
  194. width: 90%;
  195. height: 80%;
  196. min-height: 350px;
  197. max-height: 500px;
  198. z-index: 9;
  199. padding: 0px 10px 5px;
  200. }
  201. `);
  202.  
  203.  
  204. const modHelpers = {
  205. // Automated chat command helpers
  206. // (We've been OK'd to do these by the dev - all automation like this should receive approval from the dev)
  207. whisperPlayer: playerName => {
  208. enterTextIntoChat(`/whisper ${tempState.chatName} `);
  209. },
  210. partyPlayer: playerName => {
  211. enterTextIntoChat(`/partyinvite ${tempState.chatName}`);
  212. submitChat();
  213. },
  214.  
  215. // Filters all chat based on custom filters
  216. filterAllChat: () => {
  217. // Blocked user filter
  218. Object.keys(state.blockList).forEach(blockedName => {
  219. // Get the `.name` elements from the blocked user, if we haven't already hidden their messages
  220. const $blockedChatNames = Array.from(document.querySelectorAll(`[data-chat-name="${blockedName}"]:not(.js-line-blocked)`));
  221. // Hide each of their messages
  222. $blockedChatNames.forEach($name => {
  223. // Add the class name to $name so we can track whether it's been hidden in our CSS selector $blockedChatNames
  224. $name.classList.add('js-line-blocked');
  225. const $line = $name.parentNode.parentNode.parentNode;
  226. // Add the class name to $line so we can visibly hide the entire chat line
  227. $line.classList.add('js-line-blocked');
  228. });
  229. })
  230.  
  231. // Custom channel filter
  232. Object.keys(state.chat).forEach(channel => {
  233. Array.from(document.querySelectorAll(`.text${channel}.content`)).forEach($textItem => {
  234. const $line = $textItem.parentNode.parentNode;
  235. $line.classList.toggle('js-line-hidden', !state.chat[channel]);
  236. });
  237. });
  238. },
  239.  
  240. // Makes chat context menu visible and appear under the mouse
  241. showChatContextMenu: (name, mousePos) => {
  242. // Right before we show the context menu, we want to handle showing/hiding Friend/Unfriend
  243. const $contextMenu = document.querySelector('.js-chat-context-menu');
  244. $contextMenu.querySelector('[name="friend"]').classList.toggle('js-hidden', !!state.friendsList[name]);
  245. $contextMenu.querySelector('[name="unfriend"]').classList.toggle('js-hidden', !state.friendsList[name]);
  246.  
  247. $contextMenu.querySelector('.js-name').textContent = name;
  248. $contextMenu.setAttribute('style', `display: block; left: ${mousePos.x}px; top: ${mousePos.y}px;`);
  249. },
  250.  
  251. // Close chat context menu if clicking outside of it
  252. closeChatContextMenu: clickEvent => {
  253. const $target = clickEvent.target;
  254. // If clicking on name or directly on context menu, don't close it
  255. // Still closes if clicking on context menu item
  256. if ($target.classList.contains('js-is-context-menu-initd')
  257. || $target.classList.contains('js-chat-context-menu')) {
  258. return;
  259. }
  260.  
  261. const $contextMenu = document.querySelector('.js-chat-context-menu');
  262. $contextMenu.style.display = 'none';
  263. },
  264.  
  265. friendPlayer: playerName => {
  266. if (state.friendsList[playerName]) {
  267. return;
  268. }
  269.  
  270. state.friendsList[playerName] = true;
  271. modHelpers.addChatMessage(`${playerName} has been added to your friends list.`);
  272. save();
  273. },
  274.  
  275. unfriendPlayer: playerName => {
  276. if (!state.friendsList[playerName]) {
  277. return;
  278. }
  279.  
  280. delete state.friendsList[playerName];
  281. delete state.friendNotes[playerName];
  282. modHelpers.addChatMessage(`${playerName} is no longer on your friends list.`);
  283. save();
  284. },
  285.  
  286. // Adds player to block list, to be filtered out of chat
  287. blockPlayer: playerName => {
  288. if (state.blockList[playerName]) {
  289. return;
  290. }
  291.  
  292. state.blockList[playerName] = true;
  293. modHelpers.filterAllChat();
  294. modHelpers.addChatMessage(`${playerName} has been blocked.`)
  295. save();
  296. },
  297.  
  298. // Removes player from block list and makes their messages visible again
  299. unblockPlayer: playerName => {
  300. delete state.blockList[playerName];
  301. modHelpers.addChatMessage(`${playerName} has been unblocked.`);
  302. save();
  303.  
  304. // Make messages visible again
  305. const $chatNames = Array.from(document.querySelectorAll(`.js-line-blocked[data-chat-name="${playerName}"]`));
  306. $chatNames.forEach($name => {
  307. $name.classList.remove('js-line-blocked');
  308. const $line = $name.parentNode.parentNode.parentNode;
  309. $line.classList.remove('js-line-blocked');
  310. });
  311. },
  312.  
  313. // Pushes message to chat
  314. // TODO: The margins for the message are off slightly compared to other messages - why?
  315. addChatMessage: text => {
  316. const newMessageHTML = `
  317. <div class="linewrap svelte-1vrlsr3">
  318. <span class="time svelte-1vrlsr3">00.00</span>
  319. <span class="textuimod content svelte-1vrlsr3">
  320. <span class="capitalize channel svelte-1vrlsr3">UIMod</span>
  321. </span>
  322. <span class="svelte-1vrlsr3">${text}</span>
  323. </div>
  324. `;
  325.  
  326. const element = makeElement({
  327. element: 'article',
  328. class: 'line svelte-1vrlsr3',
  329. content: newMessageHTML});
  330. document.querySelector('#chat').appendChild(element);
  331. },
  332. };
  333.  
  334. // MAIN MODS BELOW
  335. const mods = [
  336. // Creates DOM elements for custom chat filters
  337. function newChatFilters() {
  338. const $channelselect = document.querySelector('.channelselect');
  339. if (!document.querySelector(`.${CHAT_GM_CLASS}`)) {
  340. const $gm = makeElement({
  341. element: 'small',
  342. class: `btn border black ${CHAT_GM_CLASS} ${state.chat.GM ? '' : 'textgrey'}`,
  343. content: 'GM'
  344. });
  345. $channelselect.appendChild($gm);
  346. }
  347. },
  348.  
  349. // Wire up new chat buttons to toggle in state+ui
  350. function newChatFilterButtons() {
  351. const $chatGM = document.querySelector(`.${CHAT_GM_CLASS}`);
  352. $chatGM.addEventListener('click', () => {
  353. state.chat.GM = !state.chat.GM;
  354. $chatGM.classList.toggle('textgrey', !state.chat.GM);
  355. modHelpers.filterAllChat();
  356. save();
  357. });
  358. },
  359.  
  360. // Filter out chat in UI based on chat buttons state
  361. function filterChatObserver() {
  362. const chatObserver = new MutationObserver(modHelpers.filterAllChat);
  363. chatObserver.observe(document.querySelector('#chat'), { attributes: true, childList: true });
  364. },
  365.  
  366. // Drag all windows by their header
  367. function draggableUIWindows() {
  368. Array.from(document.querySelectorAll('.window:not(.js-can-move)')).forEach($window => {
  369. $window.classList.add('js-can-move');
  370. dragElement($window, $window.querySelector('.titleframe'));
  371. });
  372. },
  373.  
  374. // Save dragged UI windows position to state
  375. function saveDraggedUIWindows() {
  376. Array.from(document.querySelectorAll('.window:not(.js-window-is-saving)')).forEach($window => {
  377. $window.classList.add('js-window-is-saving');
  378. const $draggableTarget = $window.querySelector('.titleframe');
  379. const windowName = $draggableTarget.querySelector('[name="title"]').textContent;
  380. $draggableTarget.addEventListener('mouseup', () => {
  381. state.windowsPos[windowName] = $window.getAttribute('style');
  382. save();
  383. });
  384. });
  385. },
  386.  
  387. // Loads draggable UI windows position from state
  388. function loadDraggedUIWindowsPositions() {
  389. Array.from(document.querySelectorAll('.window:not(.js-has-loaded-pos)')).forEach($window => {
  390. $window.classList.add('js-has-loaded-pos');
  391. const windowName = $window.querySelector('[name="title"]').textContent;
  392. const pos = state.windowsPos[windowName];
  393. if (pos) {
  394. $window.setAttribute('style', pos);
  395. }
  396. });
  397. },
  398.  
  399. // Makes chat resizable
  400. function resizableChat() {
  401. // Add the appropriate classes
  402. const $chatContainer = document.querySelector('#chat').parentNode;
  403. $chatContainer.classList.add('js-chat-resize');
  404.  
  405. // Load initial chat and map size
  406. if (state.chatWidth && state.chatHeight) {
  407. $chatContainer.style.width = state.chatWidth;
  408. $chatContainer.style.height = state.chatHeight;
  409. }
  410.  
  411. // Save chat size on resize
  412. const resizeObserverChat = new ResizeObserver(() => {
  413. const chatWidthStr = window.getComputedStyle($chatContainer, null).getPropertyValue('width');
  414. const chatHeightStr = window.getComputedStyle($chatContainer, null).getPropertyValue('height');
  415. state.chatWidth = chatWidthStr;
  416. state.chatHeight = chatHeightStr;
  417. save();
  418. });
  419. resizeObserverChat.observe($chatContainer);
  420. },
  421.  
  422. // Makes map resizable
  423. function resizeableMap() {
  424. const $map = document.querySelector('.container canvas').parentNode;
  425. const $canvas = $map.querySelector('canvas');
  426. $map.classList.add('js-map-resize');
  427.  
  428. // Track whether we're clicking (resizing) map or not
  429. // Used to detect if resize changes are manually done, or from minimizing/maximizing map (with [M])
  430. $map.addEventListener('mousedown', () => {
  431. tempState.clickingMap = true;
  432. });
  433. // Sometimes the mouseup event may be registered outside of the map - we account for this
  434. document.body.addEventListener('mouseup', () => {
  435. tempState.clickingMap = false;
  436. });
  437.  
  438. const onMapResize = () => {
  439. // Get real values of map height/width, excluding padding/margin/etc
  440. const mapWidthStr = window.getComputedStyle($map, null).getPropertyValue('width');
  441. const mapHeightStr = window.getComputedStyle($map, null).getPropertyValue('height');
  442. const mapWidth = Number(mapWidthStr.slice(0, -2));
  443. const mapHeight = Number(mapHeightStr.slice(0, -2));
  444.  
  445. // If height/width are 0 or unset, don't resize canvas
  446. if (!mapWidth || !mapHeight) {
  447. return;
  448. }
  449.  
  450. if ($canvas.width !== mapWidth) {
  451. $canvas.width = mapWidth;
  452. }
  453.  
  454. if ($canvas.height !== mapHeight) {
  455. $canvas.height = mapHeight;
  456. }
  457.  
  458. // If we're clicking map, i.e. manually resizing, then save state
  459. // Don't save state when minimizing/maximizing map via [M]
  460. if (tempState.clickingMap) {
  461. state.mapWidth = mapWidthStr;
  462. state.mapHeight = mapHeightStr;
  463. save();
  464. } else {
  465. const isMaximized = mapWidth > tempState.lastMapWidth && mapHeight > tempState.lastMapHeight;
  466. if (!isMaximized) {
  467. $map.style.width = state.mapWidth;
  468. $map.style.height = state.mapHeight;
  469. }
  470. }
  471.  
  472. // Store last map width/height in temp state, so we know if we've minimized or maximized
  473. tempState.lastMapWidth = mapWidth;
  474. tempState.lastMapHeight = mapHeight;
  475. };
  476.  
  477. if (state.mapWidth && state.mapHeight) {
  478. $map.style.width = state.mapWidth;
  479. $map.style.height = state.mapHeight;
  480. onMapResize(); // Update canvas size on initial load of saved map size
  481. }
  482.  
  483. // On resize of map, resize canvas to match
  484. const resizeObserverMap = new ResizeObserver(onMapResize);
  485. resizeObserverMap.observe($map);
  486.  
  487. // We need to observe canvas resizes to tell when the user presses M to open the big map
  488. // At that point, we resize the map to match the canvas
  489. const triggerResize = () => {
  490. // Get real values of map height/width, excluding padding/margin/etc
  491. const mapWidthStr = window.getComputedStyle($map, null).getPropertyValue('width');
  492. const mapHeightStr = window.getComputedStyle($map, null).getPropertyValue('height');
  493. const mapWidth = Number(mapWidthStr.slice(0, -2));
  494. const mapHeight = Number(mapHeightStr.slice(0, -2));
  495.  
  496. // If height/width are 0 or unset, we don't care about resizing yet
  497. if (!mapWidth || !mapHeight) {
  498. return;
  499. }
  500.  
  501. if ($canvas.width !== mapWidth) {
  502. $map.style.width = `${$canvas.width}px`;
  503. }
  504.  
  505. if ($canvas.height !== mapHeight) {
  506. $map.style.height = `${$canvas.height}px`;
  507. }
  508. };
  509.  
  510. // We debounce the canvas resize, so it doesn't resize every single
  511. // pixel you move when resizing the DOM. If this were to happen,
  512. // resizing would constantly be interrupted. You'd have to resize a tiny bit,
  513. // lift left click, left click again to resize a tiny bit more, etc.
  514. // Resizing is smooth when we debounce this canvas.
  515. const debouncedTriggerResize = debounce(triggerResize, 50);
  516. const resizeObserverCanvas = new ResizeObserver(debouncedTriggerResize);
  517. resizeObserverCanvas.observe($canvas);
  518. },
  519.  
  520. function mapControls() {
  521. const $map = document.querySelector('.container canvas');
  522. if (!$map.parentNode.classList.contains('js-map')) {
  523. $map.parentNode.classList.add('js-map');
  524. }
  525. const $mapContainer = document.querySelector('.js-map');
  526.  
  527. // On load, update map opacity to match state
  528. // We modify the opacity of the canvas and the background color alpha of the parent container
  529. // We do this to allow our opacity buttons to be visible on hover with 100% opacity
  530. // (A surprisingly difficult enough task to require this implementation)
  531. const updateMapOpacity = () => {
  532. $map.style.opacity = String(state.mapOpacity / 100);
  533. const mapContainerBgColor = window.getComputedStyle($mapContainer, null).getPropertyValue('background-color');
  534. // Credit for this regexp + This opacity+rgba dual implementation: https://stackoverflow.com/questions/16065998/replacing-changing-alpha-in-rgba-javascript
  535. let opacity = state.mapOpacity / 100;
  536. // This is a slightly lazy browser workaround to fix a bug.
  537. // If the opacity is `1`, and it sets `rgba` to `1`, then the browser changes the
  538. // rgba to rgb, dropping the alpha. We could account for that and add the `alpha` back in
  539. // later, but setting the max opacity to very close to 1 makes sure the issue never crops up.
  540. // Fun fact: 0.99 retains the alpha, but setting this to 0.999 still causes the browser to drop the alpha. Rude.
  541. if (opacity === 1) {
  542. opacity = 0.99;
  543. }
  544. const newBgColor = mapContainerBgColor.replace(/[\d\.]+\)$/g, `${opacity})`);
  545. $mapContainer.style['background-color'] = newBgColor;
  546.  
  547. // Update the button opacity
  548. const $addBtn = document.querySelector('.js-map-opacity-add');
  549. const $minusBtn = document.querySelector('.js-map-opacity-minus');
  550. // Hide plus button if the opacity is max
  551. if (state.mapOpacity === 100) {
  552. $addBtn.style.visibility = 'hidden';
  553. } else {
  554. $addBtn.style.visibility = 'visible';
  555. }
  556. // Hide minus button if the opacity is lowest
  557. if (state.mapOpacity === 0) {
  558. $minusBtn.style.visibility = 'hidden';
  559. } else {
  560. $minusBtn.style.visibility = 'visible';
  561. }
  562. };
  563.  
  564. const $mapButtons = makeElement({
  565. element: 'div',
  566. class: 'js-map-btns',
  567. content: `
  568. <button class="js-map-opacity-add">+</button>
  569. <button class="js-map-opacity-minus">-</button>
  570. <button class="js-map-reset">r</button>
  571. `,
  572. });
  573.  
  574. // Add it right before the map container div
  575. $map.parentNode.insertBefore($mapButtons, $map);
  576.  
  577. const $addBtn = document.querySelector('.js-map-opacity-add');
  578. const $minusBtn = document.querySelector('.js-map-opacity-minus');
  579. const $resetBtn = document.querySelector('.js-map-reset');
  580. // Hide the buttons if map opacity is maxed/minimum
  581. if (state.mapOpacity === 100) {
  582. $addBtn.style.visibility = 'hidden';
  583. }
  584. if (state.mapOpacity === 0) {
  585. $minusBtn.style.visibility = 'hidden';
  586. }
  587.  
  588. // Wire it up
  589. $addBtn.addEventListener('click', clickEvent => {
  590. // Update opacity
  591. state.mapOpacity += 10;
  592. save();
  593. updateMapOpacity();
  594. });
  595.  
  596. $minusBtn.addEventListener('click', clickEvent => {
  597. // Update opacity
  598. state.mapOpacity -= 10;
  599. save();
  600. updateMapOpacity();
  601. });
  602.  
  603. $resetBtn.addEventListener('click', clickEvent => {
  604. state.mapOpacity = 70;
  605. state.mapWidth = '174px';
  606. state.mapHeight = '174px';
  607. save();
  608. updateMapOpacity();
  609. $mapContainer.style.width = state.mapWidth;
  610. $mapContainer.style.height = state.mapHeight;
  611. });
  612.  
  613. updateMapOpacity();
  614. },
  615.  
  616. // The last clicked UI window displays above all other UI windows
  617. // This is useful when, for example, your inventory is near the market window,
  618. // and you want the window and the tooltips to display above the market window.
  619. function selectedWindowIsTop() {
  620. Array.from(document.querySelectorAll('.window:not(.js-is-top-initd)')).forEach($window => {
  621. $window.classList.add('js-is-top-initd');
  622.  
  623. $window.addEventListener('mousedown', () => {
  624. // First, make the other is-top window not is-top
  625. const $otherWindowContainer = document.querySelector('.js-is-top');
  626. if ($otherWindowContainer) {
  627. $otherWindowContainer.classList.remove('js-is-top');
  628. }
  629.  
  630. // Then, make our window's container (the z-index container) is-top
  631. $window.parentNode.classList.add('js-is-top');
  632. });
  633. });
  634. },
  635.  
  636. // The F icon and the UI that appears when you click it
  637. function customFriendsList() {
  638. var friendsIconElement = makeElement({
  639. element: 'div',
  640. class: 'btn border black js-friends-list-icon',
  641. content: 'F',
  642. });
  643. // Add the icon to the right of Elixir icon
  644. const $elixirIcon = document.querySelector('#sysgem');
  645. $elixirIcon.parentNode.insertBefore(friendsIconElement, $elixirIcon.nextSibling);
  646.  
  647. // Create the friends list UI
  648. document.querySelector('.js-friends-list-icon').addEventListener('click', () => {
  649. if (document.querySelector('.js-friends-list')) {
  650. // Don't open the friends list twice.
  651. return;
  652. }
  653. let friendsListHTML = '';
  654. Object.keys(state.friendsList).sort().forEach(friendName => {
  655. friendsListHTML += `
  656. <div data-player-name="${friendName}">${friendName}</div>
  657. <div class="btn blue js-whisper-player" data-player-name="${friendName}">Whisper</div>
  658. <div class="btn blue js-party-player" data-player-name="${friendName}">Party invite</div>
  659. <div class="btn orange js-unfriend-player" data-player-name="${friendName}">X</div>
  660. <input type="text" class="js-friend-note" data-player-name="${friendName}" value="${state.friendNotes[friendName] || ''}"></input>
  661. `;
  662. });
  663.  
  664. const customFriendsWindowHTML = `
  665. <h3 class="textprimary">Friends list</h3>
  666. <div class="uimod-friends">${friendsListHTML}</div>
  667. <p></p>
  668. <div class="btn purp js-close-custom-friends-list">Close</div>
  669. `;
  670.  
  671. const $customFriendsList = makeElement({
  672. element: 'div',
  673. class: 'menu panel-black js-friends-list uimod-custom-window',
  674. content: customFriendsWindowHTML,
  675. });
  676. document.body.appendChild($customFriendsList);
  677.  
  678. // Wire up the buttons
  679. Array.from(document.querySelectorAll('.js-whisper-player')).forEach($button => {
  680. $button.addEventListener('click', clickEvent => {
  681. const name = clickEvent.target.getAttribute('data-player-name');
  682. modHelpers.whisperPlayer(name);
  683. });
  684. });
  685. Array.from(document.querySelectorAll('.js-party-player')).forEach($button => {
  686. $button.addEventListener('click', clickEvent => {
  687. const name = clickEvent.target.getAttribute('data-player-name');
  688. modHelpers.partyPlayer(name);
  689. });
  690. });
  691. Array.from(document.querySelectorAll('.js-unfriend-player')).forEach($button => {
  692. $button.addEventListener('click', clickEvent => {
  693. const name = clickEvent.target.getAttribute('data-player-name');
  694. modHelpers.unfriendPlayer(name);
  695.  
  696. // Remove the blocked player from the list
  697. Array.from(document.querySelectorAll(`.js-friends-list [data-player-name="${name}"]`)).forEach($element => {
  698. $element.parentNode.removeChild($element);
  699. });
  700. });
  701. });
  702. Array.from(document.querySelectorAll('.js-friend-note')).forEach($element => {
  703. $element.addEventListener('change', clickEvent => {
  704. const name = clickEvent.target.getAttribute('data-player-name');
  705. state.friendNotes[name] = clickEvent.target.value;
  706. });
  707. })
  708.  
  709. // The close button for our custom UI
  710. document.querySelector('.js-close-custom-friends-list').addEventListener('click', () => {
  711. const $friendsListWindow = document.querySelector('.js-friends-list');
  712. $friendsListWindow.parentNode.removeChild($friendsListWindow);
  713. });
  714. });
  715. },
  716.  
  717. // Custom settings UI, currently just Blocked players
  718. function customSettings() {
  719. const $settings = document.querySelector('.divide:not(.js-settings-initd)');
  720. if (!$settings) {
  721. return;
  722. }
  723.  
  724. $settings.classList.add('js-settings-initd');
  725. const $settingsChoiceList = $settings.querySelector('.choice').parentNode;
  726. $settingsChoiceList.appendChild(makeElement({
  727. element: 'div',
  728. class: 'choice js-blocked-players',
  729. content: 'Blocked players',
  730. }));
  731.  
  732. // Upon click, we display our custom settings window UI
  733. document.querySelector('.js-blocked-players').addEventListener('click', () => {
  734. let blockedPlayersHTML = '';
  735. Object.keys(state.blockList).sort().forEach(blockedName => {
  736. blockedPlayersHTML += `
  737. <div data-player-name="${blockedName}">${blockedName}</div>
  738. <div class="btn orange js-unblock-player" data-player-name="${blockedName}">Unblock player</div>
  739. `;
  740. });
  741.  
  742. const customSettingsHTML = `
  743. <h3 class="textprimary">Blocked players</h3>
  744. <div class="settings uimod-settings">${blockedPlayersHTML}</div>
  745. <p></p>
  746. <div class="btn purp js-close-custom-settings">Close</div>
  747. `;
  748.  
  749. const $customSettings = makeElement({
  750. element: 'div',
  751. class: 'menu panel-black js-custom-settings uimod-custom-window',
  752. content: customSettingsHTML,
  753. });
  754. document.body.appendChild($customSettings);
  755.  
  756. // Wire up all the unblock buttons
  757. Array.from(document.querySelectorAll('.js-unblock-player')).forEach($button => {
  758. $button.addEventListener('click', clickEvent => {
  759. const name = clickEvent.target.getAttribute('data-player-name');
  760. modHelpers.unblockPlayer(name);
  761.  
  762. // Remove the blocked player from the list
  763. Array.from(document.querySelectorAll(`.js-custom-settings [data-player-name="${name}"]`)).forEach($element => {
  764. $element.parentNode.removeChild($element);
  765. });
  766. });
  767. });
  768. // And the close button for our custom UI
  769. document.querySelector('.js-close-custom-settings').addEventListener('click', () => {
  770. const $customSettingsWindow = document.querySelector('.js-custom-settings');
  771. $customSettingsWindow.parentNode.removeChild($customSettingsWindow);
  772. });
  773. });
  774. },
  775.  
  776. // This creates the initial chat context menu DOM (which starts as hidden)
  777. function createChatContextMenu() {
  778. if (document.querySelector('.js-chat-context-menu')) {
  779. return;
  780. }
  781.  
  782. let contextMenuHTML = `
  783. <div class="js-name">...</div>
  784. <div class="choice" name="party">Party invite</div>
  785. <div class="choice" name="whisper">Whisper</div>
  786. <div class="choice" name="friend">Friend</div>
  787. <div class="choice" name="unfriend">Unfriend</div>
  788. <div class="choice" name="block">Block</div>
  789. `
  790. document.body.appendChild(makeElement({
  791. element: 'div',
  792. class: 'panel context border grey js-chat-context-menu',
  793. content: contextMenuHTML,
  794. }));
  795.  
  796. const $chatContextMenu = document.querySelector('.js-chat-context-menu');
  797. $chatContextMenu.querySelector('[name="party"]').addEventListener('click', () => {
  798. modHelpers.partyPlayer(tempState.chatName);
  799. });
  800. $chatContextMenu.querySelector('[name="whisper"]').addEventListener('click', () => {
  801. modHelpers.whisperPlayer(tempState.chatName);
  802. });
  803. $chatContextMenu.querySelector('[name="friend"]').addEventListener('click', () => {
  804. modHelpers.friendPlayer(tempState.chatName);
  805. });
  806. $chatContextMenu.querySelector('[name="unfriend"]').addEventListener('click', () => {
  807. modHelpers.unfriendPlayer(tempState.chatName);
  808. });
  809. $chatContextMenu.querySelector('[name="block"]').addEventListener('click', () => {
  810. modHelpers.blockPlayer(tempState.chatName);
  811. });
  812. },
  813.  
  814. // This opens a context menu when you click a user's name in chat
  815. function chatContextMenu() {
  816. const addContextMenu = ($name, name) => {
  817. $name.classList.add('js-is-context-menu-initd');
  818. // Add name to element so we can target it in CSS when filtering chat for block list
  819. $name.setAttribute('data-chat-name', name);
  820.  
  821. const showContextMenu = clickEvent => {
  822. // TODO: Is there a way to pass the name to showChatContextMenumethod, instead of storing in tempState?
  823. tempState.chatName = name;
  824. modHelpers.showChatContextMenu(name, {x: clickEvent.pageX, y: clickEvent.pageY});
  825. };
  826. $name.addEventListener('click', showContextMenu); // Left click
  827. $name.addEventListener('contextmenu', showContextMenu); // Right click works too
  828. };
  829. Array.from(document.querySelectorAll('.name:not(.js-is-context-menu-initd)')).forEach(($name) => {
  830. addContextMenu($name, $name.textContent);
  831. });
  832. Array.from(document.querySelectorAll('.textwhisper .textf1:not(.js-is-context-menu-initd)')).forEach($whisperName => {
  833. // $whisperName's textContent is "to [name]" or "from [name]", so we cut off the first word
  834. let name = $whisperName.textContent.split(' ');
  835. name.shift(); // Remove the first word
  836. name = name.join(' ');
  837. addContextMenu($whisperName, name);
  838. });
  839. },
  840. ];
  841.  
  842. // Add new DOM, load our stored state, wire it up, then continuously rerun specific methods whenever UI changes
  843. function initialize() {
  844. // If the Hordes.io tab isn't active for long enough, it reloads the entire page, clearing this mod
  845. // We check for that and reinitialize the mod if that happens
  846. const $layout = document.querySelector('.layout');
  847. if ($layout.classList.contains('uimod-initd')) {
  848. return;
  849. }
  850.  
  851. modHelpers.addChatMessage(`Hordes UI Mod v${VERSION} by Sakaiyo has been initialized.`);
  852.  
  853. $layout.classList.add('uimod-initd')
  854. load();
  855. mods.forEach(mod => mod());
  856.  
  857. // Continuously re-run specific mods methods that need to be executed on UI change
  858. const rerunObserver = new MutationObserver(() => {
  859. // If new window appears, e.g. even if window is closed and reopened, we need to rewire it
  860. // Fun fact: Some windows always exist in the DOM, even when hidden, e.g. Inventory
  861. // But some windows only exist in the DOM when open, e.g. Interaction
  862. const modsToRerun = [
  863. 'saveDraggedUIWindows',
  864. 'draggableUIWindows',
  865. 'loadDraggedUIWindowsPositions',
  866. 'selectedWindowIsTop',
  867. 'customSettings',
  868. ];
  869. modsToRerun.forEach(modName => {
  870. mods.find(mod => mod.name === modName)();
  871. });
  872. });
  873. rerunObserver.observe(document.querySelector('.layout > .container'), { attributes: false, childList: true, });
  874.  
  875. // Rerun only on chat
  876. const chatRerunObserver = new MutationObserver(() => {
  877. mods.find(mod => mod.name === 'chatContextMenu')();
  878. modHelpers.filterAllChat();
  879.  
  880. });
  881. chatRerunObserver.observe(document.querySelector('#chat'), { attributes: false, childList: true, });
  882.  
  883. // Event listeners for document.body might be kept when the game reloads, so don't reinitialize them
  884. if (!document.body.classList.contains('js-uimod-initd')) {
  885. document.body.classList.add('js-uimod-initd');
  886.  
  887. // Close chat context menu when clicking outside of it
  888. document.body.addEventListener('click', modHelpers.closeChatContextMenu);
  889. }
  890. }
  891.  
  892. // Initialize mods once UI DOM has loaded
  893. const pageObserver = new MutationObserver(() => {
  894. const isUiLoaded = !!document.querySelector('.layout');
  895. if (isUiLoaded) {
  896. initialize();
  897. }
  898. });
  899. pageObserver.observe(document.body, { attributes: true, childList: true })
  900.  
  901. // UTIL METHODS
  902. // Save to in-memory state and localStorage to retain on refresh
  903. function save(items) {
  904. localStorage.setItem(STORAGE_STATE_KEY, JSON.stringify(state));
  905. }
  906.  
  907. // Load localStorage state if it exists
  908. // NOTE: If user is trying to load unsupported version of stored state,
  909. // e.g. they just upgraded to breaking version, then we delete their stored state
  910. function load() {
  911. const storedStateJson = localStorage.getItem(STORAGE_STATE_KEY)
  912. if (storedStateJson) {
  913. const storedState = JSON.parse(storedStateJson);
  914. if (storedState.breakingVersion !== BREAKING_VERSION) {
  915. localStorage.setItem(STORAGE_STATE_KEY, JSON.stringify(state));
  916. return;
  917. }
  918. state = {
  919. ...state,
  920. ...storedState,
  921. };
  922. }
  923. }
  924.  
  925. // Nicer impl to create elements in one method call
  926. function makeElement(args) {
  927. const $node = document.createElement(args.element);
  928. if (args.class) { $node.className = args.class; }
  929. if (args.content) { $node.innerHTML = args.content; }
  930. if (args.src) { $node.src = args.src; }
  931. return $node;
  932. }
  933.  
  934. function enterTextIntoChat(text) {
  935. // Open chat input
  936. const enterEvent = new KeyboardEvent('keydown', {
  937. bubbles: true, cancelable: true, keyCode: 13
  938. });
  939. document.body.dispatchEvent(enterEvent);
  940.  
  941. // Place text into chat
  942. const $input = document.querySelector('#chatinput input');
  943. $input.value = text;
  944.  
  945. // Get chat input to recognize slash commands and change the channel
  946. // by triggering the `input` event.
  947. // (Did some debugging to figure out the channel only changes when the
  948. // svelte `input` event listener exists.)
  949. const inputEvent = new KeyboardEvent('input', {
  950. bubbles: true, cancelable: true
  951. })
  952. $input.dispatchEvent(inputEvent);
  953. }
  954.  
  955. function submitChat() {
  956. const $input = document.querySelector('#chatinput input');
  957. const kbEvent = new KeyboardEvent('keydown', {
  958. bubbles: true, cancelable: true, keyCode: 13
  959. });
  960. $input.dispatchEvent(kbEvent);
  961. }
  962.  
  963. // Credit: https://stackoverflow.com/a/14234618 (Has been slightly modified)
  964. // $draggedElement is the item that will be dragged.
  965. // $dragTrigger is the element that must be held down to drag $draggedElement
  966. function dragElement($draggedElement, $dragTrigger) {
  967. let offset = [0,0];
  968. let isDown = false;
  969. $dragTrigger.addEventListener('mousedown', function(e) {
  970. isDown = true;
  971. offset = [
  972. $draggedElement.offsetLeft - e.clientX,
  973. $draggedElement.offsetTop - e.clientY
  974. ];
  975. }, true);
  976. document.addEventListener('mouseup', function() {
  977. isDown = false;
  978. }, true);
  979.  
  980. document.addEventListener('mousemove', function(e) {
  981. event.preventDefault();
  982. if (isDown) {
  983. $draggedElement.style.left = (e.clientX + offset[0]) + 'px';
  984. $draggedElement.style.top = (e.clientY + offset[1]) + 'px';
  985. }
  986. }, true);
  987. }
  988.  
  989. // Credit: David Walsh
  990. function debounce(func, wait, immediate) {
  991. var timeout;
  992. return function() {
  993. var context = this, args = arguments;
  994. var later = function() {
  995. timeout = null;
  996. if (!immediate) func.apply(context, args);
  997. };
  998. var callNow = immediate && !timeout;
  999. clearTimeout(timeout);
  1000. timeout = setTimeout(later, wait);
  1001. if (callNow) func.apply(context, args);
  1002. };
  1003. }
  1004. })();