Greasy Fork is available in English.

Keybindings

Adds keybindings to Melvor Idle. Visit the Settings menu (X) to view all keybinds.

// ==UserScript==
// @name        Keybindings
// @description Adds keybindings to Melvor Idle. Visit the Settings menu (X) to view all keybinds.
// @version     1.1.1
// @license     MIT
// @match       https://*.melvoridle.com/*
// @exclude     https://wiki.melvoridle.com*
// @grant       none
// @namespace   https://github.com/ChaseStrackbein/melvor-keybindings
// ==/UserScript==

window.kb = (() => {
  const invalidKeys = ['SHIFT', 'CONTROL', 'ALT', 'META'];
  let cachePage = window.currentPage;
  let previousPage = -1;
  let settingsGrid = null;
  let bindingBeingRemapped = null;

  let keymap = {};

  const keybindings = [
    {
      name: 'Menu',
      category: 'General',
      defaultKeys: { key: 'M' },
      callback: () => document.getElementById('page-header-user-dropdown').click()
    },
    {
      name: 'Save',
      category: 'General',
      defaultKeys: { key: 'S', ctrlKey: true },
      callback: () => forceSync(false, false)
    },
    {
      name: 'Reload / Character Select',
      category: 'General',
      defaultKeys: { key: 'F5' },
      callback: () => window.location.reload()
    },
    {
      name: 'Open Wiki',
      category: 'General',
      defaultKeys: { key: 'F1' },
      callback: () => window.open('https://wiki.melvoridle.com/', '_blank')
    },
    {
      name: 'Settings',
      category: 'General',
      defaultKeys: { key: 'X' },
      callback: () => changePage(CONSTANTS.page.Settings, false, false)
    },
    {
      name: 'Shop',
      category: 'General',
      defaultKeys: { key: 'V' },
      callback: () => changePage(CONSTANTS.page.Shop, false, false)
    },
    {
      name: 'Bank',
      category: 'General',
      defaultKeys: { key: 'B' },
      callback: () => changePage(CONSTANTS.page.Bank, false, false)
    },
    {
      name: 'Combat',
      category: 'General',
      defaultKeys: { key: 'C' },
      callback: () => changePage(CONSTANTS.page.Combat, false, false)
    },
    {
      name: 'Eat Equipped Food',
      category: 'General',
      defaultKeys: { key: 'H' },
      callback: () => player.eatFood()
    },
    {
      name: 'Loot All (Combat)',
      category: 'General',
      defaultKeys: { key: 'SPACE' },
      callback: () => combatManager.loot.lootAll()
    },
    {
      name: 'Run (Combat)',
      category: 'General',
      defaultKeys: { key: 'SPACE', ctrlKey: true },
      callback: () => combatManager.stopCombat()
    },
    {
      name: 'Equipment Set 1',
      category: 'General',
      defaultKeys: { key: '1', ctrlKey: true },
      callback: () => player.changeEquipmentSet(0)
    },
    {
      name: 'Equipment Set 2',
      category: 'General',
      defaultKeys: { key: '2', ctrlKey: true },
      callback: () => player.changeEquipmentSet(1)
    },
    {
      name: 'Equipment Set 3',
      category: 'General',
      defaultKeys: { key: '3', ctrlKey: true },
      callback: () => player.changeEquipmentSet(2)
    },
    {
      name: 'Equipment Set 4',
      category: 'General',
      defaultKeys: { key: '4', ctrlKey: true },
      callback: () => player.changeEquipmentSet(3)
    },
    {
      name: 'Search Bank',
      category: 'General',
      defaultKeys: { key: 'F', ctrlKey: true },
      callback: () => {
        changePage(CONSTANTS.page.Bank, false, false);
        updateBankSearchArray();
        document.getElementById('searchTextbox').focus();
      }
    },
    {
      name: 'Summoning Synergies Menu',
      category: 'General',
      defaultKeys: { key: 'S' },
      callback: () => {
        const modal = $('#modal-summoning-synergy').data('bs.modal');
        if (!modal || !modal._isShown) openSynergiesBreakdown();
        else modal.hide();
      }
    },
    {
      name: 'Search Summoning Synergies',
      category: 'General',
      defaultKeys: { key: 'F', ctrlKey: true, altKey: true },
      callback: () => {
        openSynergiesBreakdown();
        document.getElementById('summoning-synergy-search').focus();
      }
    },
    {
      name: 'Woodcutting',
      category: 'General',
      defaultKeys: { key: '1' },
      callback: () => changePage(CONSTANTS.page.Woodcutting, false, false)
    },
    {
      name: 'Fishing',
      category: 'General',
      defaultKeys: { key: '2' },
      callback: () => changePage(CONSTANTS.page.Fishing, false, false)
    },
    {
      name: 'Firemaking',
      category: 'General',
      defaultKeys: { key: '3' },
      callback: () => changePage(CONSTANTS.page.Firemaking, false, false)
    },
    {
      name: 'Cooking',
      category: 'General',
      defaultKeys: { key: '4' },
      callback: () => changePage(CONSTANTS.page.Cooking, false, false)
    },
    {
      name: 'Mining',
      category: 'General',
      defaultKeys: { key: '5' },
      callback: () => changePage(CONSTANTS.page.Mining, false, false)
    },
    {
      name: 'Smithing',
      category: 'General',
      defaultKeys: { key: '6' },
      callback: () => changePage(CONSTANTS.page.Smithing, false, false)
    },
    {
      name: 'Thieving',
      category: 'General',
      defaultKeys: { key: '7' },
      callback: () => changePage(CONSTANTS.page.Thieving, false, false)
    },
    {
      name: 'Farming',
      category: 'General',
      defaultKeys: { key: '8' },
      callback: () => changePage(CONSTANTS.page.Farming, false, false)
    },
    {
      name: 'Fletching',
      category: 'General',
      defaultKeys: { key: '9' },
      callback: () => changePage(CONSTANTS.page.Fletching, false, false)
    },
    {
      name: 'Crafting',
      category: 'General',
      defaultKeys: { key: '0' },
      callback: () => changePage(CONSTANTS.page.Crafting, false, false)
    },
    {
      name: 'Runecrafting',
      category: 'General',
      defaultKeys: { key: '!' },
      callback: () => changePage(CONSTANTS.page.Runecrafting, false, false)
    },
    {
      name: 'Herblore',
      category: 'General',
      defaultKeys: { key: '@' },
      callback: () => changePage(CONSTANTS.page.Herblore, false, false)
    },
    {
      name: 'Agility',
      category: 'General',
      defaultKeys: { key: '#' },
      callback: () => changePage(CONSTANTS.page.Agility, false, false)
    },
    {
      name: 'Summoning',
      category: 'General',
      defaultKeys: { key: '$' },
      callback: () => changePage(CONSTANTS.page.Summoning, false, false)
    },
    {
      name: 'Astrology',
      category: 'General',
      defaultKeys: { key: '%' },
      callback: () => changePage(CONSTANTS.page.Astrology, false, false)
    },
    {
      name: 'Alt. Magic',
      category: 'General',
      defaultKeys: { key: 'M', altKey: true },
      callback: () => changePage(CONSTANTS.page.AltMagic, false, false)
    },
    {
      name: 'Completion Log',
      category: 'General',
      defaultKeys: { key: 'Y' },
      callback: () => changePage(30, false, false)
    },
    {
      name: 'Statistics',
      category: 'General',
      defaultKeys: { key: 'F2' },
      callback: () => changePage(CONSTANTS.page.Statistics, false, false)
    },
    {
      name: 'Golbin Raid',
      category: 'General',
      defaultKeys: { key: 'G' },
      callback: () => changePage(CONSTANTS.page.GolbinRaid, false, false)
    },
    {
      name: 'Previous Page',
      category: 'General',
      defaultKeys: { key: 'BACKSPACE' },
      callback: () => changePage(previousPage, false, false)
    }
  ];

  const createHeader = () => {
    const header = document.createElement('h2');
    header.classList.add('content-heading', 'border-bottom', 'mb-4', 'pb-2');
    header.innerHTML = 'Keybindings';
    return header;
  };

  const createHelpText = () => {
    const helpText = document.createElement('div');
    helpText.classList.add('font-size-sm', 'text-muted', 'ml-2', 'mb-2');
    helpText.innerHTML = 'Click a keybinding to remap to new keys.<br />ESC or click again to cancel remapping.<br />CTRL + ALT + SPACE to clear the mapping.';
    return helpText;
  };
  
  const createWrapper = (grid) => {
    const row = document.createElement('div');
    row.classList.add('row');
    const column = document.createElement('div');
    column.classList.add('col-md-6', 'offset-md-3');
    const wrapper = document.createElement('div');
    wrapper.classList.add('mb-4');
    
    wrapper.appendChild(createHelpText());
    wrapper.appendChild(grid);
    wrapper.appendChild(createResetButton());
    column.appendChild(wrapper);
    row.appendChild(column);
    
    return row;
  };
  
  const createGrid = () => {
    const grid = document.createElement('div');
    grid.classList.add('mkb-grid');
    return grid;
  };
  
  const createRow = (keybinding) => {
    const row = document.createElement('div');
    row.classList.add('mkb-row');

    row.addEventListener('click', () => beginListeningForRemap(keybinding));
    const nameCell = createCell(keybinding.name);
    const keyCell = createCell(keybinding.keys);
    row.appendChild(nameCell);
    row.appendChild(keyCell);

    keybinding.keyCell = keyCell;

    return row;
  };
  
  const createCell = (keysOrText) => {
    const cell = document.createElement('div');
    cell.classList.add('mkb-cell');
    if (typeof keysOrText === 'string') cell.innerHTML = keysOrText;
    else {
      if (keysOrText.ctrlKey) {
        cell.appendChild(createKbd('CTRL'));
        cell.appendChild(createPlus());
      }
      if (keysOrText.altKey) {
        cell.appendChild(createKbd('ALT'));
        cell.appendChild(createPlus());
      }
      if (keysOrText.key) cell.appendChild(createKbd(keysOrText.key));
    }
    return cell;
  };
  
  const createKbd = (text) => {
    const kbd = document.createElement('kbd');
    kbd.innerHTML = text;
    return kbd;
  };
  
  const createPlus = () => {
    const plus = document.createTextNode('+');
    return plus;
  };

  const createResetButton = () => {
    const resetButton = document.createElement('button');
    resetButton.type = 'button';
    resetButton.classList.add('btn', 'btn-sm', 'btn-danger', 'm-1');
    resetButton.innerHTML = 'Reset Default Keybindings';
    resetButton.addEventListener('click', resetDefaults);
    return resetButton;
  };

  const createStylesheet = () => {
    const stylesheet = document.createElement('style');
    stylesheet.innerHTML = 
    `.mkb-grid {
      background-color: #161a22;
      height: 300px;
      overflow-y: auto;
      width: 100%;
    }
    
    .mkb-row {
      cursor: pointer;
      display: flex;
    }
    
    .mkb-row:nth-of-type(even) {
      background-color: rgba(255, 255, 255, 0.03);
    }
    
    .mkb-row:hover {
      background-color: rgba(255, 255, 255, 0.1);
    }

    .mkb-row.mkb-listening {
      background-color: #577baa !important;
    }
    
    .mkb-cell {
      align-items: center;
      display: flex;
      flex: 1 1 auto;
      padding: 5px;
      width: 100%;
    }
    
    .mkb-grid kbd {
      background-color: hsl(210, 8%, 90%);
      border: 1px solid hsl(210, 8%, 65%);
      border-radius: 3px;
      box-shadow: 0 1px 1px hsla(210, 8%, 5%, 0.15),
        inset 1px 1px 0 #ffffff;
      color: hsl(210, 8%, 15%);
      font-size: 66%;
      margin: 0 5px;
      min-width: 26px;
      padding: 3px;
      text-align: center;
      text-shadow: #ffffff;
    }
    
    .mkb-grid kbd:first-of-type {
      margin-left: 0px;
    }
    
    .mkb-grid kbd:last-of-type {
      margin-right: 0px;
    }`;
    return stylesheet;
  };
  
  const inject = () => {
    const isGameLoaded = window.isLoaded && !window.currentlyCatchingUp;
      
    if (!isGameLoaded) {
      setTimeout(inject, 50);
      return;
    }

    const grid = createGrid();
    keybindings.forEach(k => {
      k.row = createRow(k);
      grid.appendChild(k.row);
    });

    settingsGrid = grid;

    const notifications = Array.from(document.querySelectorAll('#settings-container h2')).find(e => e.textContent === 'Notification Settings');
    notifications.parentNode.insertBefore(createHeader(), notifications);
    notifications.parentNode.insertBefore(createWrapper(grid), notifications);
    document.head.appendChild(createStylesheet());
  };

  const beginListeningForRemap = (keybinding) => {
    if (keybinding === bindingBeingRemapped) {
      endListeningForRemap();
      return;
    }
    endListeningForRemap();
    keybinding.row.classList.add('mkb-listening');
    bindingBeingRemapped = keybinding;
  };

  const endListeningForRemap = () => {
    if (!bindingBeingRemapped) return;
    bindingBeingRemapped.row.classList.remove('mkb-listening');
    bindingBeingRemapped = null;
  };

  const resetDefaults = () => {
    const reset = [];
    keybindings.forEach(k => {
      const conflict = keybindings.some(kb => reset.includes(kb.name) && parseKeypress(kb.keys) === parseKeypress(k.defaultKeys));
      reset.push(k.name);
      remap(conflict ? {} : k.defaultKeys, k);
    });
  };

  const remap = (keys, keybinding) => {
    if (keys.key) {
      const conflict = keybindings.find(k => k.name !== keybinding.name && parseKeypress(k.keys) === parseKeypress(keys));
      if (conflict) remap({}, conflict);
    }
    keybinding.keys = keys;
    if (settingsGrid !== null) {
      const keyCell = createCell(keys);
      keybinding.row.replaceChild(keyCell, keybinding.keyCell);
      keybinding.keyCell = keyCell;
    }

    saveData();
    updateKeymap();
  };

  const updateKeymap = () => {
    keymap = {};
    keybindings.forEach(k => {
      const keypress = parseKeypress(k.keys);
      if (keypress) keymap[keypress] = k.callback
    });
  };

  const toKeys = (e) => {
    if (!e.key) return {};
    let key = e.key.toUpperCase();
    if (key === 'ESCAPE') key = 'ESC';
    else if (key === ' ') key = 'SPACE';
    else if (key === '\n') key = 'ENTER';
    return { key, ctrlKey: e.ctrlKey, altKey: e.altKey, metaKey: e.metaKey };
  };

  const parseKeypress = (e) => {
    if (!e.key) return '';
    if (invalidKeys.includes(e.key)) return '';

    let keys = [];
    if (e.ctrlKey) keys.push('CTRL');
    if (e.altKey) keys.push('ALT');
    keys.push(e.key.toUpperCase());

    return keys.join('+');
  };

  const doNotTriggerKeybind = (e) => {
    return e.target.tagName == 'INPUT'
      || e.target.tagName == 'SELECT'
      || e.target.tagName == 'TEXTAREA'
      || e.target.isContentEditable; 
  };

  const saveData = () => {
    const data = keybindings.map(k => ({ bindTo: k.name, keys: k.keys }));
    const existingData = getSavedData();
    existingData.forEach(d => {
      if (!data.some(dt => dt.bindTo === d.bindTo))
        data.push(d);
    });
  localStorage.setItem('MKB-data', JSON.stringify(data));
  };

  const loadData = () => {
    const data = getSavedData();
    data.forEach(k => {
      const match = keybindings.find(kb => kb.name === k.bindTo);
      if (match) match.keys = k.keys;
    });
    keybindings.filter(k => !k.keys).forEach(k => k.keys = k.defaultKeys);
  };

  const getSavedData = () => {
    const dataJson = localStorage.getItem('MKB-data');
    if (!dataJson) return [];
    return JSON.parse(dataJson);
  };

  const onKeyPress = (e) => {
    if (doNotTriggerKeybind(e)) return true;
    if (e.repeat) return true;
    const keysPressed = parseKeypress(toKeys(e));
    if (!keysPressed) return true;

    if (bindingBeingRemapped) {
      if (e.key !== 'Escape') {
        if (e.ctrlKey && e.altKey && e.key === ' ') remap({}, bindingBeingRemapped);
        else remap(toKeys(e), bindingBeingRemapped);
      }
      endListeningForRemap();
      e.preventDefault();
      return false;
    }

    if (!keymap[keysPressed]) return true;

    e.preventDefault();
    keymap[keysPressed]();
    return false;
  };

  const trackCurrentPage = () => {
    if (window.currentPage === undefined) return;
    if (currentPage === cachePage) return;
      
    endListeningForRemap();
    previousPage = cachePage;
    cachePage = currentPage;
  };

  const initialize = () => {
    if (window.kb) return;

    console.log('Initializing Keybindings...');
    loadData();
    updateKeymap();
    document.addEventListener('keydown', onKeyPress);
    inject();
    setInterval(trackCurrentPage, 10);
    console.log('Keybindings initialized.');
  };

  const register = (name, category, defaultKeys, callback) => {
    if (typeof callback !== 'function') throw `Expected type of callback is function, instead found ${typeof callback}.`;

    const conflictingName = keybindings.find(k => k.name === name);
    if (conflictingName) throw `A keybinding with the name "${name}" already exists. Please select another name and try again.`;

    let keys = defaultKeys;
    if (defaultKeys && defaultKeys.key) {
      const conflictingKeys = keybindings.find(k => parseKeypress(k.keys) === parseKeypress(defaultKeys));
      if (conflictingKeys) {
        keys = {};
        console.warn(`A keybinding matching ${parseKeypress(defaultKeys)} already exists. "${name}" will be unbound by default.`);
      }
    }

    const keybinding = { name, category, defaultKeys, keys, callback };
    keybindings.push(keybinding);
    if (settingsGrid !== null) {
      keybinding.row = createRow(keybinding);
      settingsGrid.appendChild(keybinding.row);
    }
    
    const savedData = getSavedData().find(d => d.bindTo === name);
    if (savedData) remap(savedData.keys, keybinding);
    updateKeymap();
  };

  initialize();

  return {
    register,
  };
})();