AH/QQ/SB/SV Mobile readability

Standardize font size, revert justified text, replace fonts with sans-serif/serif/monospace, dodge colored text from the background and lightly pastelize, detect centered text and add Uncenter toggle. Auto-hiding navbar sticky with style button (Light/Dark style selector + font size + padding widget).

当前为 2025-09-02 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name        AH/QQ/SB/SV Mobile readability
// @description Standardize font size, revert justified text, replace fonts with sans-serif/serif/monospace, dodge colored text from the background and lightly pastelize, detect centered text and add Uncenter toggle. Auto-hiding navbar sticky with style button (Light/Dark style selector + font size + padding widget).
// @version     1.21
// @author      C89sd
// @namespace   https://greasyfork.org/users/1376767
// @match       https://*.spacebattles.com/*
// @match       https://*.sufficientvelocity.com/*
// @match       https://*.questionablequesting.com/*
// @match       https://*.alternatehistory.com/*
// @grant       GM_addStyle
// @grant       GM_getValue
// @grant       GM_setValue
// @noframes
// ==/UserScript==

'use strict';

// ======================== Style + Font selector + Sticky Scroll Nav ======================== //

/* ───── 1. Site-specific style IDs + path ───── */
const site = location.hostname.split('.').slice(-2, -1)[0];
const [LIGHT_ID, DARK_ID] = {
  spacebattles        : [ 6,  2],
  sufficientvelocity  : [19, 20],
  questionablequesting: [ 1, 31],
  alternatehistory    : [13, 15]
}[site];

const STYLE_PATH = site === 'alternatehistory' ? '/forum/misc/style' : '/misc/style';

/* ───── 2. CSS ───── */
let css = `
  .p-nav { border-top: none !important; }
  .gm-navStyleBtn{
    cursor:pointer;
    -webkit-user-select:none;  /* stop long-press text selection */
    user-select:none;
  }
  /* popup */
  #gmStylePopup{
    position:fixed;top:64px;right:20px;background:#fff;color:#000;
    border:1px solid #666;border-radius:8px;padding:16px;z-index:9999;
    box-shadow:0 4px 14px rgba(0,0,0,.25);display:none;
    flex-direction:column;gap:16px;min-width:280px;font-size:14px}
  @media (max-width:600px){#gmStylePopup{transform:scale(1.1);transform-origin:top right}}

  /* Light / Dark tiles */
  .gm-tile-row{display:flex;width:100%;gap:8px}
  .gm-style-tile{
    flex:1 1 50%;height:60px;line-height:60px;font-size:1.05em;
    border-radius:6px;text-align:center;cursor:pointer;user-select:none;
    border:1px solid #666;text-decoration:none}
  .gm-light{background:#eaeaea;color:#000}.gm-light:hover{background:#dbdbdb}
  .gm-dark {background:#222;color:#fff}.gm-dark:hover {background:#2d2d2d}

  /* Font-size block */
  .gm-font-row{display:flex;align-items:center;gap:6px;justify-content:center}
  .gm-font-row button{
    width:41.1px;height:50px;font-size:15px;cursor:pointer;
    border:1px solid #666;border-radius:6px;background:#f5f5f5;color:#000}
  .gm-font-row button:hover{background:#e4e4e4}
  .gm-font-row input{
    width:70px;height:50px;text-align:center;font-size:16px;
    border:1px solid #666;border-radius:6px}

  .gmStickyNav{
    position:sticky;
    top:0;                   /* docks here once we reach it          */
    z-index:1010;
    will-change:transform;   /* we only translate it, no repaint     */
  }
`;
document.head.appendChild(Object.assign(document.createElement('style'), {textContent: css}));

/* ───── HELPER FUNCTIONS (MOVED UP FOR CORRECT INITIALIZATION ORDER) ───── */

function clamp(v, min, max) { return v < min ? min : v > max ? max : v; }

