Table sort columns with ctrl+alt+click

Adds column sorting to any table on any website. Trigger sorting with Ctrl+Alt+Left-Click.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name        Table sort columns with ctrl+alt+click
// @namespace   jjenkx
// @description Adds column sorting to any table on any website. Trigger sorting with Ctrl+Alt+Left-Click.
// @include     *
// @version     1.0
// @license     MIT
// ==/UserScript==

// Handles clicks anywhere on the page and checks if the user clicked inside a table
function clickHandler(event) {
  const table = event.target.closest('table');
  if (!table) return;

  const td = event.target.closest('td, th');
  const isTopRow = td.closest('tr') === table.querySelector('tr');

  const ctrl = event.ctrlKey || event.metaKey;
  const alt = event.altKey;
  const leftClick = event.button === 0;

  // Sorting only happens when the user presses Ctrl + Alt + left click
  if (ctrl && alt && leftClick) {
    sortTableByTd(table, td, isTopRow);
  }
}

// Figures out which column was clicked and triggers sorting
function sortTableByTd(table, td, includeHeader) {
  // Determine exact column index, respecting colSpan if present
  let col = 0;
  for (const tdi of td.parentNode.querySelectorAll('td, th')) {
    if (tdi === td) break;
    col += tdi.colSpan || 1;
  }

  // Determines whether user is sorting the same column again (to reverse order)
  const lastSortCol = parseInt(table.dataset.lastSortCol, 10);
  const lastSortOrder = parseInt(table.dataset.lastSortOrder, 10);
  const order = lastSortCol === col ? -lastSortOrder || -1 : -1;

  table.dataset.lastSortCol = col;
  table.dataset.lastSortOrder = order;

  sortTable(table, col, order, includeHeader);
}

// Takes a table and performs actual sorting on its rows
function sortTable(table, col, order, includeHeader) {
  const trs = Array.from(table.querySelectorAll('tr'));
  const tbody = table.querySelector('tbody') || table;

  // If sorting the top row, include it; otherwise remove header temporarily
  const headerRow = includeHeader ? null : trs.shift();

  // Extract values from the chosen column and classify them by detected data type
  const rowsWithValues = trs.map(tr => {
    const text = colText(tr, col);
    let value = text;
    let valueType = 'string';

    // Detect numbers
    if (isNum(text)) {
      value = parseNum(text);
      valueType = 'number';

    // Detect dates such as "1/5/2023" or "2023-05-12"
    } else if (isDate(text)) {
      value = parseDate(text);
      valueType = 'date';

    // Detect file sizes like "2 MB", "10 GiB", etc.
    } else if (isSize(text)) {
      value = parseSize(text);
      valueType = 'number';
    }

    return { tr, value, valueType };
  });

  // Sorting logic: numeric → date → string
  rowsWithValues.sort((a, b) => {
    if (a.valueType === b.valueType) {
      return (a.value === b.value ? 0 : a.value > b.value ? 1 : -1) * order;
    }
    const typeOrder = { 'number': 1, 'date': 2, 'string': 3 };
    return (typeOrder[a.valueType] - typeOrder[b.valueType]) * order;
  });

  // Reinsert rows in sorted order
  if (!includeHeader && headerRow) tbody.appendChild(headerRow);
  rowsWithValues.forEach(row => tbody.appendChild(row.tr));
}

// Retrieves the text for the chosen column from a row, respecting colSpan
function colText(tr, col) {
  let c = 0;
  for (const td of tr.querySelectorAll('td, th')) {
    c += td.colSpan || 1;
    if (c > col) return clean(td.textContent);
  }
  return '';
}

// Detects whether text is numeric
function isNum(text) {
  return text && !isNaN(text.replace(/,/g, ''));
}

function parseNum(text) {
  return parseFloat(text.replace(/,/g, ''));
}

// Detects simple date formats
function isDate(text) {
  const datePatterns = [/^\d{1,2}\/\d{1,2}\/\d{4}$/, /^\d{4}-\d{1,2}-\d{1,2}$/];
  return datePatterns.some(pattern => pattern.test(text));
}

// Converts date to numeric timestamp
function parseDate(text) {
  const date = new Date(text);
  return !isNaN(date) ? date.getTime() : null;
}

// Detects sizes like "1 MB", "20 GiB", etc.
function isSize(text) {
  const regex = /^\s*\d+(\.\d+)?\s*(B|KB|MB|GB|TB|PB|EB|ZB|YB|KIB|MIB|GIB|TIB|PIB|EIB|ZIB|YIB)\s*$/i;
  return regex.test(text);
}

// Converts size units into bytes
function parseSize(text) {
  const units = {
    'B': 1, 'KB': 1e3, 'MB': 1e6, 'GB': 1e9, 'TB': 1e12, 'PB': 1e15, 'EB': 1e18, 'ZB': 1e21, 'YB': 1e24,
    'KIB': 1024, 'MIB': 1024 ** 2, 'GIB': 1024 ** 3, 'TIB': 1024 ** 4, 'PIB': 1024 ** 5, 'EIB': 1024 ** 6,
    'ZIB': 1024 ** 7, 'YIB': 1024 ** 8
  };
  const match = text.match(/^\s*(\d+(\.\d+)?)\s*([a-zA-Z]+)\s*$/i);
  if (!match) return NaN;
  const [_, num, , unit] = match;
  return parseFloat(num) * (units[unit.toUpperCase()] || 0);
}

// Basic text cleanup for comparisons
function clean(string) {
  return string.trim().toLowerCase().replace(/\s+/g, ' ');
}

// Injects two optional CSS utility classes (not directly used for sorting)
let style = document.createElement('style');
style.innerHTML = `.tt_hidden { display: none; } .tt_stats { background-color: #ffc; }`;
document.head.appendChild(style);

// Global click listener for the entire page
window.addEventListener('mousedown', clickHandler, true);