PSMCX / Web Text Justify

// ==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();
})();