Torn Quick-Nav Hotkeys

Fully customizable keyboard hotkeys for fast navigation around Torn. Open settings with Alt+, or the floating ⚙️ button.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Torn Quick-Nav Hotkeys
// @namespace    https://www.torn.com
// @version      1.5.3
// @description  Fully customizable keyboard hotkeys for fast navigation around Torn. Open settings with Alt+, or the floating ⚙️ button.
// @author       Ashbrak
// @match        https://www.torn.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @license      MIT
// ==/UserScript==

/* eslint-disable no-multi-spaces */

(function () {
  'use strict';

  // ─── Default preset pages ───────────────────────────────────────────────────
  const PRESET_PAGES = [
    { label: 'Home',               url: '/index.php' },
    { label: 'Gym',                url: '/gym.php' },
    { label: 'Travel',             url: '/page.php?sid=travel' },
    { label: 'Hospital',           url: '/hospitalview.php' },
    { label: 'Jail',               url: '/jailview.php' },
    { label: 'Crimes',             url: '/crimes.php' },
    { label: 'Faction',            url: '/factions.php?step=your' },
    { label: 'Items',              url: '/item.php' },
    { label: 'City / Map',         url: '/city.php' },
    { label: 'Properties',         url: '/properties.php' },
    { label: 'Education',          url: '/education.php' },
    { label: 'Job / Company',      url: '/companies.php' },
    { label: 'Forums',             url: '/forums.php' },
    { label: 'Stock Market',       url: '/page.php?sid=stocks' },
    { label: 'Bank',               url: '/bank.php' },
    { label: 'Casino',             url: '/casino.php' },
    { label: 'View Player Profile',url: '/profiles.php?XID=__USERID__', needsUserId: true },
    { label: 'Attack Player',      url: '/loader.php?sid=attack&user2ID=__USERID__', needsUserId: true },
  ];

  const MODIFIERS = ['None (single key)', 'Alt', 'Ctrl', 'Shift', 'Alt+Shift', 'Ctrl+Shift'];
  const SETTINGS_HOTKEY = { modifier: 'Alt', key: ',' };

  // ─── Load / Save settings ───────────────────────────────────────────────────
  function loadSettings() {
    const raw = GM_getValue('quicknav_settings', null);
    if (raw) return JSON.parse(raw);
    return {
      showButton: true,
      allEnabled: true,
      hotkeys: [],  // [{ label, url, modifier, key, enabled }]
    };
  }

  function saveSettings(s) {
    GM_setValue('quicknav_settings', JSON.stringify(s));
  }

  let settings = loadSettings();

  // ─── Styles ─────────────────────────────────────────────────────────────────
  GM_addStyle(`
    #tqn-fab {
      position: fixed;
      bottom: 20px;
      right: 20px;
      z-index: 999999;
      width: 42px;
      height: 42px;
      border-radius: 50%;
      background: #2c2c2c;
      color: #fff;
      font-size: 20px;
      border: 2px solid #555;
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
      box-shadow: 0 2px 10px rgba(0,0,0,0.5);
      transition: background 0.2s;
      user-select: none;
    }
    #tqn-fab:hover { background: #444; }

    #tqn-overlay {
      position: fixed;
      inset: 0;
      background: rgba(0,0,0,0.6);
      z-index: 1000000;
      display: flex;
      align-items: center;
      justify-content: center;
    }
    #tqn-modal {
      background: #1e1e1e;
      color: #e0e0e0;
      border-radius: 10px;
      width: min(680px, 95vw);
      max-height: 85vh;
      display: flex;
      flex-direction: column;
      box-shadow: 0 8px 40px rgba(0,0,0,0.7);
      font-family: Arial, sans-serif;
      font-size: 14px;
      overflow: hidden;
    }
    #tqn-modal-header {
      padding: 16px 20px;
      background: #141414;
      border-bottom: 1px solid #333;
      display: flex;
      align-items: center;
      justify-content: space-between;
      flex-shrink: 0;
    }
    #tqn-modal-header h2 {
      margin: 0;
      font-size: 16px;
      color: #fff;
    }
    #tqn-modal-header span {
      font-size: 12px;
      color: #888;
    }
    #tqn-modal-body {
      overflow-y: auto;
      padding: 16px 20px;
      flex: 1;
    }
    #tqn-modal-footer {
      padding: 12px 20px;
      background: #141414;
      border-top: 1px solid #333;
      display: flex;
      align-items: center;
      justify-content: space-between;
      flex-shrink: 0;
      gap: 10px;
    }

    .tqn-section-title {
      font-size: 11px;
      text-transform: uppercase;
      color: #888;
      letter-spacing: 0.08em;
      margin: 0 0 10px 0;
    }
    .tqn-hotkey-row {
      display: grid;
      grid-template-columns: 20px 1fr 130px 70px 32px;
      gap: 8px;
      align-items: center;
      margin-bottom: 8px;
    }
    .tqn-hotkey-row.tqn-disabled input,
    .tqn-hotkey-row.tqn-disabled select {
      opacity: 0.35;
    }
    .tqn-hotkey-row input, .tqn-hotkey-row select {
      background: #2a2a2a;
      border: 1px solid #444;
      border-radius: 5px;
      color: #e0e0e0;
      padding: 5px 8px;
      font-size: 13px;
      width: 100%;
      box-sizing: border-box;
    }
    .tqn-hotkey-row input:focus, .tqn-hotkey-row select:focus {
      outline: none;
      border-color: #e3a800;
    }
    .tqn-key-input {
      text-align: center;
      font-weight: bold;
      text-transform: uppercase;
    }
    .tqn-btn-del {
      background: #3a1a1a;
      border: 1px solid #622;
      border-radius: 5px;
      color: #e06060;
      cursor: pointer;
      font-size: 16px;
      width: 32px;
      height: 32px;
      display: flex;
      align-items: center;
      justify-content: center;
      flex-shrink: 0;
    }
    .tqn-btn-del:hover { background: #511; }

    .tqn-add-row {
      display: grid;
      grid-template-columns: 80px 1fr;
      gap: 8px;
      margin-bottom: 16px;
    }
    .tqn-add-row select, .tqn-add-row input {
      background: #2a2a2a;
      border: 1px solid #444;
      border-radius: 5px;
      color: #e0e0e0;
      padding: 5px 8px;
      font-size: 13px;
      width: 100%;
      box-sizing: border-box;
    }

    .tqn-btn {
      padding: 7px 16px;
      border-radius: 6px;
      border: none;
      cursor: pointer;
      font-size: 13px;
      font-weight: bold;
    }
    .tqn-btn-primary {
      background: #e3a800;
      color: #111;
    }
    .tqn-btn-primary:hover { background: #f0b800; }
    .tqn-btn-secondary {
      background: #2a2a2a;
      color: #ccc;
      border: 1px solid #444;
    }
    .tqn-btn-secondary:hover { background: #333; }
    .tqn-btn-add {
      background: #1a3a1a;
      color: #6de06d;
      border: 1px solid #3a6a3a;
      width: 100%;
      margin-bottom: 16px;
    }
    .tqn-btn-add:hover { background: #243a24; }

    .tqn-divider { border: none; border-top: 1px solid #333; margin: 16px 0; }

    .tqn-toggle-row {
      display: flex;
      align-items: center;
      gap: 10px;
      margin-bottom: 14px;
    }
    .tqn-toggle {
      appearance: none;
      width: 36px;
      height: 20px;
      background: #444;
      border-radius: 10px;
      position: relative;
      cursor: pointer;
      transition: background 0.2s;
      flex-shrink: 0;
    }
    .tqn-toggle:checked { background: #e3a800; }
    .tqn-toggle::after {
      content: '';
      position: absolute;
      width: 14px; height: 14px;
      background: #fff;
      border-radius: 50%;
      top: 3px; left: 3px;
      transition: left 0.2s;
    }
    .tqn-toggle:checked::after { left: 19px; }

    /* smaller variant used in hotkey rows */
    .tqn-hk-enabled {
      width: 20px !important;
      height: 12px !important;
      border-radius: 6px !important;
    }
    .tqn-hk-enabled::after {
      width: 8px !important;
      height: 8px !important;
      top: 2px !important;
      left: 2px !important;
    }
    .tqn-hk-enabled:checked::after {
      left: 10px !important;
    }

    .tqn-col-headers {
      display: grid;
      grid-template-columns: 20px 1fr 130px 70px 32px;
      gap: 8px;
      font-size: 11px;
      color: #666;
      text-transform: uppercase;
      letter-spacing: 0.06em;
      margin-bottom: 6px;
    }
    .tqn-toast {
      position: fixed;
      bottom: 80px;
      right: 20px;
      background: #2a2a2a;
      color: #6de06d;
      border: 1px solid #3a6a3a;
      border-radius: 6px;
      padding: 8px 16px;
      font-size: 13px;
      z-index: 1000001;
      animation: tqn-fadein 0.2s ease;
    }
    @keyframes tqn-fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
  `);

  // ─── Floating button (draggable) ─────────────────────────────────────────────
  const fab = document.createElement('div');
  fab.id = 'tqn-fab';
  fab.textContent = '⚙️';
  fab.title = 'Torn Quick-Nav Settings (Alt+,)';

  // Restore saved position or default (higher up from bottom edge)
  const savedPos = JSON.parse(GM_getValue('quicknav_fab_pos', 'null'));
  if (savedPos) {
    fab.style.left   = savedPos.x + 'px';
    fab.style.top    = savedPos.y + 'px';
    fab.style.right  = 'auto';
    fab.style.bottom = 'auto';
  } else {
    fab.style.bottom = '80px';
    fab.style.right  = '20px';
  }

  fab.style.display = settings.showButton ? 'flex' : 'none';
  document.body.appendChild(fab);

  // Drag logic — distinguishes click vs drag so modal still opens on click
  let dragging = false, dragOffX = 0, dragOffY = 0, dragMoved = false;
  fab.addEventListener('mousedown', function (e) {
    dragging = true;
    dragMoved = false;
    const rect = fab.getBoundingClientRect();
    dragOffX = e.clientX - rect.left;
    dragOffY = e.clientY - rect.top;
    fab.style.transition = 'none';
    fab.style.cursor = 'grabbing';
    e.preventDefault();
  });
  document.addEventListener('mousemove', function (e) {
    if (!dragging) return;
    dragMoved = true;
    const x = Math.max(0, Math.min(window.innerWidth  - fab.offsetWidth,  e.clientX - dragOffX));
    const y = Math.max(0, Math.min(window.innerHeight - fab.offsetHeight, e.clientY - dragOffY));
    fab.style.left   = x + 'px';
    fab.style.top    = y + 'px';
    fab.style.right  = 'auto';
    fab.style.bottom = 'auto';
  });
  document.addEventListener('mouseup', function () {
    if (!dragging) return;
    dragging = false;
    fab.style.cursor = 'pointer';
    if (dragMoved) {
      GM_setValue('quicknav_fab_pos', JSON.stringify({
        x: parseInt(fab.style.left), y: parseInt(fab.style.top)
      }));
    }
  });
  fab.addEventListener('click', function () { if (!dragMoved) openModal(); });

  // ─── Modal ───────────────────────────────────────────────────────────────────
  function openModal() {
    if (document.getElementById('tqn-overlay')) return;

    const overlay = document.createElement('div');
    overlay.id = 'tqn-overlay';
    overlay.addEventListener('click', e => { if (e.target === overlay) closeModal(); });

    overlay.innerHTML = `
      <div id="tqn-modal">
        <div id="tqn-modal-header">
          <div style="display:flex; flex-direction:column; gap:3px;">
            <h2 style="margin:0; font-size:16px; color:#fff;">⚡ Torn Quick-Nav Hotkeys</h2>
            <a href="https://www.torn.com/profiles.php?XID=3888401" target="_blank" style="font-size:11px; color:#e3a800; text-decoration:none; opacity:0.6; transition:opacity 0.2s;" onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.6'">by Ashbrak</a>
          </div>
          <span>Alt+, to open anytime</span>
        </div>
        <div id="tqn-modal-body">

          <div class="tqn-section-title">General</div>
          <div class="tqn-toggle-row">
            <input type="checkbox" class="tqn-toggle" id="tqn-show-btn" ${settings.showButton ? 'checked' : ''}>
            <label for="tqn-show-btn">Show floating ⚙️ button on page</label>
          </div>
          <div class="tqn-toggle-row">
            <input type="checkbox" class="tqn-toggle" id="tqn-all-enabled" ${settings.allEnabled !== false ? 'checked' : ''}>
            <label for="tqn-all-enabled">Hotkeys active <span style="color:#666; font-weight:normal; font-size:12px;">(master switch)</span></label>
          </div>

          <hr class="tqn-divider">
          <div class="tqn-section-title">Add a hotkey</div>

          <div class="tqn-add-row">
            <select id="tqn-add-type">
              <option value="preset">Preset</option>
              <option value="custom">Custom URL</option>
            </select>
            <select id="tqn-add-preset">
              ${PRESET_PAGES.map(p => `<option value="${p.url}">${p.label}${p.needsUserId ? ' 🔑' : ''}</option>`).join('')}
            </select>
          </div>
          <div id="tqn-custom-url-row" style="display:none; margin-bottom:8px;">
            <input type="text" id="tqn-custom-label" placeholder="Label (e.g. My Page)" style="margin-bottom:6px;">
            <input type="text" id="tqn-custom-url" placeholder="Full URL or path (e.g. /crimes.php or https://www.torn.com/...)">
          </div>
          <button class="tqn-btn tqn-btn-add" id="tqn-add-btn">+ Add Hotkey</button>

          <hr class="tqn-divider">
          <div class="tqn-section-title">Your hotkeys</div>
          <div class="tqn-col-headers">
            <span>On</span><span>Page / URL</span><span>Modifier</span><span>Key</span><span></span>
          </div>
          <div id="tqn-hotkey-list"></div>

        </div>
        <div id="tqn-modal-footer">
          <div style="font-size:12px; color:#666;">Changes auto-save</div>
          <div style="display:flex; gap:8px;">
            <button class="tqn-btn tqn-btn-secondary" id="tqn-close-btn">Close</button>
            <button class="tqn-btn tqn-btn-primary" id="tqn-save-btn">Save & Close</button>
          </div>
        </div>
      </div>
    `;

    document.body.appendChild(overlay);
    renderHotkeyList();

    // Type toggle
    document.getElementById('tqn-add-type').addEventListener('change', function () {
      const isCustom = this.value === 'custom';
      document.getElementById('tqn-add-preset').style.display = isCustom ? 'none' : '';
      document.getElementById('tqn-custom-url-row').style.display = isCustom ? 'grid' : 'none';
      document.getElementById('tqn-custom-url-row').style.gridTemplateColumns = '1fr';
      document.getElementById('tqn-custom-url-row').style.gap = '6px';
    });

    document.getElementById('tqn-add-btn').addEventListener('click', () => {
      const type = document.getElementById('tqn-add-type').value;
      let label, url;
      if (type === 'preset') {
        const sel = document.getElementById('tqn-add-preset');
        const preset = PRESET_PAGES.find(p => p.url === sel.value);
        url   = preset.url;
        label = preset.label;
        if (preset.needsUserId) {
          const uid = prompt(`Enter the Torn user ID for "${label}":`);
          if (!uid || !uid.trim()) return;
          url = url.replace('__USERID__', uid.trim());
          label = `${label} (${uid.trim()})`;
        }
      } else {
        label = document.getElementById('tqn-custom-label').value.trim();
        url = document.getElementById('tqn-custom-url').value.trim();
        if (!label || !url) { alert('Please fill in both label and URL.'); return; }
      }
      settings.hotkeys.push({ label, url, modifier: 'None (single key)', key: '', enabled: true });
      saveSettings(settings);
      renderHotkeyList();
    });

    document.getElementById('tqn-show-btn').addEventListener('change', function () {
      settings.showButton = this.checked;
      fab.style.display = this.checked ? 'flex' : 'none';
      saveSettings(settings);
    });

    document.getElementById('tqn-all-enabled').addEventListener('change', function () {
      settings.allEnabled = this.checked;
      saveSettings(settings);
    });

    document.getElementById('tqn-close-btn').addEventListener('click', closeModal);
    document.getElementById('tqn-save-btn').addEventListener('click', () => {
      collectHotkeyEdits();
      saveSettings(settings);
      showToast('Hotkeys saved!');
      closeModal();
    });
  }

  function closeModal() {
    const overlay = document.getElementById('tqn-overlay');
    if (overlay) overlay.remove();
  }

  function renderHotkeyList() {
    const list = document.getElementById('tqn-hotkey-list');
    if (!list) return;
    list.innerHTML = '';

    if (settings.hotkeys.length === 0) {
      list.innerHTML = '<div style="color:#666; font-size:13px; padding: 8px 0;">No hotkeys yet. Add one above!</div>';
      return;
    }

    settings.hotkeys.forEach((hk, i) => {
      const enabled = hk.enabled !== false;
      const row = document.createElement('div');
      row.className = 'tqn-hotkey-row' + (enabled ? '' : ' tqn-disabled');
      row.dataset.index = i;
      row.innerHTML = `
        <input type="checkbox" class="tqn-toggle tqn-hk-enabled" ${enabled ? 'checked' : ''} title="Enable/disable this hotkey">
        <input type="text" class="tqn-hk-label" value="${escHtml(hk.label)}" placeholder="Label or URL" title="${escHtml(hk.url)}">
        <select class="tqn-hk-modifier">
          ${MODIFIERS.map(m => `<option value="${m}" ${hk.modifier === m ? 'selected' : ''}>${m}</option>`).join('')}
        </select>
        <input type="text" class="tqn-hk-key tqn-key-input" maxlength="1" value="${escHtml(hk.key)}" placeholder="key">
        <button class="tqn-btn-del" data-del="${i}" title="Remove">×</button>
      `;
      list.appendChild(row);
    });

    list.querySelectorAll('.tqn-hk-enabled').forEach(cb => {
      cb.addEventListener('change', function () {
        const row = this.closest('.tqn-hotkey-row');
        row.classList.toggle('tqn-disabled', !this.checked);
        collectHotkeyEdits();
        saveSettings(settings);
      });
    });

    list.querySelectorAll('.tqn-btn-del').forEach(btn => {
      btn.addEventListener('click', () => {
        collectHotkeyEdits();
        const idx = parseInt(btn.dataset.del);
        settings.hotkeys.splice(idx, 1);
        saveSettings(settings);
        renderHotkeyList();
      });
    });

    // Capture single keypress for key field
    list.querySelectorAll('.tqn-hk-key').forEach(input => {
      input.addEventListener('keydown', function (e) {
        e.preventDefault();
        if (e.key.length === 1) this.value = e.key.toUpperCase();
      });
    });
  }

  function collectHotkeyEdits() {
    const list = document.getElementById('tqn-hotkey-list');
    if (!list) return;
    list.querySelectorAll('.tqn-hotkey-row').forEach(row => {
      const i = parseInt(row.dataset.index);
      if (!settings.hotkeys[i]) return;
      settings.hotkeys[i].label    = row.querySelector('.tqn-hk-label').value.trim();
      settings.hotkeys[i].modifier = row.querySelector('.tqn-hk-modifier').value;
      settings.hotkeys[i].key      = row.querySelector('.tqn-hk-key').value.trim().toUpperCase();
      settings.hotkeys[i].enabled  = row.querySelector('.tqn-hk-enabled').checked;
    });
  }

  // ─── Keyboard listener ───────────────────────────────────────────────────────
  function isTyping() {
    const el = document.activeElement;
    if (!el) return false;
    const tag = el.tagName.toLowerCase();
    if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
    if (el.isContentEditable) return true;
    if (tag === 'iframe') return true;
    // Check if focus is inside a contenteditable (e.g. rich text editor)
    if (el.closest && el.closest('[contenteditable="true"]')) return true;
    return false;
  }
  document.addEventListener('keydown', function (e) {
    if (isTyping()) return;

    // Settings hotkey: Alt+,
    if (e.altKey && e.key === ',') {
      e.preventDefault();
      if (document.getElementById('tqn-overlay')) closeModal();
      else openModal();
      return;
    }

    for (const hk of settings.hotkeys) {
      if (!hk.key) continue;
      if (hk.enabled === false) continue;
      if (settings.allEnabled === false) continue;
      const mod = hk.modifier || 'Alt';
      const keyMatch = e.key.toUpperCase() === hk.key.toUpperCase();
      const modMatch = checkModifier(e, mod);
      if (keyMatch && modMatch) {
        e.preventDefault();
        window.location.href = resolveUrl(hk.url);
        return;
      }
    }
  });

  function checkModifier(e, mod) {
    switch (mod) {
      case 'None (single key)': return !e.altKey && !e.ctrlKey && !e.shiftKey;
      case 'Alt':        return e.altKey && !e.ctrlKey && !e.shiftKey;
      case 'Ctrl':       return e.ctrlKey && !e.altKey && !e.shiftKey;
      case 'Shift':      return e.shiftKey && !e.altKey && !e.ctrlKey;
      case 'Alt+Shift':  return e.altKey && e.shiftKey && !e.ctrlKey;
      case 'Ctrl+Shift': return e.ctrlKey && e.shiftKey && !e.altKey;
      default:           return false;
    }
  }

  // ─── URL normalizer — accepts full URL or path, always navigates correctly ───
  function resolveUrl(raw) {
    raw = raw.trim();
    if (raw.startsWith('https://www.torn.com')) return raw; // full URL — use as-is
    if (raw.startsWith('http'))                 return raw; // external URL — use as-is
    // path only — prepend origin
    if (!raw.startsWith('/')) raw = '/' + raw;
    return 'https://www.torn.com' + raw;
  }
  function showToast(msg) {
    const t = document.createElement('div');
    t.className = 'tqn-toast';
    t.textContent = msg;
    document.body.appendChild(t);
    setTimeout(() => t.remove(), 2000);
  }

  function escHtml(s) {
    return String(s ?? '').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
  }

})();