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 widget).

目前為 2025-09-02 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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 widget).
// @version     1.12
// @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 = `
  /* 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}));

/* ───── 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 (Jump-aware) ───── */
document.getElementById('js-XFUniqueId1')?.remove();

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

  const btn = document.createElement('div');
  btn.className = 'p-navgroup-link gm-navStyleBtn';
  btn.textContent = 'Style';

  /* ─── NEW insertion rule ─── */
  const firstLink = navOpposite.querySelector('.p-navgroup-link');
  if (firstLink && firstLink.textContent.trim() === 'Jump') {
    firstLink.after(btn);                    // place to the right of "Jump"
  } else {
    navOpposite.insertBefore(btn, navOpposite.firstChild); // default: prepend
  }

  btn.addEventListener('click', togglePopup);
}

/* ───── 5. Build popup once ───── */
let popup;
popup = buildPopup();
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)), 14, 18);

  const num = Object.assign(document.createElement('input'), {
    type: 'number', step: '0.1', min: '14', max: '18', 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)));

  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, 14, 18);
    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';
});

/* ───── 9. Utility ───── */
function clamp(v, min, max) { return v < min ? min : v > max ? max : v; }

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

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;
    });
  }
}


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);
    const bg = toRGB("rgb(39, 39, 39)");

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

    // const clamp = v => Math.max(0, Math.min(255, v));
    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] - 39);
    positiveDiff += Math.max(0, fg[1] - 39);
    positiveDiff += Math.max(0, fg[2] - 39);

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

      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 = '';
      }

      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);
        } else if (SERIF.test(font)) {
          style.fontFamily = 'serif';
        }
      }

      if (style.textAlign) {
        if (style.textAlign !== 'center' && style.textAlign !== 'right') {
          style.textAlign = '';
        }
      }

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

// 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; }
    */
  `);
}