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).
目前為
// ==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; }
*/
`);
}