Hordes UI Mod

Various UI mods for Hordes.io.

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

	// ==UserScript==
	// @name         Hordes UI Mod
	// @version      0.150
	// @description  Various UI mods for Hordes.io.
	// @author       Sakaiyo
	// @match        https://hordes.io/play
	// @grant        GM_addStyle
// @namespace https://greasyfork.org/users/160017
	// ==/UserScript==
	/**
	  * TODO: Implement chat tabs
	  * TODO: Implement inventory sorting
	  * TODO: (Maybe) Improved healer party frames
	  * TODO: Opacity scaler for map
	  *       Check if it's ok to emulate keypresses before releasing. If not, then maybe copy text to clipboard.
	  * TODO: FIX BUG: Add support for resizing map back to saved position after minimizing it, from maximized position
	  * TODO (Maybe): Ability to make GM chat look like normal chat?
	  * TODO: Add toggleable option to include chat messages to right of party frame
	  * TODO: Remove all reliance on svelte- classes, likely breaks with updates
	  */
	(function() {
	    'use strict';

	    // If this version is different from the user's stored state,
	    // e.g. they have upgraded the version of this script and there are breaking changes,
	    // then their stored state will be deleted.
	    const BREAKING_VERSION = 1;
	    const VERSION = '0.150'; // Should match version in UserScript description

	    // The width+height of the maximized chat, so we don't save map size when it's maximized
	    // TODO: FIX BUG: This is NOT everyones max size. INSTEAD OF USING A STATIC SIZE, we should detect large instant resizes
	    // Should also do this to avoid saving when minimizing menu after maximizing
	    const CHAT_MAXIMIZED_SIZE = 692;

	    const STORAGE_STATE_KEY = 'hordesio-uimodsakaiyo-state';
	    const CHAT_GM_CLASS = 'js-chat-gm';

	    let state = {
	    	breakingVersion: BREAKING_VERSION,
	    	chat: {
	    		GM: true,
	    	},
	    	windowsPos: {},
	    	blockList: {},
	    };
	    // tempState is saved only between page refreshed.
	    const tempState = {
	    	// The last name clicked in chat
	    	chatName: null
	    };

	    // UPDATING STYLES BELOW - Must be invoked in main function
	    GM_addStyle(`
	    	/* Transparent chat bg color */
			.frame.svelte-1vrlsr3 {
				background: rgba(0,0,0,0.4);
			}

			/* Transparent map */
			.svelte-hiyby7 {
				opacity: 0.7;
			}

			/* Our mod's chat message color */
			.textuimod {
				color: #00dd33;
			}

			/* Allows windows to be moved */
			.window {
				position: relative;
			}

			/* Allows last clicked window to appear above all other windows */
			.js-is-top {
				z-index: 9998 !important;
			}
			.panel.context:not(.commandlist) {
				z-index: 9999 !important;
			}

			/* Custom chat context menu, invisible by default */
			.js-chat-context-menu {
				display: none;
			}

			.js-chat-context-menu .name {
				color: white;
				padding: 2px 4px;
			}

			/* Allow names in chat to be clicked */
			#chat .name {
				pointer-events: all !important;
			}

			/* Custom chat filter colors */
			.js-chat-gm {
				color: #a6dcd5;
			}

			/* Class that hides chat lines */
			.js-line-hidden,
			.js-line-blocked {
				display: none;
			}

			/* Enable chat & map resize */
			.js-chat-resize {
				resize: both;
				overflow: auto;
			}
			.js-map-resize:hover {
				resize: both;
				overflow: auto;
				direction: rtl;
			}

			/* The browser resize icon */
			*::-webkit-resizer {
		        background: linear-gradient(to right, rgba(51, 77, 80, 0), rgba(203, 202, 165, 0.5));
			    border-radius: 8px;
			    box-shadow: 0 1px 1px rgba(0,0,0,1);
			}
			*::-moz-resizer {
		        background: linear-gradient(to right, rgba(51, 77, 80, 0), rgba(203, 202, 165, 0.5));
			    border-radius: 8px;
			    box-shadow: 0 1px 1px rgba(0,0,0,1);
			}

			/* Custom css for settings page, duplicates preexisting settings pane grid */
			.uimod-settings {
				display: grid;
				grid-template-columns: 2fr 3fr;
				grid-gap: 8px;
				align-items: center;
				max-height: 390px;
				margin: 0 20px;
			}
			/* The custom settings main window, closely mirrors main settings window */
			.uimod-custom-settings {
				position: absolute;
				top: 100px;
			    left: 50%;
			    transform: translate(-50%, 0);
			    min-width: 350px;
			    max-width: 600px;
			    width: 90%;
			    height: 80%;
			    min-height: 350px;
			    max-height: 500px;
			    z-index: 9;
			    padding: 0px 10px 5px;
			}
		`);


	    const modHelpers = {
	    	// Filters all chat based on custom filters
	    	filterAllChat: () => {
	    		// Blocked user filter
	    		Object.keys(state.blockList).forEach(blockedName => {
	    			// Get the `.name` elements from the blocked user, if we haven't already hidden their messages
	    			const $blockedChatNames = Array.from(document.querySelectorAll(`[data-chat-name="${blockedName}"]:not(.js-line-blocked)`));
	    			// Hide each of their messages
	    			$blockedChatNames.forEach($name => {
	    				// Add the class name to $name so we can track whether it's been hidden in our CSS selector $blockedChatNames
	    				$name.classList.add('js-line-blocked');
	    				const $line = $name.parentNode.parentNode.parentNode;
	    				// Add the class name to $line so we can visibly hide the entire chat line
	    				$line.classList.add('js-line-blocked');
	    			});
	    		})

	    		// Custom channel filter
		    	Object.keys(state.chat).forEach(channel => {
			  		Array.from(document.querySelectorAll(`.text${channel}.content`)).forEach($textItem => {
			  			const $line = $textItem.parentNode.parentNode;
			  			$line.classList.toggle('js-line-hidden', !state.chat[channel]);
			  		});
		    	});
			},

			// Makes chat context menu visible and appear under the mouse
			showChatContextMenu: (name, mousePos) => {
				const $contextMenu = document.querySelector('.js-chat-context-menu');
				$contextMenu.querySelector('.js-name').textContent = name;
				$contextMenu.setAttribute('style', `display: block; left: ${mousePos.x}px; top: ${mousePos.y}px;`);
			},

			// Close chat context menu if clicking outside of it
			closeChatContextMenu: clickEvent => {
	    		const $target = clickEvent.target;
	    		// If clicking on name or directly on context menu, don't close it
	    		// Still closes if clicking on context menu item
	    		if ($target.classList.contains('js-is-context-menu-initd') 
	    			|| $target.classList.contains('js-chat-context-menu')) {
	    			return;
	    		}

	    		const $contextMenu = document.querySelector('.js-chat-context-menu');
	    		$contextMenu.setAttribute('style', 'display: none');
	    	},

	    	// Adds player to block list, to be filtered out of chat
	    	blockPlayer: playerName => {
	    		if (state.blockList[playerName]) {
	    			return;
	    		}
	    		
	    		state.blockList[playerName] = true;
	    		modHelpers.filterAllChat();
	    		modHelpers.addChatMessage(`${playerName} has been blocked.`)
	    		save({blockList: state.blockList});
	    	},

	    	// Removes player from block list and makes their messages visible again
	    	unblockPlayer: playerName => {
	    		delete state.blockList[playerName];
	    		modHelpers.addChatMessage(`${playerName} has been unblocked.`);
	    		save({blockList: state.blockList});

	    		// Make messages visible again
	    		const $chatNames = Array.from(document.querySelectorAll(`.js-line-blocked[data-chat-name="${playerName}"]`));
	    		$chatNames.forEach($name => {
	    			$name.classList.remove('js-line-blocked');
	    			const $line = $name.parentNode.parentNode.parentNode;
	    			$line.classList.remove('js-line-blocked');
	    		});
	    	},

	    	// Pushes message to chat
	    	// TODO: The margins for the message are off slightly compared to other messages - why?
	    	addChatMessage: text => {
	    		const newMessageHTML = `
				<div class="linewrap svelte-1vrlsr3">
					<span class="time svelte-1vrlsr3">00.00</span>
					<span class="textuimod content svelte-1vrlsr3">
					<span class="capitalize channel svelte-1vrlsr3">UIMod</span>
					</span>
					<span class="svelte-1vrlsr3">${text}</span>
				</div>
	    		`;

	    		const element = makeElement({
	    			element: 'article',
	    			class: 'line svelte-1vrlsr3',
	    			content: newMessageHTML});
	    		document.querySelector('#chat').appendChild(element);
	    	},
	    };

	    // MAIN MODS BELOW
	    const mods = [
	    	// Creates DOM elements for custom chat filters
	    	function newChatFilters() {
		    	const $channelselect = document.querySelector('.channelselect');
		    	if (!document.querySelector(`.${CHAT_GM_CLASS}`)) {
					const $gm = makeElement({
			        	element: 'small',
			        	class: `btn border black ${CHAT_GM_CLASS} ${state.chat.GM ? '' : 'textgrey'}`,
			        	content: 'GM'
			        });
			        $channelselect.appendChild($gm);
			    }
		    },

	    	// Wire up new chat buttons to toggle in state+ui
	    	function newChatFilterButtons() {
	    		const $chatGM = document.querySelector(`.${CHAT_GM_CLASS}`);
	    		$chatGM.addEventListener('click', () => {
	    			state.chat.GM = !state.chat.GM;
	    			$chatGM.classList.toggle('textgrey', !state.chat.GM);
	    			modHelpers.filterAllChat();
	    			save({chat: state.chat});
	    		});
	    	},

	    	// Filter out chat in UI based on chat buttons state
	    	function filterChatObserver() {
			    const chatObserver = new MutationObserver(modHelpers.filterAllChat);
				chatObserver.observe(document.querySelector('#chat'), { attributes: true, childList: true });
	    	},

	    	// Drag all windows by their header
	    	function draggableUIWindows() {
	    		Array.from(document.querySelectorAll('.window:not(.js-can-move)')).forEach($window => {
	    			$window.classList.add('js-can-move');
					dragElement($window, $window.querySelector('.titleframe'));
	    		});
	    	},

	    	// Save dragged UI windows position to state
	    	function saveDraggedUIWindows() {
	    		Array.from(document.querySelectorAll('.window:not(.js-window-is-saving)')).forEach($window => {
	    			$window.classList.add('js-window-is-saving');
	    			const $draggableTarget = $window.querySelector('.titleframe');
	    			const windowName = $draggableTarget.querySelector('[name="title"]').textContent;
	    			$draggableTarget.addEventListener('mouseup', () => {
	    				state.windowsPos[windowName] = $window.getAttribute('style');
	    				save({windowsPos: state.windowsPos});
	    			});
	    		});
	    	},

	    	// Loads draggable UI windows position from state
	    	function loadDraggedUIWindowsPositions() {
	    		Array.from(document.querySelectorAll('.window:not(.js-has-loaded-pos)')).forEach($window => {
	    			$window.classList.add('js-has-loaded-pos');
					const windowName = $window.querySelector('[name="title"]').textContent;
					const pos = state.windowsPos[windowName];
					if (pos) {
						$window.setAttribute('style', pos);
					}
	    		});
	    	},

	    	// Makes chat resizable
	    	function resizableChat() {
	    		// Add the appropriate classes
	    		const $chatContainer = document.querySelector('#chat').parentNode;
	    		$chatContainer.classList.add('js-chat-resize');

	    		// Load initial chat and map size
	    		if (state.chatWidth && state.chatHeight) {
		    		$chatContainer.style.width = state.chatWidth;
		    		$chatContainer.style.height = state.chatHeight;
		    	}

	    		// Save chat size on resize
	    		const resizeObserverChat = new ResizeObserver(() => {
	    			const chatWidthStr = window.getComputedStyle($chatContainer, null).getPropertyValue('width');
	    			const chatHeightStr = window.getComputedStyle($chatContainer, null).getPropertyValue('height');
	    			save({
	    				chatWidth: chatWidthStr,
	    				chatHeight: chatHeightStr,
	    			});
	    		});
	    		resizeObserverChat.observe($chatContainer);
	    	},

	    	// Makes map resizable
	    	function resizeableMap() {
	    		const $map = document.querySelector('.svelte-hiyby7');
	    		const $canvas = $map.querySelector('canvas');
	    		$map.classList.add('js-map-resize');

	    		const onMapResize = () => {
	    			// Get real values of map height/width, excluding padding/margin/etc
	    			const mapWidthStr = window.getComputedStyle($map, null).getPropertyValue('width');
	    			const mapHeightStr = window.getComputedStyle($map, null).getPropertyValue('height');
	    			const mapWidth = Number(mapWidthStr.slice(0, -2));
	    			const mapHeight = Number(mapHeightStr.slice(0, -2));

	    			// If height/width are 0 or unset, don't resize canvas
	    			if (!mapWidth || !mapHeight) {
	    				return;
	    			}

	    			if ($canvas.width !== mapWidth) {
	    				$canvas.width = mapWidth;
	    			}

	    			if ($canvas.height !== mapHeight) {
	    				$canvas.height = mapHeight;
	    			}

	    			// Save map size on resize, unless map has been maximized by user
	    			if (mapWidth !== CHAT_MAXIMIZED_SIZE && mapHeight !== CHAT_MAXIMIZED_SIZE) {
	    				save({
	    					mapWidth: mapWidthStr,
	    					mapHeight: mapHeightStr,
	    				});
	    			}
	    		};

		    	if (state.mapWidth && state.mapHeight) {
		    		$map.style.width = state.mapWidth;
		    		$map.style.height = state.mapHeight;
		    		onMapResize(); // Update canvas size on initial load of saved map size
		    	}

	    		// On resize of map, resize canvas to match
	    		const resizeObserverMap = new ResizeObserver(onMapResize);
	    		resizeObserverMap.observe($map);

	    		// We need to observe canvas resizes to tell when the user presses M to open the big map
	    		// At that point, we resize the map to match the canvas
	    		const triggerResize = () => {
	    			// Get real values of map height/width, excluding padding/margin/etc
	    			const mapWidthStr = window.getComputedStyle($map, null).getPropertyValue('width');
	    			const mapHeightStr = window.getComputedStyle($map, null).getPropertyValue('height');
	    			const mapWidth = Number(mapWidthStr.slice(0, -2));
	    			const mapHeight = Number(mapHeightStr.slice(0, -2));

	    			// If height/width are 0 or unset, we don't care about resizing yet
	    			if (!mapWidth || !mapHeight) {
	    				return;
	    			}

	    			if ($canvas.width !== mapWidth) {
	    				$map.style.width = `${$canvas.width}px`;
	    			}

	    			if ($canvas.height !== mapHeight) {
	    				$map.style.height = `${$canvas.height}px`;
	    			}
	    		};

	    		// We debounce the canvas resize, so it doesn't resize every single
	    		// pixel you move when resizing the DOM. If this were to happen,
	    		// resizing would constantly be interrupted. You'd have to resize a tiny bit,
	    		// lift left click, left click again to resize a tiny bit more, etc.
	    		// Resizing is smooth when we debounce this canvas.
		    	const debouncedTriggerResize = debounce(triggerResize, 200);
	    		const resizeObserverCanvas = new ResizeObserver(debouncedTriggerResize);
	    		resizeObserverCanvas.observe($canvas);
	    	},

	    	// The last clicked UI window displays above all other UI windows
	    	// This is useful when, for example, your inventory is near the market window,
	    	// and you want the window and the tooltips to display above the market window.
	    	function selectedWindowIsTop() {
	    		Array.from(document.querySelectorAll('.window:not(.js-is-top-initd)')).forEach($window => {
	    			$window.classList.add('js-is-top-initd');

	    			$window.addEventListener('mousedown', () => {
		    			// First, make the other is-top window not is-top
		    			const $otherWindowContainer = document.querySelector('.js-is-top');
		    			if ($otherWindowContainer) {
		    				$otherWindowContainer.classList.remove('js-is-top');
		    			}

		    			// Then, make our window's container (the z-index container) is-top
		    			$window.parentNode.classList.add('js-is-top');
		    		});
	    		});
	    	},

	    	function customSettings() {
	    		const $settings = document.querySelector('.divide:not(.js-settings-initd)');
	    		if (!$settings) {
	    			return;
	    		}

	    		$settings.classList.add('js-settings-initd');
	    		const $settingsChoiceList = $settings.querySelector('.choice').parentNode;
	    		$settingsChoiceList.appendChild(makeElement({
	    			element: 'div',
	    			class: 'choice js-blocked-players',
	    			content: 'Blocked players',
	    		}));

	    		// Upon click, we display our custom settings window UI
	    		document.querySelector('.js-blocked-players').addEventListener('click', () => {
	    			let blockedPlayersHTML = '';
	    			Object.keys(state.blockList).sort().forEach(blockedName => {
	    				blockedPlayersHTML += `
	    					<div data-player-name="${blockedName}">${blockedName}</div>
	    					<div class="btn orange js-unblock-player" data-player-name="${blockedName}">Unblock player</div>
    					`;
	    			});

	    			const customSettingsHTML = `
	    				<h3 class="textprimary">Blocked players</h3>
	    				<div class="settings uimod-settings">${blockedPlayersHTML}</div>
	    				<p></p>
	    				<div class="btn blue js-close-custom-settings">Close</div>
	    			`;

	    			const $customSettings = makeElement({
	    				element: 'div',
	    				class: 'menu panel-black js-custom-settings uimod-custom-settings',
	    				content: customSettingsHTML,
	    			});
	    			document.body.appendChild($customSettings);

	    			// Wire up all the unblock buttons
	    			Array.from(document.querySelectorAll('.js-unblock-player')).forEach($button => {
	    				$button.addEventListener('click', clickEvent => {
	    					const name = clickEvent.target.getAttribute('data-player-name');
	    					modHelpers.unblockPlayer(name);

	    					// Remove the blocked player from the list
	    					Array.from(document.querySelectorAll(`[data-player-name="${name}"]`)).forEach($element => {
	    						$element.parentNode.removeChild($element);
	    					});
	    				});
	    			});
	    			// And the close button for our custom UI
	    			document.querySelector('.js-close-custom-settings').addEventListener('click', () => {
	    				const $customSettingsWindow = document.querySelector('.js-custom-settings');
	    				$customSettingsWindow.parentNode.removeChild($customSettings);
	    			});
	    		});
	    	},

	    	// This creates the initial chat context menu (which starts as hidden)
	    	function createChatContextMenu() {
	    		if (document.querySelector('.js-chat-context-menu')) {
	    			return;
	    		}

	    		document.body.appendChild(makeElement({
	    			element: 'div',
	    			class: 'panel context border grey js-chat-context-menu',
	    			content: `
	    				<div class="js-name">...</div>
	    				<div class="choice" name="party">Party invite</div>
	    				<div class="choice" name="whisper">Whisper</div>
	    				<div class="choice" name="block">Block</div>
	    			`,
	    		}));

	    		const $chatContextMenu = document.querySelector('.js-chat-context-menu');
	    		$chatContextMenu.querySelector('[name="party"]').addEventListener('click', () => {
					enterTextIntoChat(`/partyinvite ${tempState.chatName}`);
					submitChat();
	    		});
	    		$chatContextMenu.querySelector('[name="whisper"]').addEventListener('click', () => {
	    			enterTextIntoChat(`/whisper ${tempState.chatName} `);
	    		});
	    		$chatContextMenu.querySelector('[name="block"]').addEventListener('click', () => {
	    			modHelpers.blockPlayer(tempState.chatName);
	    		})
	    	},

	    	// This opens a context menu when you click a user's name in chat
	    	function chatContextMenu() {
	    		Array.from(document.querySelectorAll('.name:not(.js-is-context-menu-initd)')).forEach($name => {
	    			$name.classList.add('js-is-context-menu-initd');
	    			// Add name to element so we can target it in CSS when filtering chat for block list
	    			$name.setAttribute('data-chat-name', $name.textContent);

	    			const showContextMenu = clickEvent => {
	    				// TODO: Is there a way to pass the name to showChatContextMenumethod, instead of storing in tempState?
	    				tempState.chatName = $name.textContent;
	    				modHelpers.showChatContextMenu($name.textContent, {x: clickEvent.pageX, y: clickEvent.pageY});
	    			};
	    			$name.addEventListener('click', showContextMenu);
	    		});
	    	},
	    ];

	    // Add new DOM, load our stored state, wire it up, then continuously rerun specific methods whenever UI changes
	    function initialize() {
	    	// If the Hordes.io tab isn't active for long enough, it reloads the entire page, clearing this mod
	    	// We check for that and reinitialize the mod if that happens
	    	const $layout = document.querySelector('.layout');
	    	if ($layout.classList.contains('uimod-initd')) {
	    		return;
	    	}

	    	modHelpers.addChatMessage(`Hordes UI Mod v${VERSION} by Sakaiyo has been initialized.`);

	    	$layout.classList.add('uimod-initd')
			load();
	        mods.forEach(mod => mod());

	        // Continuously re-run specific mods methods that need to be executed on UI change
			const rerunObserver = new MutationObserver(() => {
				// If new window appears, e.g. even if window is closed and reopened, we need to rewire it
	        	// Fun fact: Some windows always exist in the DOM, even when hidden, e.g. Inventory
	        	// 		     But some windows only exist in the DOM when open, e.g. Interaction
	        	const modsToRerun = [
	        		'saveDraggedUIWindows',
	        		'draggableUIWindows',
	        		'loadDraggedUIWindowsPositions',
	        		'selectedWindowIsTop',
	        		'customSettings',
	    		];
	        	modsToRerun.forEach(modName => {
	        		mods.find(mod => mod.name === modName)();
	        	});
			});
			rerunObserver.observe(document.querySelector('.layout > .container'), { attributes: false, childList: true, });

			// Rerun only on chat
			const chatRerunObserver = new MutationObserver(() => {
				mods.find(mod => mod.name === 'chatContextMenu')();
				modHelpers.filterAllChat();

			});
			chatRerunObserver.observe(document.querySelector('#chat'), { attributes: false, childList: true, });

			// Event listeners for document.body might be kept when the game reloads, so don't reinitialize them
			if (!document.body.classList.contains('js-uimod-initd')) {
				document.body.classList.add('js-uimod-initd');

				// Close chat context menu when clicking outside of it
				document.body.addEventListener('click', modHelpers.closeChatContextMenu);
			}
	    }

	    // Initialize mods once UI DOM has loaded
	    const pageObserver = new MutationObserver(() => {
		    const isUiLoaded = !!document.querySelector('.layout');
		    if (isUiLoaded) {
		    	initialize();
		    }
		});
		pageObserver.observe(document.body, { attributes: true, childList: true })

		// UTIL METHODS
		// Save to in-memory state and localStorage to retain on refresh
		// TODO: Can use this solely to save to storage - dont need to update state, we already do that ourselves a lot
		function save(items) {
			state = {
				...state,
				...items,
			};
			localStorage.setItem(STORAGE_STATE_KEY, JSON.stringify(state));
		}

		// Load localStorage state if it exists
		// NOTE: If user is trying to load unsupported version of stored state,
		//       e.g. they just upgraded to breaking version, then we delete their stored state
		function load() {
			const storedStateJson = localStorage.getItem(STORAGE_STATE_KEY)
			if (storedStateJson) {
				const storedState = JSON.parse(storedStateJson);
				if (storedState.breakingVersion !== BREAKING_VERSION) {
					localStorage.setItem(STORAGE_STATE_KEY, JSON.stringify(state));
					return;
				}
				state = {
					...state,
					...storedState,
				};
			}
		}

		// Nicer impl to create elements in one method call
		function makeElement(args) {
			const $node = document.createElement(args.element);
			if (args.class) { $node.className = args.class; }
			if (args.content) { $node.innerHTML = args.content; }
			if (args.src) { $node.src = args.src; }
			return $node;
		}

		function enterTextIntoChat(text) {
			// Open chat input
			const enterEvent = new KeyboardEvent('keydown', {
			    bubbles: true, cancelable: true, keyCode: 13
			});
			document.body.dispatchEvent(enterEvent);

			// Place text into chat
			const $input = document.querySelector('#chatinput input');
			$input.value = text;

			// Get chat input to recognize slash commands and change the channel
			// by triggering the `input` event.
			// (Did some debugging to figure out the channel only changes when the
			//  svelte `input` event listener exists.)
			const inputEvent = new KeyboardEvent('input', {
				bubbles: true, cancelable: true
			})
			$input.dispatchEvent(inputEvent);
		}

		function submitChat() {
			const $input = document.querySelector('#chatinput input');
			const kbEvent = new KeyboardEvent('keydown', {
			    bubbles: true, cancelable: true, keyCode: 13
			});
			$input.dispatchEvent(kbEvent);
		}

		// Credit: https://stackoverflow.com/a/14234618 (Has been slightly modified)
		// $draggedElement is the item that will be dragged.
		// $dragTrigger is the element that must be held down to drag $draggedElement
		function dragElement($draggedElement, $dragTrigger) {
			let offset = [0,0];
			let isDown = false;
			$dragTrigger.addEventListener('mousedown', function(e) {
			    isDown = true;
			    offset = [
			        $draggedElement.offsetLeft - e.clientX,
			        $draggedElement.offsetTop - e.clientY
			    ];
			}, true);
			document.addEventListener('mouseup', function() {
			    isDown = false;
			}, true);

			document.addEventListener('mousemove', function(e) {
			    event.preventDefault();
			    if (isDown) {
			        $draggedElement.style.left = (e.clientX + offset[0]) + 'px';
			        $draggedElement.style.top = (e.clientY + offset[1]) + 'px';
			    }
			}, true);
		}

		// Credit: David Walsh
		function debounce(func, wait, immediate) {
			var timeout;
			return function() {
				var context = this, args = arguments;
				var later = function() {
					timeout = null;
					if (!immediate) func.apply(context, args);
				};
				var callNow = immediate && !timeout;
				clearTimeout(timeout);
				timeout = setTimeout(later, wait);
				if (callNow) func.apply(context, args);
			};
		}
	})();