Hordes UI Mod

Various UI mods for Hordes.io.

目前為 2019-12-27 提交的版本,檢視 最新版本

  1. // ==UserScript==
  2. // @name Hordes UI Mod
  3. // @version 0.200
  4. // @description Various UI mods for Hordes.io.
  5. // @author Sakaiyo & Chandog#6373
  6. // @match https://hordes.io/play
  7. // @grant GM_addStyle
  8. // @namespace https://greasyfork.org/users/160017
  9. // ==/UserScript==
  10. /**
  11. * Potential Future Changes:
  12. * TODO: Add whisper chat filter
  13. * TODO: Implement inventory sorting
  14. * TODO: Remove all reliance on svelte- classes, likely breaks with updates
  15. * TODO: Add cooldown on skills (leverage skill icon URL, have a map for each skill icon mapping to its respective cooldown)
  16. * TODO: Clicking names in party to add as friends
  17. * TODO: Rotate map canvas with your direction
  18. * TODO: Move[Drag] player/target health bars
  19. * TODO: Tooltip on buffs to show name+basic effect [cant get level probably]
  20. * 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
  21. * TODO: (MAYBE, confirm if dev is ok w it) Ctrl clicking item to copy details so user can paste in chat
  22. * TODO: (MAYBE) Improved healer party frames
  23. * TODO: (MAYBE) Add toggleable option to include chat messages to right of party frame
  24. * TODO: (MAYBE) Fame/time meter
  25. * TODO: (MAYBE) EXP/time meter
  26. * TODO: (MAYBE) Heals per second meter
  27. * TODO: (MAYBE, Confirm if dev is ok w it) Alt+click to auto-equip item (emulate right click + click on Equip item in context menu)
  28. * TODO BUGFIX: If any windows are dragged outside of the screen view, they become inaccessible. We should pop it back into view automatically if it's dragged too far
  29. * TODO NOTE: For inventory sort / etc, can hover over tooltips with `element.dispatchEvent(new Event('pointerenter'))`
  30. */
  31. (function() {
  32. 'use strict';
  33.  
  34. // If this version is different from the user's stored state,
  35. // e.g. they have upgraded the version of this script and there are breaking changes,
  36. // then their stored state will be deleted.
  37. const BREAKING_VERSION = 1;
  38. const VERSION = '0.200'; // Should match version in UserScript description
  39.  
  40. const DEFAULT_CHAT_TAB_NAME = 'Untitled';
  41. const STORAGE_STATE_KEY = 'hordesio-uimodsakaiyo-state';
  42.  
  43. let state = {
  44. breakingVersion: BREAKING_VERSION,
  45. chat: {
  46. GM: true,
  47. },
  48. windowsPos: {},
  49. blockList: {},
  50. friendsList: {},
  51. mapOpacity: 70, // e.g. 70 = opacity: 0.7
  52. friendNotes: {},
  53. chatTabs: [],
  54. xpMeterState: {
  55. currentXp: 0,
  56. xpGains: [], // array of xp deltas every second
  57. averageXp: 0,
  58. gainedXp: 0,
  59. currentLvl: 0,
  60. }
  61. };
  62.  
  63. // tempState is saved only between page refreshes.
  64. const tempState = {
  65. // The last name clicked in chat
  66. chatName: null,
  67. lastMapWidth: 0,
  68. lastMapHeight: 0,
  69. xpMeterInterval: null, // tracks the interval for fetching xp data
  70. };
  71.  
  72. // UPDATING STYLES BELOW - Must be invoked in main function
  73. GM_addStyle(`
  74. /* Transparent chat bg color */
  75. .frame.svelte-1vrlsr3 {
  76. background: rgba(0,0,0,0.4);
  77. }
  78.  
  79. /* Our mod's chat message color */
  80. .textuimod {
  81. color: #00dd33;
  82. }
  83.  
  84. /* Allows windows to be moved */
  85. .window {
  86. position: relative;
  87. }
  88.  
  89. /* Allows last clicked window to appear above all other windows */
  90. .js-is-top {
  91. z-index: 9998 !important;
  92. }
  93. .panel.context:not(.commandlist) {
  94. z-index: 9999 !important;
  95. }
  96. /* The item icon being dragged in the inventory */
  97. .container.svelte-120o2pb {
  98. z-index: 9999 !important;
  99. }
  100.  
  101. /* All purpose hidden class */
  102. .js-hidden {
  103. display: none;
  104. }
  105.  
  106. /* Custom chat context menu, invisible by default */
  107. .js-chat-context-menu {
  108. display: none;
  109. }
  110.  
  111. .js-chat-context-menu .name {
  112. color: white;
  113. padding: 2px 4px;
  114. }
  115.  
  116. /* Allow names in chat to be clicked */
  117. #chat .name,
  118. .textwhisper .textf1 {
  119. pointer-events: all !important;
  120. }
  121.  
  122. /* Custom chat filter colors */
  123. .js-chat-gm {
  124. color: #a6dcd5;
  125. }
  126.  
  127. /* Class that hides chat lines */
  128. .js-line-hidden,
  129. .js-line-blocked {
  130. display: none;
  131. }
  132.  
  133. /* Enable chat & map resize */
  134. .js-chat-resize {
  135. resize: both;
  136. overflow: auto;
  137. }
  138. .js-map-resize:hover {
  139. resize: both;
  140. overflow: auto;
  141. direction: rtl;
  142. }
  143.  
  144. /* The browser resize icon */
  145. *::-webkit-resizer {
  146. background: linear-gradient(to right, rgba(51, 77, 80, 0), rgba(203, 202, 165, 0.5));
  147. border-radius: 8px;
  148. box-shadow: 0 1px 1px rgba(0,0,0,1);
  149. }
  150. *::-moz-resizer {
  151. background: linear-gradient(to right, rgba(51, 77, 80, 0), rgba(203, 202, 165, 0.5));
  152. border-radius: 8px;
  153. box-shadow: 0 1px 1px rgba(0,0,0,1);
  154. }
  155.  
  156. .js-map-btns {
  157. position: absolute;
  158. top: 46px;
  159. right: 12px;
  160. z-index: 999;
  161. width: 100px;
  162. height: 100px;
  163. text-align: right;
  164. display: none;
  165. pointer-events: all;
  166. }
  167. .js-map-btns:hover {
  168. display: block;
  169. }
  170. .js-map-btns button {
  171. border-radius: 10px;
  172. font-size: 18px;
  173. padding: 0 5px;
  174. background: rgba(0,0,0,0.4);
  175. border: 0;
  176. color: white;
  177. font-weight: bold;
  178. pointer: cursor;
  179. }
  180. /* On hover of map, show opacity controls */
  181. .js-map:hover .js-map-btns {
  182. display: block;
  183. }
  184.  
  185. /* Custom css for settings page, duplicates preexisting settings pane grid */
  186. .uimod-settings {
  187. display: grid;
  188. grid-template-columns: 2fr 3fr;
  189. grid-gap: 8px;
  190. align-items: center;
  191. max-height: 390px;
  192. margin: 0 20px;
  193. overflow-y: auto;
  194. }
  195. /* Friends list CSS, similar to settings but supports 4 columns */
  196. .uimod-friends {
  197. display: grid;
  198. grid-template-columns: 2fr 1.1fr 1.5fr 0.33fr 3fr;
  199. grid-gap: 8px;
  200. align-items: center;
  201. max-height: 390px;
  202. margin: 0 20px;
  203. overflow-y: auto;
  204. }
  205. /* Our custom window, closely mirrors main settings window */
  206. .uimod-custom-window {
  207. position: absolute;
  208. top: 100px;
  209. left: 50%;
  210. transform: translate(-50%, 0);
  211. min-width: 350px;
  212. max-width: 600px;
  213. width: 90%;
  214. height: 80%;
  215. min-height: 350px;
  216. max-height: 500px;
  217. z-index: 9;
  218. padding: 0px 10px 5px;
  219. }
  220. /* Custom chat tabs */
  221. .uimod-chat-tabs {
  222. position: fixed;
  223. margin-top: -22px;
  224. left: 5px;
  225. pointer-events: all;
  226. color: #5b858e;
  227. font-size: 12px;
  228. font-weight: bold;
  229. }
  230. .uimod-chat-tabs > div {
  231. cursor: pointer;
  232. background-color: rgba(0,0,0,0.4);
  233. border-top-right-radius: 4px;
  234. border-top-left-radius: 4px;
  235. display: inline-block;
  236. border: 1px black solid;
  237. border-bottom: 0;
  238. margin-right: 2px;
  239. padding: 3px 5px;
  240. }
  241. .uimod-chat-tabs > div:not(.js-selected-tab):hover {
  242. border-color: #aaa;
  243. }
  244. .uimod-chat-tabs > .js-selected-tab {
  245. color: #fff;
  246. }
  247.  
  248. /* Chat tab custom config */
  249. .uimod-chat-tab-config {
  250. position: absolute;
  251. z-index: 9999999;
  252. background-color: rgba(0,0,0,0.6);
  253. color: white;
  254. border-radius: 3px;
  255. text-align: center;
  256. padding: 8px 12px 8px 6px;
  257. width: 175px;
  258. font-size: 14px;
  259. border: 1px solid black;
  260. display: none;
  261. }
  262.  
  263. .uimod-chat-tab-config-grid {
  264. grid-template-columns: 35% 65%;
  265. display: grid;
  266. grid-gap: 6px;
  267. align-items: center;
  268. }
  269.  
  270. .uimod-chat-tab-config h1 {
  271. font-size: 16px;
  272. margin-top: 0;
  273. }
  274.  
  275. .uimod-chat-tab-config .btn,
  276. .uimod-chat-tab-config input {
  277. font-size: 12px;
  278. }
  279.  
  280. .container.uimod-xpmeter-1 {
  281. z-index: 6;
  282. }
  283.  
  284. .window.uimod-xpmeter-2 {
  285. padding: 5px;
  286. height: 100%;
  287. display: grid;
  288. grid-template-rows: 30px 1fr;
  289. grid-gap: 4px;
  290. transform-origin: inherit;
  291. min-width: fit-content;
  292. }
  293.  
  294. .titleframe.uimod-xpmeter-2 {
  295. line-height: 1em;
  296. display: flex;
  297. align-items: center;
  298. position: relative;
  299. letter-spacing: 0.5px;
  300. }
  301.  
  302. .titleicon.uimod-xpmeter-2 {
  303. margin: 3px;
  304. }
  305.  
  306. .title.uimod-xpmeter-2 {
  307. width: 100%;
  308. padding-left: 4px;
  309. font-weight: bold;
  310. }
  311.  
  312. .slot.uimod-xpmeter-2 {
  313. min-height: 0;
  314. }
  315.  
  316. .wrapper.uimod-xpmeter-1 {
  317. width: 200px;
  318. }
  319.  
  320. .bar.uimod-xpmeter-3 {
  321. background-color: rgba(45, 66, 71, 0.7);
  322. border-radius: 1.5px;
  323. position: relative;
  324. color: #DAE8EA;
  325. overflow: hidden;
  326. text-shadow: 1px 1px 2px #10131d;
  327. white-space: nowrap;
  328. text-transform: capitalize;
  329. font-weight: bold;
  330. }
  331.  
  332. .buttons.uimod-xpmeter-1 {
  333. line-height: 1;
  334. font-size: 13px;
  335. }
  336.  
  337. .left.uimod-xpmeter-3 {
  338. padding-left: 4px;
  339. position: relative;
  340. z-index: 1;
  341. }
  342.  
  343. .right.uimod-xpmeter-3 {
  344. position: absolute;
  345. right: 7px;
  346. z-index: 1;
  347. }
  348. `);
  349.  
  350.  
  351. const modHelpers = {
  352. // Gets the node of a tooltip for any element
  353. // Must be `await`'d to use, e.g. `await getTooltipContent($element)`
  354. getTooltipContent: async ($elementToHoverOver) => {
  355. $elementToHoverOver.dispatchEvent(new Event('pointerenter'));
  356. const closeTooltipPromise = new Promise(resolve => setTimeout(() => {
  357. resolve($elementToHoverOver.querySelector('.slotdescription').cloneNode(true));
  358. $elementToHoverOver.dispatchEvent(new Event('pointerleave'));
  359. }, 0));
  360. const $tooltip = await closeTooltipPromise;
  361. return $tooltip;
  362. },
  363.  
  364. // Automated chat command helpers
  365. // (We've been OK'd to do these by the dev - all automation like this should receive approval from the dev)
  366. whisperPlayer: playerName => {
  367. enterTextIntoChat(`/whisper ${tempState.chatName} `);
  368. },
  369. partyPlayer: playerName => {
  370. enterTextIntoChat(`/partyinvite ${tempState.chatName}`);
  371. submitChat();
  372. },
  373.  
  374. // Filters all chat based on custom filters
  375. filterAllChat: () => {
  376. // Blocked user filter
  377. Object.keys(state.blockList).forEach(blockedName => {
  378. // Get the `.name` elements from the blocked user, if we haven't already hidden their messages
  379. const $blockedChatNames = Array.from(document.querySelectorAll(`[data-chat-name="${blockedName}"]:not(.js-line-blocked)`));
  380. // Hide each of their messages
  381. $blockedChatNames.forEach($name => {
  382. // Add the class name to $name so we can track whether it's been hidden in our CSS selector $blockedChatNames
  383. $name.classList.add('js-line-blocked');
  384. const $line = $name.parentNode.parentNode.parentNode;
  385. // Add the class name to $line so we can visibly hide the entire chat line
  386. $line.classList.add('js-line-blocked');
  387. });
  388. })
  389.  
  390. // Custom channel filter
  391. Object.keys(state.chat).forEach(channel => {
  392. Array.from(document.querySelectorAll(`.text${channel}.content`)).forEach($textItem => {
  393. const $line = $textItem.parentNode.parentNode;
  394. $line.classList.toggle('js-line-hidden', !state.chat[channel]);
  395. });
  396. });
  397. },
  398.  
  399. // Makes chat context menu visible and appear under the mouse
  400. showChatContextMenu: (name, mousePos) => {
  401. // Right before we show the context menu, we want to handle showing/hiding Friend/Unfriend
  402. const $contextMenu = document.querySelector('.js-chat-context-menu');
  403. $contextMenu.querySelector('[name="friend"]').classList.toggle('js-hidden', !!state.friendsList[name]);
  404. $contextMenu.querySelector('[name="unfriend"]').classList.toggle('js-hidden', !state.friendsList[name]);
  405.  
  406. $contextMenu.querySelector('.js-name').textContent = name;
  407. $contextMenu.setAttribute('style', `display: block; left: ${mousePos.x}px; top: ${mousePos.y}px;`);
  408. },
  409.  
  410. // Close chat context menu if clicking outside of it
  411. closeChatContextMenu: clickEvent => {
  412. const $target = clickEvent.target;
  413. // If clicking on name or directly on context menu, don't close it
  414. // Still closes if clicking on context menu item
  415. if ($target.classList.contains('js-is-context-menu-initd')
  416. || $target.classList.contains('js-chat-context-menu')) {
  417. return;
  418. }
  419.  
  420. const $contextMenu = document.querySelector('.js-chat-context-menu');
  421. $contextMenu.style.display = 'none';
  422. },
  423.  
  424. friendPlayer: playerName => {
  425. if (state.friendsList[playerName]) {
  426. return;
  427. }
  428.  
  429. state.friendsList[playerName] = true;
  430. modHelpers.addChatMessage(`${playerName} has been added to your friends list.`);
  431. save();
  432. },
  433.  
  434. unfriendPlayer: playerName => {
  435. if (!state.friendsList[playerName]) {
  436. return;
  437. }
  438.  
  439. delete state.friendsList[playerName];
  440. delete state.friendNotes[playerName];
  441. modHelpers.addChatMessage(`${playerName} is no longer on your friends list.`);
  442. save();
  443. },
  444.  
  445. // Adds player to block list, to be filtered out of chat
  446. blockPlayer: playerName => {
  447. if (state.blockList[playerName]) {
  448. return;
  449. }
  450.  
  451. state.blockList[playerName] = true;
  452. modHelpers.filterAllChat();
  453. modHelpers.addChatMessage(`${playerName} has been blocked.`)
  454. save();
  455. },
  456.  
  457. // Removes player from block list and makes their messages visible again
  458. unblockPlayer: playerName => {
  459. delete state.blockList[playerName];
  460. modHelpers.addChatMessage(`${playerName} has been unblocked.`);
  461. save();
  462.  
  463. // Make messages visible again
  464. const $chatNames = Array.from(document.querySelectorAll(`.js-line-blocked[data-chat-name="${playerName}"]`));
  465. $chatNames.forEach($name => {
  466. $name.classList.remove('js-line-blocked');
  467. const $line = $name.parentNode.parentNode.parentNode;
  468. $line.classList.remove('js-line-blocked');
  469. });
  470. },
  471.  
  472. // Pushes message to chat
  473. // TODO: The margins for the message are off slightly compared to other messages - why?
  474. addChatMessage: text => {
  475. const newMessageHTML = `
  476. <div class="linewrap svelte-1vrlsr3">
  477. <span class="time svelte-1vrlsr3">00.00</span>
  478. <span class="textuimod content svelte-1vrlsr3">
  479. <span class="capitalize channel svelte-1vrlsr3">UIMod</span>
  480. </span>
  481. <span class="svelte-1vrlsr3">${text}</span>
  482. </div>
  483. `;
  484.  
  485. const element = makeElement({
  486. element: 'article',
  487. class: 'line svelte-1vrlsr3',
  488. content: newMessageHTML
  489. });
  490. document.querySelector('#chat').appendChild(element);
  491. },
  492.  
  493. // Gets current chat filters as represented in the UI
  494. // filter being true means it's invisible(filtered) in chat
  495. // filter being false means it's visible(unfiltered) in chat
  496. getCurrentChatFilters: () => {
  497. // Saved by the official game client
  498. const gameFilters = JSON.parse(localStorage.getItem('filteredChannels'));
  499. return {
  500. global: gameFilters.includes('global'),
  501. faction: gameFilters.includes('faction'),
  502. party: gameFilters.includes('party'),
  503. clan: gameFilters.includes('clan'),
  504. pvp: gameFilters.includes('pvp'),
  505. inv: gameFilters.includes('inv'),
  506. GM: !state.chat.GM, // state.chat.GM is whether or not GM chat is shown - we want whether or not GM chat should be hidden
  507. };
  508. },
  509.  
  510. // Shows the chat tab config window for a specific tab, displayed in a specific position
  511. showChatTabConfigWindow: (tabId, pos) => {
  512. const $chatTabConfig = document.querySelector('.js-chat-tab-config');
  513. const chatTab = state.chatTabs.find(tab => tab.id === tabId);
  514. // Update position and name in chat tab config
  515. $chatTabConfig.style.left = `${pos.x}px`;
  516. $chatTabConfig.style.top = `${pos.y}px`;
  517. $chatTabConfig.querySelector('.js-chat-tab-name').value = chatTab.name;
  518.  
  519. // Store tabId in state, to be used by the Remove/Add buttons in config window
  520. tempState.editedChatTabId = tabId;
  521.  
  522. // Hide remove button if only one chat tab left - can't remove last one
  523. // Show it if more than one chat tab left
  524. const chatTabCount = Object.keys(state.chatTabs).length;
  525. const $removeChatTabBtn = $chatTabConfig.querySelector('.js-remove-chat-tab');
  526. $removeChatTabBtn.style.display = chatTabCount < 2 ? 'none' : 'block';
  527.  
  528. // Show chat tab config
  529. $chatTabConfig.style.display = 'block';
  530. },
  531.  
  532. // Adds chat tab to DOM, sets it as selected
  533. // If argument chatTab is provided, will use that name+id
  534. // If no argument is provided, will create new tab name/id and add it to state
  535. // isInittingTab is optional boolean, if `true`, will _not_ set added tab as selected. Used when initializing all chat tabs on load
  536. // Returns newly added tabId
  537. addChatTab: (chatTab, isInittingTab) => {
  538. let tabName = DEFAULT_CHAT_TAB_NAME;
  539. let tabId = uuid();
  540. if (chatTab) {
  541. tabName = chatTab.name;
  542. tabId = chatTab.id;
  543. } else {
  544. // If no chat tab was provided, create it in state
  545. state.chatTabs.push({
  546. name: tabName,
  547. id: tabId,
  548. filters: modHelpers.getCurrentChatFilters(),
  549. });
  550. save();
  551. }
  552.  
  553. const $tabs = document.querySelector('.js-chat-tabs');
  554. const $tab = makeElement({
  555. element: 'div',
  556. content: tabName,
  557. });
  558. $tab.setAttribute('data-tab-id', tabId);
  559.  
  560. // Add chat tab to DOM
  561. $tabs.appendChild($tab);
  562.  
  563. // Wire chat tab up to open config on right click
  564. $tab.addEventListener('contextmenu', clickEvent => {
  565. const mousePos = { x: clickEvent.pageX, y: clickEvent.pageY };
  566. modHelpers.showChatTabConfigWindow(tabId, mousePos);
  567. });
  568. // And select chat tab on left click
  569. $tab.addEventListener('click', () => {
  570. modHelpers.selectChatTab(tabId);
  571. });
  572.  
  573. if (!isInittingTab) {
  574. // Select the newly added chat tab
  575. modHelpers.selectChatTab(tabId);
  576. }
  577.  
  578. // Returning tabId to all adding new tab to pass tab ID to `showChatTabConfigWindow`
  579. return tabId;
  580. },
  581.  
  582. // Selects chat tab [on click], updating client chat filters and custom chat filters
  583. selectChatTab: tabId => {
  584. // Remove selected class from everything, then add selected class to clicked tab
  585. Array.from(document.querySelectorAll('[data-tab-id]')).forEach($tab => {
  586. $tab.classList.remove('js-selected-tab');
  587. });
  588. const $tab = document.querySelector(`[data-tab-id="${tabId}"]`);
  589. $tab.classList.add('js-selected-tab');
  590.  
  591. const tabFilters = state.chatTabs.find(tab => tab.id === tabId).filters;
  592. // Simulating clicks on the filters to turn them on/off
  593. const $filterButtons = Array.from(document.querySelectorAll('.channelselect small'));
  594. Object.keys(tabFilters).forEach(filter => {
  595. const $filterButton = $filterButtons.find($btn => $btn.textContent === filter);
  596. const isCurrentlyFiltered = $filterButton.classList.contains('textgrey');
  597.  
  598. // If is currently filtered but filter for this tab is turned off, click it to turn filter off
  599. if (isCurrentlyFiltered && !tabFilters[filter]) {
  600. $filterButton.click();
  601. }
  602. // If it is not currently filtered but filter for this tab is turned on, click it to turn filter on
  603. if (!isCurrentlyFiltered && tabFilters[filter]) {
  604. $filterButton.click();
  605. }
  606. });
  607.  
  608. // Update state for our custom chat filters to match the tab's configuration, then filter chat for it
  609. const isGMChatVisible = !tabFilters.GM;
  610. modHelpers.setGMChatVisibility(isGMChatVisible);
  611.  
  612. // Update the selected tab in state
  613. state.selectedChatTabId = tabId;
  614. save();
  615. },
  616.  
  617. // Updates state.chat.GM and the DOM to make text white/grey depending on if gm chat is visible/filtered
  618. // Then filters chat and saves updated chat state
  619. setGMChatVisibility: isGMChatVisible => {
  620. const $chatGM = document.querySelector(`.js-chat-gm`);
  621. state.chat.GM = isGMChatVisible;
  622. $chatGM.classList.toggle('textgrey', !state.chat.GM);
  623. modHelpers.filterAllChat();
  624. save();
  625. },
  626.  
  627. getCurrentCharacterLvl: () => Number(document.querySelector('#ufplayer .bgmana > .left').textContent.split('Lv. ')[1]),
  628.  
  629. getCurrentXp: () => Number(document.querySelector('#expbar .progressBar > .left').textContent.split('/')[0].trim()),
  630.  
  631. getNextLevelXp: () => Number(document.querySelector('#expbar .progressBar > .left').textContent.split('/')[1].trim()),
  632.  
  633. // user invoked reset of xp meter stats
  634. resetXpMeterState: () => {
  635. state.xpMeterState.xpGains = []; // array of xp deltas every second
  636. state.xpMeterState.averageXp = 0;
  637. state.xpMeterState.gainedXp = 0;
  638. save();
  639. document.querySelector('.js-xp-time').textContent = '-:-:-';
  640. },
  641.  
  642. // toggle the xp meter
  643. toggleXpMeterVisibility: () => {
  644. const xpMeterContainer = document.querySelector('.js-xpmeter');
  645. xpMeterContainer.style.display = xpMeterContainer.style.display === 'none' ? 'block' : 'none';
  646. },
  647.  
  648. };
  649.  
  650. // MAIN MODS BELOW
  651. const mods = [
  652. // Creates DOM elements for custom chat filters
  653. function newChatFilters() {
  654. const $channelselect = document.querySelector('.channelselect');
  655. if (!document.querySelector(`.js-chat-gm`)) {
  656. const $gm = makeElement({
  657. element: 'small',
  658. class: `btn border black js-chat-gm ${state.chat.GM ? '' : 'textgrey'}`,
  659. content: 'GM'
  660. });
  661. $channelselect.appendChild($gm);
  662. }
  663. },
  664.  
  665. // Creates DOM elements and wires them up for custom chat tabs and chat tab config
  666. // Note: Should be done after creating new custom chat filters
  667. function customChatTabs() {
  668. // Create the chat tab configuration DOM
  669. const $chatTabConfigurator = makeElement({
  670. element: 'div',
  671. class: 'uimod-chat-tab-config js-chat-tab-config',
  672. content: `
  673. <h1>Chat Tab Config</h1>
  674. <div class="uimod-chat-tab-config-grid">
  675. <div>Name</div><input type="text" class="js-chat-tab-name" value="untitled"></input>
  676. <div class="btn orange js-remove-chat-tab">Remove</div><div class="btn blue js-save-chat-tab">Ok</div>
  677. </div>
  678. `,
  679. });
  680. document.body.append($chatTabConfigurator);
  681.  
  682. // Wire it up
  683. document.querySelector('.js-remove-chat-tab').addEventListener('click', () => {
  684. // Remove the chat tab from state
  685. const editedChatTab = state.chatTabs.find(tab => tab.id === tempState.editedChatTabId);
  686. const editedChatTabIndex = state.chatTabs.indexOf(editedChatTab);
  687. state.chatTabs.splice(editedChatTabIndex, 1);
  688.  
  689. // Remove the chat tab from DOM
  690. const $chatTab = document.querySelector(`[data-tab-id="${tempState.editedChatTabId}"]`);
  691. $chatTab.parentNode.removeChild($chatTab);
  692.  
  693. // If we just removed the currently selected chat tab
  694. if (tempState.editedChatTabId === state.selectedChatTabId) {
  695. // Select the chat tab to the left of the removed one
  696. const nextChatTabIndex = editedChatTabIndex === 0 ? 0 : editedChatTabIndex - 1;
  697. modHelpers.selectChatTab(state.chatTabs[nextChatTabIndex].id);
  698. }
  699.  
  700. // Close chat tab config
  701. document.querySelector('.js-chat-tab-config').style.display = 'none';
  702. });
  703.  
  704. document.querySelector('.js-save-chat-tab').addEventListener('click', () => {
  705. // Set new chat tab name in DOM
  706. const $chatTab = document.querySelector(`[data-tab-id="${state.selectedChatTabId}"]`);
  707. const newName = document.querySelector('.js-chat-tab-name').value;
  708. $chatTab.textContent = newName;
  709.  
  710. // Set new chat tab name in state
  711. // `selectedChatTab` is a reference on `state.chatTabs`, so updating it above still updates it in the state - we want to save that
  712. const selectedChatTab = state.chatTabs.find(tab => tab.id === state.selectedChatTabId);
  713. selectedChatTab.name = newName;
  714. save();
  715.  
  716. // Close chat tab config
  717. document.querySelector('.js-chat-tab-config').style.display = 'none';
  718. });
  719.  
  720. // Create the initial chat tabs HTML
  721. const $chat = document.querySelector('#chat');
  722. const $chatTabs = makeElement({
  723. element: 'div',
  724. class: 'uimod-chat-tabs js-chat-tabs',
  725. content: '<div class="js-chat-tab-add">+</div>',
  726. });
  727.  
  728. // Add them to the DOM
  729. $chat.parentNode.insertBefore($chatTabs, $chat);
  730.  
  731. // Add all our chat tabs from state
  732. state.chatTabs.forEach(chatTab => {
  733. const isInittingTab = true;
  734. modHelpers.addChatTab(chatTab, isInittingTab);
  735. });
  736.  
  737. // Wire up the add chat tab button
  738. document.querySelector('.js-chat-tab-add').addEventListener('click', clickEvent => {
  739. const chatTabId = modHelpers.addChatTab();
  740. const mousePos = { x: clickEvent.pageX, y: clickEvent.pageY };
  741. modHelpers.showChatTabConfigWindow(chatTabId, mousePos);
  742. });
  743.  
  744. // If initial chat tab doesn't exist, create it based off current filter settings
  745. if (!Object.keys(state.chatTabs).length) {
  746. const tabId = uuid();
  747. const chatTab = {
  748. name: 'Main',
  749. id: tabId,
  750. filters: modHelpers.getCurrentChatFilters()
  751. };
  752. state.chatTabs.push(chatTab);
  753. save();
  754. modHelpers.addChatTab(chatTab);
  755. }
  756.  
  757. // Wire up click event handlers onto the filters to update the selected chat tab's filters in state
  758. document.querySelector('.channelselect').addEventListener('click', clickEvent => {
  759. const $elementMouseIsOver = document.elementFromPoint(clickEvent.clientX, clickEvent.clientY);
  760.  
  761. // We only want to change the filters if the user manually clicks the filter button
  762. // If they clicked a chat tab and we programatically set filters, we don't want to update
  763. // the current tab's filter state
  764. if (!$elementMouseIsOver.classList.contains('btn')) {
  765. return;
  766. }
  767. const selectedChatTab = state.chatTabs.find(tab => tab.id === state.selectedChatTabId);
  768. selectedChatTab.filters = modHelpers.getCurrentChatFilters();
  769. save();
  770. });
  771.  
  772. // Select the currently selected tab in state on mod initialization
  773. if (state.selectedChatTabId) {
  774. modHelpers.selectChatTab(state.selectedChatTabId);
  775. }
  776. },
  777.  
  778. // Wire up new chat buttons to toggle in state+ui
  779. function newChatFilterButtons() {
  780. const $chatGM = document.querySelector(`.js-chat-gm`);
  781. $chatGM.addEventListener('click', () => {
  782. modHelpers.setGMChatVisibility(!state.chat.GM);
  783. });
  784. },
  785.  
  786. // Filter out chat in UI based on chat buttons state
  787. function filterChatObserver() {
  788. const chatObserver = new MutationObserver(modHelpers.filterAllChat);
  789. chatObserver.observe(document.querySelector('#chat'), { attributes: true, childList: true });
  790. },
  791.  
  792. // Drag all windows by their header
  793. function draggableUIWindows() {
  794. Array.from(document.querySelectorAll('.window:not(.js-can-move)')).forEach($window => {
  795. $window.classList.add('js-can-move');
  796. dragElement($window, $window.querySelector('.titleframe'));
  797. });
  798. },
  799.  
  800. // Save dragged UI windows position to state
  801. function saveDraggedUIWindows() {
  802. Array.from(document.querySelectorAll('.window:not(.js-window-is-saving)')).forEach($window => {
  803. $window.classList.add('js-window-is-saving');
  804. const $draggableTarget = $window.querySelector('.titleframe');
  805. const windowName = $draggableTarget.querySelector('[name="title"]').textContent;
  806. $draggableTarget.addEventListener('mouseup', () => {
  807. state.windowsPos[windowName] = $window.getAttribute('style');
  808. save();
  809. });
  810. });
  811. },
  812.  
  813. // Loads draggable UI windows position from state
  814. function loadDraggedUIWindowsPositions() {
  815. Array.from(document.querySelectorAll('.window:not(.js-has-loaded-pos)')).forEach($window => {
  816. $window.classList.add('js-has-loaded-pos');
  817. const windowName = $window.querySelector('[name="title"]').textContent;
  818. const pos = state.windowsPos[windowName];
  819. if (pos) {
  820. $window.setAttribute('style', pos);
  821. }
  822. });
  823. },
  824.  
  825. // Makes chat resizable
  826. function resizableChat() {
  827. // Add the appropriate classes
  828. const $chatContainer = document.querySelector('#chat').parentNode;
  829. $chatContainer.classList.add('js-chat-resize');
  830.  
  831. // Load initial chat and map size
  832. if (state.chatWidth && state.chatHeight) {
  833. $chatContainer.style.width = state.chatWidth;
  834. $chatContainer.style.height = state.chatHeight;
  835. }
  836.  
  837. // Save chat size on resize
  838. const resizeObserverChat = new ResizeObserver(() => {
  839. const chatWidthStr = window.getComputedStyle($chatContainer, null).getPropertyValue('width');
  840. const chatHeightStr = window.getComputedStyle($chatContainer, null).getPropertyValue('height');
  841. state.chatWidth = chatWidthStr;
  842. state.chatHeight = chatHeightStr;
  843. save();
  844. });
  845. resizeObserverChat.observe($chatContainer);
  846. },
  847.  
  848. // Makes map resizable
  849. function resizeableMap() {
  850. const $map = document.querySelector('.container canvas').parentNode;
  851. const $canvas = $map.querySelector('canvas');
  852. $map.classList.add('js-map-resize');
  853.  
  854. // Track whether we're clicking (resizing) map or not
  855. // Used to detect if resize changes are manually done, or from minimizing/maximizing map (with [M])
  856. $map.addEventListener('mousedown', () => {
  857. tempState.clickingMap = true;
  858. });
  859. // Sometimes the mouseup event may be registered outside of the map - we account for this
  860. document.body.addEventListener('mouseup', () => {
  861. tempState.clickingMap = false;
  862. });
  863.  
  864. const onMapResize = () => {
  865. // Get real values of map height/width, excluding padding/margin/etc
  866. const mapWidthStr = window.getComputedStyle($map, null).getPropertyValue('width');
  867. const mapHeightStr = window.getComputedStyle($map, null).getPropertyValue('height');
  868. const mapWidth = Number(mapWidthStr.slice(0, -2));
  869. const mapHeight = Number(mapHeightStr.slice(0, -2));
  870.  
  871. // If height/width are 0 or unset, don't resize canvas
  872. if (!mapWidth || !mapHeight) {
  873. return;
  874. }
  875.  
  876. if ($canvas.width !== mapWidth) {
  877. $canvas.width = mapWidth;
  878. }
  879.  
  880. if ($canvas.height !== mapHeight) {
  881. $canvas.height = mapHeight;
  882. }
  883.  
  884. // If we're clicking map, i.e. manually resizing, then save state
  885. // Don't save state when minimizing/maximizing map via [M]
  886. if (tempState.clickingMap) {
  887. state.mapWidth = mapWidthStr;
  888. state.mapHeight = mapHeightStr;
  889. save();
  890. } else {
  891. const isMaximized = mapWidth > tempState.lastMapWidth && mapHeight > tempState.lastMapHeight;
  892. if (!isMaximized) {
  893. $map.style.width = state.mapWidth;
  894. $map.style.height = state.mapHeight;
  895. }
  896. }
  897.  
  898. // Store last map width/height in temp state, so we know if we've minimized or maximized
  899. tempState.lastMapWidth = mapWidth;
  900. tempState.lastMapHeight = mapHeight;
  901. };
  902.  
  903. if (state.mapWidth && state.mapHeight) {
  904. $map.style.width = state.mapWidth;
  905. $map.style.height = state.mapHeight;
  906. onMapResize(); // Update canvas size on initial load of saved map size
  907. }
  908.  
  909. // On resize of map, resize canvas to match
  910. const resizeObserverMap = new ResizeObserver(onMapResize);
  911. resizeObserverMap.observe($map);
  912.  
  913. // We need to observe canvas resizes to tell when the user presses M to open the big map
  914. // At that point, we resize the map to match the canvas
  915. const triggerResize = () => {
  916. // Get real values of map height/width, excluding padding/margin/etc
  917. const mapWidthStr = window.getComputedStyle($map, null).getPropertyValue('width');
  918. const mapHeightStr = window.getComputedStyle($map, null).getPropertyValue('height');
  919. const mapWidth = Number(mapWidthStr.slice(0, -2));
  920. const mapHeight = Number(mapHeightStr.slice(0, -2));
  921.  
  922. // If height/width are 0 or unset, we don't care about resizing yet
  923. if (!mapWidth || !mapHeight) {
  924. return;
  925. }
  926.  
  927. if ($canvas.width !== mapWidth) {
  928. $map.style.width = `${$canvas.width}px`;
  929. }
  930.  
  931. if ($canvas.height !== mapHeight) {
  932. $map.style.height = `${$canvas.height}px`;
  933. }
  934. };
  935.  
  936. // We debounce the canvas resize, so it doesn't resize every single
  937. // pixel you move when resizing the DOM. If this were to happen,
  938. // resizing would constantly be interrupted. You'd have to resize a tiny bit,
  939. // lift left click, left click again to resize a tiny bit more, etc.
  940. // Resizing is smooth when we debounce this canvas.
  941. const debouncedTriggerResize = debounce(triggerResize, 50);
  942. const resizeObserverCanvas = new ResizeObserver(debouncedTriggerResize);
  943. resizeObserverCanvas.observe($canvas);
  944. },
  945.  
  946. function mapControls() {
  947. const $map = document.querySelector('.container canvas');
  948. if (!$map.parentNode.classList.contains('js-map')) {
  949. $map.parentNode.classList.add('js-map');
  950. }
  951. const $mapContainer = document.querySelector('.js-map');
  952.  
  953. // On load, update map opacity to match state
  954. // We modify the opacity of the canvas and the background color alpha of the parent container
  955. // We do this to allow our opacity buttons to be visible on hover with 100% opacity
  956. // (A surprisingly difficult enough task to require this implementation)
  957. const updateMapOpacity = () => {
  958. $map.style.opacity = String(state.mapOpacity / 100);
  959. const mapContainerBgColor = window.getComputedStyle($mapContainer, null).getPropertyValue('background-color');
  960. // Credit for this regexp + This opacity+rgba dual implementation: https://stackoverflow.com/questions/16065998/replacing-changing-alpha-in-rgba-javascript
  961. let opacity = state.mapOpacity / 100;
  962. // This is a slightly lazy browser workaround to fix a bug.
  963. // If the opacity is `1`, and it sets `rgba` to `1`, then the browser changes the
  964. // rgba to rgb, dropping the alpha. We could account for that and add the `alpha` back in
  965. // later, but setting the max opacity to very close to 1 makes sure the issue never crops up.
  966. // Fun fact: 0.99 retains the alpha, but setting this to 0.999 still causes the browser to drop the alpha. Rude.
  967. if (opacity === 1) {
  968. opacity = 0.99;
  969. }
  970. const newBgColor = mapContainerBgColor.replace(/[\d\.]+\)$/g, `${opacity})`);
  971. $mapContainer.style['background-color'] = newBgColor;
  972.  
  973. // Update the button opacity
  974. const $addBtn = document.querySelector('.js-map-opacity-add');
  975. const $minusBtn = document.querySelector('.js-map-opacity-minus');
  976. // Hide plus button if the opacity is max
  977. if (state.mapOpacity === 100) {
  978. $addBtn.style.visibility = 'hidden';
  979. } else {
  980. $addBtn.style.visibility = 'visible';
  981. }
  982. // Hide minus button if the opacity is lowest
  983. if (state.mapOpacity === 0) {
  984. $minusBtn.style.visibility = 'hidden';
  985. } else {
  986. $minusBtn.style.visibility = 'visible';
  987. }
  988. };
  989.  
  990. const $mapButtons = makeElement({
  991. element: 'div',
  992. class: 'js-map-btns',
  993. content: `
  994. <button class="js-map-opacity-add">+</button>
  995. <button class="js-map-opacity-minus">-</button>
  996. <button class="js-map-reset">r</button>
  997. `,
  998. });
  999.  
  1000. // Add it right before the map container div
  1001. $map.parentNode.insertBefore($mapButtons, $map);
  1002.  
  1003. const $addBtn = document.querySelector('.js-map-opacity-add');
  1004. const $minusBtn = document.querySelector('.js-map-opacity-minus');
  1005. const $resetBtn = document.querySelector('.js-map-reset');
  1006. // Hide the buttons if map opacity is maxed/minimum
  1007. if (state.mapOpacity === 100) {
  1008. $addBtn.style.visibility = 'hidden';
  1009. }
  1010. if (state.mapOpacity === 0) {
  1011. $minusBtn.style.visibility = 'hidden';
  1012. }
  1013.  
  1014. // Wire it up
  1015. $addBtn.addEventListener('click', clickEvent => {
  1016. // Update opacity
  1017. state.mapOpacity += 10;
  1018. save();
  1019. updateMapOpacity();
  1020. });
  1021.  
  1022. $minusBtn.addEventListener('click', clickEvent => {
  1023. // Update opacity
  1024. state.mapOpacity -= 10;
  1025. save();
  1026. updateMapOpacity();
  1027. });
  1028.  
  1029. $resetBtn.addEventListener('click', clickEvent => {
  1030. state.mapOpacity = 70;
  1031. state.mapWidth = '174px';
  1032. state.mapHeight = '174px';
  1033. save();
  1034. updateMapOpacity();
  1035. $mapContainer.style.width = state.mapWidth;
  1036. $mapContainer.style.height = state.mapHeight;
  1037. });
  1038.  
  1039. updateMapOpacity();
  1040. },
  1041.  
  1042. // The last clicked UI window displays above all other UI windows
  1043. // This is useful when, for example, your inventory is near the market window,
  1044. // and you want the window and the tooltips to display above the market window.
  1045. function selectedWindowIsTop() {
  1046. Array.from(document.querySelectorAll('.window:not(.js-is-top-initd)')).forEach($window => {
  1047. $window.classList.add('js-is-top-initd');
  1048.  
  1049. $window.addEventListener('mousedown', () => {
  1050. // First, make the other is-top window not is-top
  1051. const $otherWindowContainer = document.querySelector('.js-is-top');
  1052. if ($otherWindowContainer) {
  1053. $otherWindowContainer.classList.remove('js-is-top');
  1054. }
  1055.  
  1056. // Then, make our window's container (the z-index container) is-top
  1057. $window.parentNode.classList.add('js-is-top');
  1058. });
  1059. });
  1060. },
  1061.  
  1062. // The F icon and the UI that appears when you click it
  1063. function customFriendsList() {
  1064. var friendsIconElement = makeElement({
  1065. element: 'div',
  1066. class: 'btn border black js-friends-list-icon',
  1067. content: 'F',
  1068. });
  1069. // Add the icon to the right of Elixir icon
  1070. const $elixirIcon = document.querySelector('#sysgem');
  1071. $elixirIcon.parentNode.insertBefore(friendsIconElement, $elixirIcon.nextSibling);
  1072.  
  1073. // Create the friends list UI
  1074. document.querySelector('.js-friends-list-icon').addEventListener('click', () => {
  1075. if (document.querySelector('.js-friends-list')) {
  1076. // Don't open the friends list twice.
  1077. return;
  1078. }
  1079. let friendsListHTML = '';
  1080. Object.keys(state.friendsList).sort().forEach(friendName => {
  1081. friendsListHTML += `
  1082. <div data-player-name="${friendName}">${friendName}</div>
  1083. <div class="btn blue js-whisper-player" data-player-name="${friendName}">Whisper</div>
  1084. <div class="btn blue js-party-player" data-player-name="${friendName}">Party invite</div>
  1085. <div class="btn orange js-unfriend-player" data-player-name="${friendName}">X</div>
  1086. <input type="text" class="js-friend-note" data-player-name="${friendName}" value="${state.friendNotes[friendName] || ''}"></input>
  1087. `;
  1088. });
  1089.  
  1090. const customFriendsWindowHTML = `
  1091. <h3 class="textprimary">Friends list</h3>
  1092. <div class="uimod-friends">${friendsListHTML}</div>
  1093. <p></p>
  1094. <div class="btn purp js-close-custom-friends-list">Close</div>
  1095. `;
  1096.  
  1097. const $customFriendsList = makeElement({
  1098. element: 'div',
  1099. class: 'menu panel-black js-friends-list uimod-custom-window',
  1100. content: customFriendsWindowHTML,
  1101. });
  1102. document.body.appendChild($customFriendsList);
  1103.  
  1104. // Wire up the buttons
  1105. Array.from(document.querySelectorAll('.js-whisper-player')).forEach($button => {
  1106. $button.addEventListener('click', clickEvent => {
  1107. const name = clickEvent.target.getAttribute('data-player-name');
  1108. modHelpers.whisperPlayer(name);
  1109. });
  1110. });
  1111. Array.from(document.querySelectorAll('.js-party-player')).forEach($button => {
  1112. $button.addEventListener('click', clickEvent => {
  1113. const name = clickEvent.target.getAttribute('data-player-name');
  1114. modHelpers.partyPlayer(name);
  1115. });
  1116. });
  1117. Array.from(document.querySelectorAll('.js-unfriend-player')).forEach($button => {
  1118. $button.addEventListener('click', clickEvent => {
  1119. const name = clickEvent.target.getAttribute('data-player-name');
  1120. modHelpers.unfriendPlayer(name);
  1121.  
  1122. // Remove the blocked player from the list
  1123. Array.from(document.querySelectorAll(`.js-friends-list [data-player-name="${name}"]`)).forEach($element => {
  1124. $element.parentNode.removeChild($element);
  1125. });
  1126. });
  1127. });
  1128. Array.from(document.querySelectorAll('.js-friend-note')).forEach($element => {
  1129. $element.addEventListener('change', clickEvent => {
  1130. const name = clickEvent.target.getAttribute('data-player-name');
  1131. state.friendNotes[name] = clickEvent.target.value;
  1132. });
  1133. })
  1134.  
  1135. // The close button for our custom UI
  1136. document.querySelector('.js-close-custom-friends-list').addEventListener('click', () => {
  1137. const $friendsListWindow = document.querySelector('.js-friends-list');
  1138. $friendsListWindow.parentNode.removeChild($friendsListWindow);
  1139. });
  1140. });
  1141. },
  1142.  
  1143. // Custom settings UI, currently just Blocked players
  1144. function customSettings() {
  1145. const $settings = document.querySelector('.divide:not(.js-settings-initd)');
  1146. if (!$settings) {
  1147. return;
  1148. }
  1149.  
  1150. $settings.classList.add('js-settings-initd');
  1151. const $settingsChoiceList = $settings.querySelector('.choice').parentNode;
  1152. $settingsChoiceList.appendChild(makeElement({
  1153. element: 'div',
  1154. class: 'choice js-blocked-players',
  1155. content: 'Blocked players',
  1156. }));
  1157.  
  1158. // Upon click, we display our custom settings window UI
  1159. document.querySelector('.js-blocked-players').addEventListener('click', () => {
  1160. let blockedPlayersHTML = '';
  1161. Object.keys(state.blockList).sort().forEach(blockedName => {
  1162. blockedPlayersHTML += `
  1163. <div data-player-name="${blockedName}">${blockedName}</div>
  1164. <div class="btn orange js-unblock-player" data-player-name="${blockedName}">Unblock player</div>
  1165. `;
  1166. });
  1167.  
  1168. const customSettingsHTML = `
  1169. <h3 class="textprimary">Blocked players</h3>
  1170. <div class="settings uimod-settings">${blockedPlayersHTML}</div>
  1171. <p></p>
  1172. <div class="btn purp js-close-custom-settings">Close</div>
  1173. `;
  1174.  
  1175. const $customSettings = makeElement({
  1176. element: 'div',
  1177. class: 'menu panel-black js-custom-settings uimod-custom-window',
  1178. content: customSettingsHTML,
  1179. });
  1180. document.body.appendChild($customSettings);
  1181.  
  1182. // Wire up all the unblock buttons
  1183. Array.from(document.querySelectorAll('.js-unblock-player')).forEach($button => {
  1184. $button.addEventListener('click', clickEvent => {
  1185. const name = clickEvent.target.getAttribute('data-player-name');
  1186. modHelpers.unblockPlayer(name);
  1187.  
  1188. // Remove the blocked player from the list
  1189. Array.from(document.querySelectorAll(`.js-custom-settings [data-player-name="${name}"]`)).forEach($element => {
  1190. $element.parentNode.removeChild($element);
  1191. });
  1192. });
  1193. });
  1194. // And the close button for our custom UI
  1195. document.querySelector('.js-close-custom-settings').addEventListener('click', () => {
  1196. const $customSettingsWindow = document.querySelector('.js-custom-settings');
  1197. $customSettingsWindow.parentNode.removeChild($customSettingsWindow);
  1198. });
  1199. });
  1200. },
  1201.  
  1202. // This creates the initial chat context menu DOM (which starts as hidden)
  1203. function createChatContextMenu() {
  1204. if (document.querySelector('.js-chat-context-menu')) {
  1205. return;
  1206. }
  1207.  
  1208. let contextMenuHTML = `
  1209. <div class="js-name">...</div>
  1210. <div class="choice" name="party">Party invite</div>
  1211. <div class="choice" name="whisper">Whisper</div>
  1212. <div class="choice" name="friend">Friend</div>
  1213. <div class="choice" name="unfriend">Unfriend</div>
  1214. <div class="choice" name="block">Block</div>
  1215. `
  1216. document.body.appendChild(makeElement({
  1217. element: 'div',
  1218. class: 'panel context border grey js-chat-context-menu',
  1219. content: contextMenuHTML,
  1220. }));
  1221.  
  1222. const $chatContextMenu = document.querySelector('.js-chat-context-menu');
  1223. $chatContextMenu.querySelector('[name="party"]').addEventListener('click', () => {
  1224. modHelpers.partyPlayer(tempState.chatName);
  1225. });
  1226. $chatContextMenu.querySelector('[name="whisper"]').addEventListener('click', () => {
  1227. modHelpers.whisperPlayer(tempState.chatName);
  1228. });
  1229. $chatContextMenu.querySelector('[name="friend"]').addEventListener('click', () => {
  1230. modHelpers.friendPlayer(tempState.chatName);
  1231. });
  1232. $chatContextMenu.querySelector('[name="unfriend"]').addEventListener('click', () => {
  1233. modHelpers.unfriendPlayer(tempState.chatName);
  1234. });
  1235. $chatContextMenu.querySelector('[name="block"]').addEventListener('click', () => {
  1236. modHelpers.blockPlayer(tempState.chatName);
  1237. });
  1238. },
  1239.  
  1240. // This opens a context menu when you click a user's name in chat
  1241. function chatContextMenu() {
  1242. const addContextMenu = ($name, name) => {
  1243. $name.classList.add('js-is-context-menu-initd');
  1244. // Add name to element so we can target it in CSS when filtering chat for block list
  1245. $name.setAttribute('data-chat-name', name);
  1246.  
  1247. const showContextMenu = clickEvent => {
  1248. // TODO: Is there a way to pass the name to showChatContextMenumethod, instead of storing in tempState?
  1249. tempState.chatName = name;
  1250. modHelpers.showChatContextMenu(name, { x: clickEvent.pageX, y: clickEvent.pageY });
  1251. };
  1252. $name.addEventListener('click', showContextMenu); // Left click
  1253. $name.addEventListener('contextmenu', showContextMenu); // Right click works too
  1254. };
  1255. Array.from(document.querySelectorAll('#chat .name:not(.js-is-context-menu-initd)')).forEach(($name) => {
  1256. addContextMenu($name, $name.textContent);
  1257. });
  1258. Array.from(document.querySelectorAll('.textwhisper .textf1:not(.js-is-context-menu-initd)')).forEach($whisperName => {
  1259. // $whisperName's textContent is "to [name]" or "from [name]", so we cut off the first word
  1260. let name = $whisperName.textContent.split(' ');
  1261. name.shift(); // Remove the first word
  1262. name = name.join(' ');
  1263. addContextMenu($whisperName, name);
  1264. });
  1265. },
  1266.  
  1267. // Adds XP Meter DOM icon and window, starts continuous interval to get current xp over time
  1268. function xpMeter() {
  1269. const $layoutContainer = document.querySelector('body > div.layout > div.container:nth-child(1)');
  1270. const $dpsMeterToggleElement = document.querySelector('#systrophy');
  1271. const $xpMeterToggleElement = makeElement({ element: 'div', class: 'js-sysxp js-xpmeter-icon btn border black', content: 'XP' });
  1272.  
  1273. const xpMeterHTMLString = `
  1274. <div class="l-corner-lr container uimod-xpmeter-1 js-xpmeter" style="display: none">
  1275. <div class="window panel-black uimod-xpmeter-2">
  1276. <div class="titleframe uimod-xpmeter-2">
  1277. <img src="/assets/ui/icons/trophy.svg?v=3282286" class="titleicon svgicon uimod-xpmeter-2">
  1278. <div class="textprimary title uimod-xpmeter-2">
  1279. <div name="title">Experience / XP</div>
  1280. </div>
  1281. <img src="/assets/ui/icons/cross.svg?v=3282286" class="js-xpmeter-close-icon btn black svgicon">
  1282. </div>
  1283. <div class="slot uimod-xpmeter-2" style="">
  1284. <div class="wrapper uimod-xpmeter-1">
  1285. <div class="bar uimod-xpmeter-3" style="z-index: 0;">
  1286. <div class="progressBar bgc1 uimod-xpmeter-3" style="width: 100%; font-size: 1em;">
  1287. <span class="left uimod-xpmeter-3">XP per minute:</span>
  1288. <span class="right uimod-xpmeter-3 js-xpm">-</span>
  1289. </div>
  1290. <div class="progressBar bgc1 uimod-xpmeter-3" style="width: 100%; font-size: 1em;">
  1291. <span class="left uimod-xpmeter-3">XP per hour:</span>
  1292. <span class="right uimod-xpmeter-3 js-xph">-</span>
  1293. </div>
  1294. <div class="progressBar bgc1 uimod-xpmeter-3" style="width: 100%; font-size: 1em;">
  1295. <span class="left uimod-xpmeter-3">XP Gained:</span>
  1296. <span class="right uimod-xpmeter-3 js-xpg">-</span>
  1297. </div>
  1298. <div class="progressBar bgc1 uimod-xpmeter-3" style="width: 100%; font-size: 1em;">
  1299. <span class="left uimod-xpmeter-3">XP Left:</span>
  1300. <span class="right uimod-xpmeter-3 js-xpl">-</span>
  1301. </div>
  1302. <div class="progressBar bgc1 uimod-xpmeter-3" style="width: 100%; font-size: 1em;">
  1303. <span class="left uimod-xpmeter-3">Session Time: </span>
  1304. <span class="right uimod-xpmeter-3 js-xp-s-time">-</span>
  1305. </div>
  1306. <div class="progressBar bgc1 uimod-xpmeter-3" style="width: 100%; font-size: 1em;">
  1307. <span class="left uimod-xpmeter-3">Time to lvl: </span>
  1308. <span class="right uimod-xpmeter-3 js-xp-time">-</span>
  1309. </div>
  1310. </div>
  1311. </div>
  1312. <div class="grid buttons marg-top uimod-xpmeter-1 js-xpmeter-reset-button">
  1313. <div class="btn grey">Reset</div>
  1314. </div>
  1315. </div>
  1316. </div>
  1317. </div>
  1318. `;
  1319.  
  1320. $dpsMeterToggleElement.parentNode.insertBefore($xpMeterToggleElement, $dpsMeterToggleElement.nextSibling);
  1321.  
  1322. const $xpMeterElement = makeElement({ element: 'div', content: xpMeterHTMLString.trim() })
  1323. $layoutContainer.appendChild($xpMeterElement.firstChild);
  1324.  
  1325. // Wire up icon and xpmeter window
  1326. document.querySelector('.js-sysxp').addEventListener('click', modHelpers.toggleXpMeterVisibility);
  1327. document.querySelector('.js-xpmeter-close-icon').addEventListener('click', modHelpers.toggleXpMeterVisibility);
  1328. document.querySelector('.js-xpmeter-reset-button').addEventListener('click', modHelpers.resetXpMeterState);
  1329.  
  1330. state.xpMeterState.currentXp = modHelpers.getCurrentXp();
  1331. state.xpMeterState.currentLvl = modHelpers.getCurrentCharacterLvl();
  1332. save();
  1333.  
  1334. if (tempState.xpMeterInterval) clearInterval(tempState.xpMeterInterval);
  1335.  
  1336. // every second we run the operations for xp meter, update xps, calc delta, etc
  1337. tempState.xpMeterInterval = setInterval(() => {
  1338. if (!document.querySelector('#expbar')) {
  1339. return;
  1340. }
  1341.  
  1342. const currentXp = modHelpers.getCurrentXp();
  1343. const nextLvlXp = modHelpers.getNextLevelXp();
  1344. const currentLvl = modHelpers.getCurrentCharacterLvl();
  1345.  
  1346. state.xpMeterState.gainedXp += currentXp - state.xpMeterState.currentXp;
  1347. state.xpMeterState.xpGains.push(currentXp - state.xpMeterState.currentXp); // array of xp deltas every second
  1348. state.xpMeterState.currentXp = currentXp;
  1349. state.xpMeterState.averageXp = state.xpMeterState.xpGains.reduce((a, b) => a + b) / state.xpMeterState.xpGains.length;
  1350. save();
  1351.  
  1352. if (document.querySelector('.js-xpmeter')) {
  1353. document.querySelector('.js-xpm').textContent = parseInt((state.xpMeterState.averageXp * 60).toFixed(0)).toLocaleString();
  1354. document.querySelector('.js-xph').textContent = parseInt((state.xpMeterState.averageXp * 60 * 60).toFixed(0)).toLocaleString();
  1355. document.querySelector('.js-xpg').textContent = state.xpMeterState.gainedXp.toLocaleString();
  1356. document.querySelector('.js-xpl').textContent = (nextLvlXp - currentXp).toLocaleString();
  1357. document.querySelector('.js-xp-s-time').textContent = msToString(state.xpMeterState.xpGains.length * 1000)
  1358. // need a positive integer for averageXp to calc time left
  1359. if (state.xpMeterState.averageXp > 0) document.querySelector('.js-xp-time').textContent = msToString((nextLvlXp - currentXp) / state.xpMeterState.averageXp * 1000);
  1360. }
  1361.  
  1362. if (state.xpMeterState.currentLvl < currentLvl) {
  1363. modHelpers.resetXpMeterState();
  1364. state.xpMeterState.currentLvl = currentLvl;
  1365. save();
  1366. }
  1367. }, 1000);
  1368. }
  1369. ];
  1370.  
  1371. // Add new DOM, load our stored state, wire it up, then continuously rerun specific methods whenever UI changes
  1372. function initialize() {
  1373. // If the Hordes.io tab isn't active for long enough, it reloads the entire page, clearing this mod
  1374. // We check for that and reinitialize the mod if that happens
  1375. const $layout = document.querySelector('.layout');
  1376. if ($layout.classList.contains('uimod-initd')) {
  1377. return;
  1378. }
  1379.  
  1380. modHelpers.addChatMessage(`Hordes UI Mod v${VERSION} by Sakaiyo has been initialized.`);
  1381.  
  1382. $layout.classList.add('uimod-initd')
  1383. load();
  1384. mods.forEach(mod => mod());
  1385.  
  1386. // Continuously re-run specific mods methods that need to be executed on UI change
  1387. const rerunObserver = new MutationObserver(() => {
  1388. // If new window appears, e.g. even if window is closed and reopened, we need to rewire it
  1389. // Fun fact: Some windows always exist in the DOM, even when hidden, e.g. Inventory
  1390. // But some windows only exist in the DOM when open, e.g. Interaction
  1391. const modsToRerun = [
  1392. 'saveDraggedUIWindows',
  1393. 'draggableUIWindows',
  1394. 'loadDraggedUIWindowsPositions',
  1395. 'selectedWindowIsTop',
  1396. 'customSettings',
  1397. ];
  1398. modsToRerun.forEach(modName => {
  1399. mods.find(mod => mod.name === modName)();
  1400. });
  1401. });
  1402. rerunObserver.observe(document.querySelector('.layout > .container'), { attributes: false, childList: true, });
  1403.  
  1404. // Rerun only on chat
  1405. const chatRerunObserver = new MutationObserver(() => {
  1406. mods.find(mod => mod.name === 'chatContextMenu')();
  1407. modHelpers.filterAllChat();
  1408.  
  1409. });
  1410. chatRerunObserver.observe(document.querySelector('#chat'), { attributes: false, childList: true, });
  1411.  
  1412. // Event listeners for document.body might be kept when the game reloads, so don't reinitialize them
  1413. if (!document.body.classList.contains('js-uimod-initd')) {
  1414. document.body.classList.add('js-uimod-initd');
  1415.  
  1416. // Close chat context menu when clicking outside of it
  1417. document.body.addEventListener('click', modHelpers.closeChatContextMenu);
  1418. }
  1419. }
  1420.  
  1421. // Initialize mods once UI DOM has loaded
  1422. const pageObserver = new MutationObserver(() => {
  1423. const isUiLoaded = !!document.querySelector('.layout');
  1424. if (isUiLoaded) {
  1425. initialize();
  1426. }
  1427. });
  1428. pageObserver.observe(document.body, { attributes: true, childList: true })
  1429.  
  1430. // UTIL METHODS
  1431. // Save to in-memory state and localStorage to retain on refresh
  1432. function save(items) {
  1433. localStorage.setItem(STORAGE_STATE_KEY, JSON.stringify(state));
  1434. }
  1435.  
  1436. // Load localStorage state if it exists
  1437. // NOTE: If user is trying to load unsupported version of stored state,
  1438. // e.g. they just upgraded to breaking version, then we delete their stored state
  1439. function load() {
  1440. const storedStateJson = localStorage.getItem(STORAGE_STATE_KEY)
  1441. if (storedStateJson) {
  1442. const storedState = JSON.parse(storedStateJson);
  1443. if (storedState.breakingVersion !== BREAKING_VERSION) {
  1444. localStorage.setItem(STORAGE_STATE_KEY, JSON.stringify(state));
  1445. return;
  1446. }
  1447. state = {
  1448. ...state,
  1449. ...storedState,
  1450. };
  1451. }
  1452. }
  1453.  
  1454. // Nicer impl to create elements in one method call
  1455. function makeElement(args) {
  1456. const $node = document.createElement(args.element);
  1457. if (args.class) { $node.className = args.class; }
  1458. if (args.content) { $node.innerHTML = args.content; }
  1459. if (args.src) { $node.src = args.src; }
  1460. return $node;
  1461. }
  1462.  
  1463. function enterTextIntoChat(text) {
  1464. // Open chat input
  1465. const enterEvent = new KeyboardEvent('keydown', {
  1466. bubbles: true, cancelable: true, keyCode: 13
  1467. });
  1468. document.body.dispatchEvent(enterEvent);
  1469.  
  1470. // Place text into chat
  1471. const $input = document.querySelector('#chatinput input');
  1472. $input.value = text;
  1473.  
  1474. // Get chat input to recognize slash commands and change the channel
  1475. // by triggering the `input` event.
  1476. // (Did some debugging to figure out the channel only changes when the
  1477. // svelte `input` event listener exists.)
  1478. const inputEvent = new KeyboardEvent('input', {
  1479. bubbles: true, cancelable: true
  1480. })
  1481. $input.dispatchEvent(inputEvent);
  1482. }
  1483.  
  1484. function submitChat() {
  1485. const $input = document.querySelector('#chatinput input');
  1486. const kbEvent = new KeyboardEvent('keydown', {
  1487. bubbles: true, cancelable: true, keyCode: 13
  1488. });
  1489. $input.dispatchEvent(kbEvent);
  1490. }
  1491.  
  1492. // Credit: https://stackoverflow.com/a/14234618 (Has been slightly modified)
  1493. // $draggedElement is the item that will be dragged.
  1494. // $dragTrigger is the element that must be held down to drag $draggedElement
  1495. function dragElement($draggedElement, $dragTrigger) {
  1496. let offset = [0, 0];
  1497. let isDown = false;
  1498. $dragTrigger.addEventListener('mousedown', function(e) {
  1499. isDown = true;
  1500. offset = [
  1501. $draggedElement.offsetLeft - e.clientX,
  1502. $draggedElement.offsetTop - e.clientY
  1503. ];
  1504. }, true);
  1505. document.addEventListener('mouseup', function() {
  1506. isDown = false;
  1507. }, true);
  1508.  
  1509. document.addEventListener('mousemove', function(e) {
  1510. event.preventDefault();
  1511. if (isDown) {
  1512. $draggedElement.style.left = (e.clientX + offset[0]) + 'px';
  1513. $draggedElement.style.top = (e.clientY + offset[1]) + 'px';
  1514. }
  1515. }, true);
  1516. }
  1517.  
  1518. // Credit: David Walsh
  1519. function debounce(func, wait, immediate) {
  1520. var timeout;
  1521. return function() {
  1522. var context = this, args = arguments;
  1523. var later = function() {
  1524. timeout = null;
  1525. if (!immediate) func.apply(context, args);
  1526. };
  1527. var callNow = immediate && !timeout;
  1528. clearTimeout(timeout);
  1529. timeout = setTimeout(later, wait);
  1530. if (callNow) func.apply(context, args);
  1531. };
  1532. }
  1533.  
  1534. // Credit: https://gist.github.com/jcxplorer/823878
  1535. // Generate random UUID string
  1536. function uuid() {
  1537. var uuid = "", i, random;
  1538. for (i = 0; i < 32; i++) {
  1539. random = Math.random() * 16 | 0;
  1540. if (i == 8 || i == 12 || i == 16 || i == 20) {
  1541. uuid += "-";
  1542. }
  1543. uuid += (i == 12 ? 4 : (i == 16 ? (random & 3 | 8) : random)).toString(16);
  1544. }
  1545. return uuid;
  1546. }
  1547.  
  1548. // milliseconds to humand readable
  1549. function msToString(ms) {
  1550. const pad = value => (value < 10 ? `0${value}` : value);
  1551. const hours = pad(Math.floor((ms / (1000 * 60 * 60)) % 60));
  1552. const minutes = pad(Math.floor((ms / (1000 * 60)) % 60));
  1553. const seconds = pad(Math.floor((ms / 1000) % 60));
  1554. return `${hours}:${minutes}:${seconds}`;
  1555. }
  1556.  
  1557. })();