const colorCache = new Map();
const adjustTextColor = (textRGBStr) => {
    if (colorCache.has(textRGBStr)) {
        return colorCache.get(textRGBStr);
    }

    function toRGB(str) { // can return up to 3 values [] to [r,g,b]
        const nums = str.match(/\d+/g);
        return nums ? nums.map(Number).slice(0, 3) : [];
    }

    let fg = toRGB(textRGBStr);
    // The background color for comparison is hardcoded here (rgb(39, 39, 39)).
    // Consider making this dynamic based on the actual page background if needed.
    const bg = [39, 39, 39]; // Represents the dark background color value (from "rgb(39, 39, 39)")

    if (fg.length !== 3) { return textRGBStr }

    const pastelize = ([r, g, b]) => {
        const neutral = 128;
        const whiteBoost = Math.pow((r + g + b) / (3 * 255), 10);
        const factor = 0.8 + 0.1*whiteBoost;
        return [
            neutral + factor * (r - neutral),
            neutral + factor * (g - neutral),
            neutral + factor * (b - neutral)
        ].map(clamp);
    };

    fg = pastelize(fg);

    let positiveDiff = 0;
    positiveDiff += Math.max(0, fg[0] - bg[0]);
    positiveDiff += Math.max(0, fg[1] - bg[1]);
    positiveDiff += Math.max(0, fg[2] - bg[2]);

    const threshold = 180;
    if (positiveDiff < threshold) {
      fg[0] = Math.max(fg[0], bg[0]);
      fg[1] = Math.max(fg[1], bg[1]);
      fg[2] = Math.max(fg[2], bg[2]);

      const avg = (fg[0]+fg[1]+fg[2])/3;
      const boost = Math.min(1.0, (Math.abs(fg[0]-avg)+Math.abs(fg[1]-avg)+Math.abs(fg[2]-avg))/255/0.2); // grays = 0, colors >= 1

      let correction = Math.round((threshold - positiveDiff) / 3 + boost*40);
      fg = fg.map(c => Math.min(c + correction, 255));
    }

    const result = `rgb(${fg[0]}, ${fg[1]}, ${fg[2]})`;
    colorCache.set(textRGBStr, result);

    return result;
};

function applyFontSize(px) {
  px = px + 'px'

  const SANS_SERIF = /arial|tahoma|trebuchet ms|verdana/;
  const MONOSPACE = /courier new/;
  const SERIF = /times new roman|georgia|book antiqua/;

  const scale = (val, factor) => (parseFloat(val) * factor).toFixed(2) + "px";

  const wrappers = document.getElementsByClassName('bbWrapper');
  for (let wrapper of wrappers) {
    wrapper.style.fontSize = px;

    const children = wrapper.querySelectorAll('*');
    for (let child of children) {
      const style = child.style;

      if (style.fontSize) {
        style.fontSize = ''; // Reset explicit font sizes for consistency
      }

      if (style.fontFamily) {
        const font = style.fontFamily;
        if (SANS_SERIF.test(font)) {
          style.fontFamily = 'sans-serif';
        } else if (MONOSPACE.test(font)) {
          style.fontFamily = 'monospace';
          style.fontSize = scale(px, 0.9); // Make monospace slightly smaller
        } else if (SERIF.test(font)) {
          style.fontFamily = 'serif';
        }
      }

      if (style.textAlign) {
        // Keep 'center' and 'right', clear others for readability
        if (style.textAlign !== 'center' && style.textAlign !== 'right') {
          style.textAlign = '';
        }
      }

      if (style.color && style.color.startsWith('rgb')) {
        style.color = adjustTextColor(style.color);
      }
    }
  }
}

function applyPadding(px) {
  px = clamp(px, 0, 80) + 'px';
  const cells = document.querySelectorAll('.message-cell.message-cell--main');
  for (const cell of cells) {
    cell.style.paddingLeft = px;
    cell.style.paddingBottom = px;
  }
}
/* ───── END HELPER FUNCTIONS ───── */


/* ───── Sticky bar injection ───── */

/* 1.  pick the nav and keep only our class */
const sticky =
      document.querySelector('.p-navSticky.p-navSticky--primary') ||
      document.querySelector('.p-nav');

sticky.classList.add('gmStickyNav');
sticky.classList.remove('p-navSticky', 'p-navSticky--primary', 'is-sticky');
sticky.removeAttribute('data-xf-init');

/* 2.  choose algorithm */
const useOld = location.origin .includes('alternatehistory');

/* 3.  state common to both algorithms */
let barH   = sticky.offsetHeight;
let offset = 0;
let lastY  = scrollY;

