NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name Web Text Justify
// @name:en Web Text Justify
// @namespace https://github.com/yourname/web-text-justify
// @version 1.1.3
// @description 将网页正文文本两端对齐(text-align: justify),类似 Word 排版;中文排版优化(text-justify: inter-ideograph);排除代码块/表格/标题/输入控件/已对齐元素;支持 iframe 与 Shadow DOM;油猴菜单一键开关。
// @description:en Justify web page body text like Word. Chinese typography (inter-ideograph). Skips code blocks/tables/headings/inputs/aligned elements. Handles iframe & Shadow DOM. Toggle via userscript menu.
// @author PSMCX
// @match *://*/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @run-at document-end
// @license MIT
// ==/UserScript==
(function () {
'use strict';
/* ==================== 配置区(按需修改) ==================== */
const CONFIG = {
mainSelectors:
'article, main, [role="main"], .content, .post, .post-content, .entry-content, .article-content, .article-body, #content, .opus-module-content',
fallbackToBody: false,
fallbackSkipTags: ['HEADER', 'NAV', 'FOOTER', 'ASIDE'],
storageKey: 'justifyEnabled',
defaultEnabled: true,
};
const FALLBACK_SKIP_TAGS = new Set(CONFIG.fallbackSkipTags);
const SKIP_TAGS = new Set([
'PRE', 'CODE', 'KBD', 'SAMP', 'VAR', 'TT',
'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
'TABLE', 'TR', 'TD', 'TH', 'THEAD', 'TBODY', 'TFOOT', 'CAPTION', 'COL', 'COLGROUP',
'INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'OPTION', 'OPTGROUP', 'DATALIST', 'KEYGEN', 'METER', 'PROGRESS', 'OUTPUT',
'SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'IFRAME', 'OBJECT', 'EMBED',
'SVG', 'CANVAS', 'VIDEO', 'AUDIO', 'IMG', 'PICTURE', 'SOURCE', 'TRACK', 'MAP', 'AREA', 'HR', 'BR', 'WBR',
]);
const CODE_CLASS_RE = /\b(?:code|codeblock|code-block|highlight|hljs|language-|prettyprint|prism|syntax|terminal)\b/i;
// 文本级元素:computed justify 可能只是继承自被本脚本置为 justify 的容器,
// 而非页面自身样式;这类元素应显式打上内联样式,防止页面后续加载的 CSS
// (如 Docusaurus 异步样式块)覆盖继承值,造成"生效后又失效"。
const TEXT_ELEMENTS = new Set(['P', 'LI', 'DD', 'DT', 'BLOCKQUOTE', 'FIGCAPTION']);
/* ==================== 状态 ==================== */
let enabled;
let menuCommandId = null; // 保存菜单ID,用于注销
try {
enabled = GM_getValue(CONFIG.storageKey, CONFIG.defaultEnabled);
} catch (e) {
enabled = CONFIG.defaultEnabled;
}
const styledEls = new Set();
const observedShadowRoots = new WeakSet();
const shadowObservers = [];
const isTopFrame = (() => {
try { return window.top === window.self; } catch (e) { return true; }
})();
// 重新渲染油猴菜单
function renderMenu() {
if (!isTopFrame) return;
// 注销上一次菜单
if (menuCommandId !== null) {
try { GM_unregisterMenuCommand(menuCommandId); } catch (e) {}
}
const label = enabled ? "Web Text Justify: On" : "Web Text Justify: Off";
menuCommandId = GM_registerMenuCommand(label, toggle);
}
/* ==================== 分类判断 ==================== */
function classify(el) {
if (SKIP_TAGS.has(el.tagName)) return 'skip';
if (el.dataset.justifySkip !== undefined) return 'skip';
if (el.hasAttribute('align')) return 'skip';
const role = (el.getAttribute('role') || '').toLowerCase();
if (role === 'heading' || role === 'textbox' || role === 'combobox' || role === 'listbox' || role === 'menu') return 'skip';
if (el.isContentEditable) return 'skip';
const cs = getComputedStyle(el);
// 已对齐(center/right/justify):不要覆盖,但要继续下探——
// 祖先刚被置为 justify 后子元素会"继承"justify,若在此返回 'skip'
// 遍历会停住,带显式 text-align:left 规则的子元素(如 docs.python.org 的
// `div.body p { text-align:left }`)就永远不会被修正。
if (isAligned(cs)) {
// 文本级元素例外:继承而来的 justify 也应打内联样式,见 TEXT_ELEMENTS 注释。
if (cs.textAlign === 'justify' && TEXT_ELEMENTS.has(el.tagName)) return 'style';
return 'pass';
}
if (cs.display === 'none' || cs.display === 'inline' || cs.display === 'contents') return 'pass';
return 'style';
}
function isAligned(cs) {
const ta = cs.textAlign;
if (ta === 'center' || ta === 'right' || ta === 'justify') return true;
if (ta === 'end') return cs.direction !== 'rtl';
return false;
}
/* ==================== 遍历 Shadow DOM ==================== */
function walk(root, cb) {
const stack = [root];
while (stack.length) {
const node = stack.pop();
if (node.nodeType !== 1) continue;
if (cb(node) === 'skip') continue;
const children = node.children;
for (let i = children.length - 1; i >= 0; i--) stack.push(children[i]);
if (node.shadowRoot) {
ensureShadowObserver(node.shadowRoot);
for (let i = node.shadowRoot.children.length - 1; i >= 0; i--) {
stack.push(node.shadowRoot.children[i]);
}
}
}
}
/* ==================== 应用 / 移除样式 ==================== */
function applyJustify(root) {
if (!enabled) return;
walk(root, (el) => {
const action = classify(el);
if (action === 'style') {
el.style.setProperty('text-align', 'justify', 'important');
el.style.setProperty('text-justify', 'inter-ideograph', 'important');
styledEls.add(el);
}
return action;
});
}
function removeJustify() {
for (const el of styledEls) {
el.style.removeProperty('text-align');
el.style.removeProperty('text-justify');
}
styledEls.clear();
}
function getContainers() {
let list = [];
try {
list = Array.from(document.querySelectorAll(CONFIG.mainSelectors));
} catch (err) {
console.warn('[网页两端对齐] 正文选择器无效:', CONFIG.mainSelectors, err);
}
if (!list.length) {
if (CONFIG.fallbackToBody && document.body) {
list = [document.body];
} else {
console.info('[网页两端对齐] 未匹配到正文区域(可修改 CONFIG.mainSelectors,或开启 fallbackToBody)');
}
}
return list;
}
function applyAll() {
if (!enabled) return;
for (const c of getContainers()) applyJustify(c);
}
/* ==================== MutationObserver 动态内容 ==================== */
// 待处理节点必须跨批次累积:React 水合失败重建整棵树时会在极短时间
// 内产生大量批次,若每批各自 clearTimeout 重建定时器,前一批的节点集合
// 会被直接丢弃,最后只剩某一批(如 <head> 里的 LINK)被处理,重建后的
// 正文永远不会被样式化——表现为"短暂生效后失效"。
let debounceTimer = null;
let pendingNodes = new Set();
let pendingSince = 0;
const DEBOUNCE_MS = 150;
const FORCE_AFTER_MS = 1500; // 连续 mutation 流(如无限滚动)下强制处理一次
const docObserver = new MutationObserver(onMutations);
function processPending() {
debounceTimer = null;
const pending = pendingNodes;
pendingNodes = new Set();
pendingSince = 0;
if (!enabled) return;
let hasMain = false;
try { hasMain = !!document.querySelector(CONFIG.mainSelectors); } catch (e) {}
for (const node of pending) {
if (!node.isConnected) continue;
if (isWithinMainArea(node, hasMain)) {
applyJustify(node);
continue;
}
if (!hasMain) continue;
// 新增节点可能是正文容器的"祖先":React 水合失败重建整棵树时,
// 新树常作为某个外层 DIV 的后代被整体插入,新增节点本身不在容器
// 内,但它的子树里含容器——对子树中的每个容器直接应用样式。
let inners = [];
try { inners = node.querySelectorAll ? Array.from(node.querySelectorAll(CONFIG.mainSelectors)) : []; } catch (e) {}
for (const c of inners) applyJustify(c);
}
}
function onMutations(mutations) {
for (const m of mutations) {
for (const n of m.addedNodes) {
if (n.nodeType === 1) pendingNodes.add(n);
}
}
if (!pendingNodes.size) return;
const now = Date.now();
if (!pendingSince) pendingSince = now;
if (debounceTimer !== null && now - pendingSince >= FORCE_AFTER_MS) {
// mutation 持续不断,定时器一直被重置,先强制处理一次
clearTimeout(debounceTimer);
processPending();
return;
}
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => processPending(), DEBOUNCE_MS);
}
function isWithinMainArea(el, hasMain) {
if (hasMain) return !!climb(el, (n) => n.matches(CONFIG.mainSelectors));
if (!CONFIG.fallbackToBody) return false;
return !climb(el, (n) => FALLBACK_SKIP_TAGS.has(n.tagName));
}
function climb(el, test) {
let cur = el;
while (cur) {
if (cur.nodeType === 1 && test(cur)) return cur;
const parent = cur.parentNode;
if (parent && parent.nodeType === 11) cur = parent.host;
else cur = parent;
}
return null;
}
function ensureShadowObserver(shadowRoot) {
if (observedShadowRoots.has(shadowRoot)) return;
observedShadowRoots.add(shadowRoot);
const obs = new MutationObserver(onMutations);
obs.observe(shadowRoot, { childList: true, subtree: true });
shadowObservers.push(obs);
}
/* ==================== 开关逻辑 ==================== */
function setEnabled(value, opts = {}) {
if (enabled === value) return;
enabled = value;
if (opts.save !== false) {
try { GM_setValue(CONFIG.storageKey, enabled); } catch (e) {}
}
renderMenu(); // 更新菜单文字
if (enabled) applyAll();
else removeJustify();
if (opts.relay !== false) broadcastToIframes();
console.info('[网页两端对齐] 已' + (enabled ? '开启' : '关闭'));
}
function toggle() { setEnabled(!enabled); }
function broadcastToIframes() {
const msg = { __webJustify: 'set', enabled };
for (const f of document.querySelectorAll('iframe')) {
try { f.contentWindow.postMessage(msg, '*'); } catch (e) {}
}
}
function onMessage(e) {
const d = e.data;
if (!d || typeof d !== 'object' || typeof d.__webJustify !== 'string') return;
if (d.__webJustify === 'set') {
setEnabled(Boolean(d.enabled), { save: false, relay: true });
} else if (d.__webJustify === 'ask') {
try { e.source.postMessage({ __webJustify: 'reply', enabled }, '*'); } catch (err) {}
} else if (d.__webJustify === 'reply') {
setEnabled(Boolean(d.enabled), { save: false, relay: false });
}
}
/* ==================== 初始化 ==================== */
function init() {
if (!document.documentElement) return;
docObserver.observe(document.documentElement, { childList: true, subtree: true });
window.addEventListener('message', onMessage);
applyAll();
if (isTopFrame) {
renderMenu();
} else {
try { window.parent.postMessage({ __webJustify: 'ask' }, '*'); } catch (e) {}
}
}
init();
})();