/* ──────────────────────────────────────────────────────────────
   A) OLD algorithm  (once-only measurement of page position)
────────────────────────────────────────────────────────────────*/
if (useOld) {

  /* a.   measure the element’s true document position */
  function measure() {
    const prev = sticky.style.position;
    sticky.style.position = 'static';                       // neutralise sticky
    const originTop = sticky.getBoundingClientRect().top + scrollY;
    sticky.style.position = prev;                           // restore
    return { originTop, h: sticky.offsetHeight };
  }

  let { originTop } = measure();

  function onScrollOld() {
    const y = scrollY;

    /* still above the slot → keep bar fully visible */
    if (y < originTop) {
      offset = 0;
      sticky.style.transform = '';
      lastY = y;
      return;
    }

    /* bar is docked → pixel-for-pixel retract / reveal */
    const dy = y - lastY;        // + down, – up
    lastY    = y;
    offset   = clamp(offset + dy, 0, barH);
    sticky.style.transform = `translateY(${-offset}px)`;
  }

  addEventListener('scroll',  onScrollOld, { passive:true });
  addEventListener('resize', () => { ({ originTop, h:barH } = measure()); });

}
/* ──────────────────────────────────────────────────────────────
   B) NEW algorithm  (live getBoundingClientRect check)
────────────────────────────────────────────────────────────────*/
else {

  let stuck = false;   // “is the bar currently touching the viewport top?”

  function onScrollNew() {
    const y    = scrollY;
    const top  = sticky.getBoundingClientRect().top;  // distance to viewport top

    /* bar still in normal flow */
    if (top > 0) {
      if (stuck) {
        stuck  = false;
        offset = 0;
        sticky.style.transform = '';
      }
      lastY = y;
      return;
    }

    /* bar just became sticky */
    if (!stuck) {
      stuck = true;
      lastY = y;        // reset baseline → no initial jump
      return;
    }

    /* regular retract / reveal */
    const dy = y - lastY;  // + down, – up
    lastY    = y;
    offset   = clamp(offset + dy, 0, barH);
    sticky.style.transform = `translateY(${-offset}px)`;
  }

  addEventListener('scroll',  onScrollNew, { passive:true });
  addEventListener('resize', () => { barH = sticky.offsetHeight; });
}

/* 4.  Remove old XF button, add our own to the right of Jump */

let Tbtn = document.querySelector('.p-nav-opposite > div[data-xf-click="sv-font-size-chooser-form"]')
Tbtn?.remove();

const navOpposite = document.querySelector('#top .p-nav-opposite');
if (navOpposite) {
  navOpposite.querySelector('#js-XFUniqueId1')?.closest('div')?.remove();

  /* ─── NEW: use <a> instead of <div> ─── */
  const btn = document.createElement('div');
  btn.className = 'p-navgroup-link gm-navStyleBtn';
  btn.setAttribute('role', 'button');     // accessibility
  btn.textContent = 'Style';

  const firstLink = navOpposite.querySelector('.p-navgroup-link');
  if (firstLink && firstLink.textContent.trim() === 'Jump')
    firstLink.after(btn);
  else
    navOpposite.insertBefore(btn, navOpposite.firstChild);

  btn.onclick = (event) => {
    event.preventDefault();
    event.stopPropagation();
    togglePopup();
  };
}
/* ───── 5. Build popup once ───── */
let popup;
popup = buildPopup(); // This call now happens *after* `applyFontSize` is defined
document.body.appendChild(popup);

/* ───── 6. Popup builder ───── */
function buildPopup() {
  const p = Object.assign(document.createElement('div'), { id: 'gmStylePopup' });

  /* Light / Dark */
  const tiles = Object.assign(document.createElement('div'), { className: 'gm-tile-row' });
  tiles.append(makeTile('Light', LIGHT_ID, 'gm-light'),
               makeTile('Dark',  DARK_ID,  'gm-dark'));
  p.appendChild(tiles);

  /* Font size */
  const fsRow = Object.assign(document.createElement('div'), { className: 'gm-font-row' });
  const stored = clamp(Number(GM_getValue('fontSize', 15)), 10, 25);

  const num = Object.assign(document.createElement('input'), {
    type: 'number', step: '0.1', min: '10', max: '25', value: stored.toFixed(1)
  });
  fsRow.append(
    makeFSBtn('-1',  -1),  makeFSBtn('-0.1', -.1),
    num,
    makeFSBtn('+0.1',  .1),makeFSBtn('+1',    1)
  );
  p.appendChild(fsRow);

  applyFS(stored);
  num.addEventListener('change', () => applyFS(parseFloat(num.value)));

  /* Padding size */
  const padRow = Object.assign(document.createElement('div'), { className: 'gm-font-row' });
  const storedPad = clamp(Number(GM_getValue('paddingSize', 10)), 0, 80);

  const padNum = Object.assign(document.createElement('input'), {
    type: 'number', step: '0.1', min: '0', max: '80', value: storedPad.toFixed(1)
  });
  padRow.append(
    makePadBtn('-1',  -1),  makePadBtn('-0.1', -.1),
    padNum,
    makePadBtn('+0.1',  .1),makePadBtn('+1',    1)
  );
  p.appendChild(padRow);

  applyPad(storedPad);
  padNum.addEventListener('change', () => applyPad(parseFloat(padNum.value)));

  function makePadBtn(txt, delta) {
    const b = document.createElement('button');
    b.textContent = txt;
    b.addEventListener('click', () => applyPad(parseFloat(padNum.value) + delta));
    return b;
  }
  function applyPad(px) {
    px = clamp(Math.round(px * 10) / 10, 0, 80);
    padNum.value = px.toFixed(1);
    GM_setValue('paddingSize', px);
    applyPadding(px);
  }

  function makeFSBtn(txt, delta) {
    const b = document.createElement('button');
    b.textContent = txt;
    b.addEventListener('click', () => applyFS(parseFloat(num.value) + delta));
    return b;
  }
  function applyFS(px) {
    px = clamp(Math.round(px * 10) / 10, 10, 25);
    num.value = px.toFixed(1);
    GM_setValue('fontSize', px);
    applyFontSize(px);
  }
  return p;
}

/* ───── 7. Style tiles ───── */
function makeTile(label, id, cls) {
  const token = document.querySelector('input[name="_xfToken"]')?.value || '';
  const url   = `${STYLE_PATH}?style_id=${id}&_xfRedirect=/&t=${encodeURIComponent(token)}`;

  const a = Object.assign(document.createElement('a'), {
    className: `gm-style-tile ${cls}`,
    textContent: label,
    href: url
  });
  a.addEventListener('click', ev => {
    ev.preventDefault();
    fetch(url, { credentials: 'same-origin', mode: 'same-origin' })
      .then(r => { if (!r.ok) throw 0; return r.text(); })
      .then(() => location.reload())
      .catch(() => (location.href = url));
  });
  return a;
}

/* ───── 8. Popup show / hide ───── */
function togglePopup() {
  popup.style.display = popup.style.display === 'flex' ? 'none' : 'flex';
}
document.addEventListener('click', e => {
  if (!popup.contains(e.target) && !e.target.closest('.gm-navStyleBtn'))
    popup.style.display = 'none';
});

// ========================================================== //

const IS_THREAD = document.URL.includes('/threads/');
if (!IS_THREAD) return;

// Centered text toggle
const OP = document.querySelector('.username.u-concealed')?.textContent || '!';
const messages = document.getElementsByClassName('message');
let foundCentered = false;
for (let message of messages) {
  if (message.getAttribute('data-author') === OP || message.classList.contains('hasThreadmark')) {
    const centeredDiv = message.querySelector('.bbWrapper:first-of-type > div[style*="text-align: center"], .bbWrapper > div.uncentered');
    if (centeredDiv) {
      foundCentered = true;
      break;
    }
  }
}
if (foundCentered) {
  let buttonGrp = document.querySelector('.block-outer-threadmarks .buttonGroup');
  let centered = true;
  if (buttonGrp) {
    buttonGrp.insertAdjacentHTML('afterbegin', '<div class="buttonGroup buttonGroup-buttonWrapper"><span class="button--link button" style="cursor:pointer" title="Toggle center text">Uncenter</span></div>');
    buttonGrp.firstElementChild.firstElementChild.addEventListener('click', function() {
      document.querySelectorAll(centered ? '.bbWrapper > div[style*="text-align: center"]' : '.bbWrapper > div.uncentered').forEach(el => {
        if (centered) {
          el.classList.add('uncentered');
          el.style.textAlign = '';
        } else {
          el.style.textAlign = 'center';
        }
      });
      centered = !centered;
    });
  }
}

// Smaller tables
GM_addStyle(`
.bbTable {
  font-size: 13.5px;
  overflow: scroll;
  table-layout: auto;
}
.bbTable td {
  vertical-align: top;
  white-space: normal;
  word-wrap: normal;
}
`);

// SB Weekly stats thread
if (document.URL.includes('.1140820/')) {
  // Fixed 2/3 columns
  const w = {2: '50%', 3: '33.33%'};
  for (const t of document.querySelectorAll('.bbTable table')) {
    const c = t.rows[0]?.cells, n = c?.length;
    if (w[n]) {
      t.style.cssText = 'table-layout:fixed;width:100%';
      for (const cell of c) cell.style.width = w[n];
    }
  }
  GM_addStyle(`
    /* Indicator */
    .bbTable td >  b:first-of-type:has(code.bbCodeInline) { display: inline-block; padding: 2px 0; }
    .bbTable td >  b:first-of-type code.bbCodeInline { font-size: 11px; }

    .bbTable span:has(.username) { font-size: 11.5px; } /* Name */
    .bbTable span:has(.bbc-abbr) { font-size: 12.5px; } /* Metric */

    /* Bottom Tags */
    .bbTable td > span:last-child:has(>code.bbCodeInline) { display: inline-flex; flex-wrap: wrap; gap: 1px; padding-top: 6px; }
    .bbTable td > span:last-child > code.bbCodeInline { font-size: 9px; padding: 1px 3px; }

    /* Inline break with sliced decorations
    .bbTable td > span:last-child:has(>code.bbCodeInline) { display: inline-block; padding-top: 2px; }
    .bbTable td > span:last-child > code.bbCodeInline { margin: 1px 1px; box-decoration-break: slice; }
    */
  `);
}