Global Black

A global black dark mode

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

You will need to install an extension such as Tampermonkey to install this script.

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

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

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

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

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Global Black
// @namespace    github.com/annaroblox
// @version      2.1
// @description  A global black dark mode
// @author       annaroblox
// @match        */*
// @grant        none
// @run-at       document-start
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  // --- CONFIGURATION ---
  const LIGHT_BACKGROUND_THRESHOLD = 400;
  const DARK_GREY_BG_THRESHOLD = 500;
  const DARK_TEXT_THRESHOLD = 128;

  const TARGET_BACKGROUND_COLOR = "#000000";
  const TARGET_TEXT_COLOR = "#FFFFFF";
  const TARGET_BORDER_COLOR = "#000000"; // change this if you want borders to be distinct

  const IGNORED_TAGS = ["IMG", "PICTURE", "VIDEO", "CANVAS", "SVG"];

  // --- IMMEDIATE STYLE INJECTION (RUNS BEFORE DOM IS READY) ---
  // This is the most important part for an instant effect and preventing a "flash of white".
  const style = document.createElement("style");
  style.id = "pure-black-mode-global-style";
  style.textContent = `
        /* Force dark scrollbars and form controls for a consistent experience */
        :root {
            color-scheme: dark !important;
        }
        /* Instantly apply to the base elements to prevent flash of white */
        html, #text, body, mt-sm, recent-posts,  article, header, footer, nav, main, aside,
        ul, ol, li, dl, table,  tr, td, overlay, label, #content, theme-auto,  th, thead, tbody, tfoot, style-scope,
        form, fieldset, button, section {
            background-color: ${TARGET_BACKGROUND_COLOR} !important;
            background: ${TARGET_BACKGROUND_COLOR} !important;
            color: ${TARGET_TEXT_COLOR} !important;
        }
        /* Handle syntax highlighting blocks gracefully */
        pre, code {
           background-color: #000000 !important;
           color: #D4D4D4 !important;
        }
    `;
  // Using document.documentElement ensures this runs as early as possible.
  document.documentElement.appendChild(style);

  // --- SCRIPT LOGIC (RUNS ONCE DOM IS INTERACTIVE) ---

  // A single, temporary div is used for all color computations to avoid DOM thrashing.
  const tempDiv = document.createElement("div");
  tempDiv.style.display = "none";
  document.documentElement.appendChild(tempDiv);

  // Cache for memoizing color lightness calculations to boost performance.
  const colorLightnessCache = new Map();

  /**
   * Calculates the "lightness" of a CSS color string, with caching.
   * @param {string} colorString - The CSS color (e.g., "rgb(255, 255, 255)", "#FFF", "white").
   * @returns {number} A lightness value from 0 (black) to 255 (white), or -1 if invalid/transparent.
   */
  function getColorLightness(colorString) {
    if (
      !colorString ||
      colorString === "none" ||
      colorString.includes("inherit") ||
      colorString.includes("initial") ||
      colorString.includes("unset")
    ) {
      return -1;
    }

    // Return from cache if value already computed.
    if (colorLightnessCache.has(colorString)) {
      return colorLightnessCache.get(colorString);
    }

    // Use the temporary div to resolve the color to a consistent rgb() format.
    tempDiv.style.color = colorString;
    const computedColor = window.getComputedStyle(tempDiv).color;

    if (computedColor === "rgba(0, 0, 0, 0)" || !computedColor) {
      colorLightnessCache.set(colorString, -1); // Cache transparent/invalid colors.
      return -1;
    }

    const match = computedColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
    let result = -1;
    if (match) {
      const [r, g, b] = [
        parseInt(match[1]),
        parseInt(match[2]),
        parseInt(match[3]),
      ];
      // Using a simple average is fast and sufficient for this script's purpose.
      result = (r + g + b) / 3;
    }

    colorLightnessCache.set(colorString, result);
    return result;
  }

  /**
   * The core function that processes a single element.
   * @param {HTMLElement} element - The DOM element to process.
   */
  function processElement(element) {
    // Basic checks to quickly exit for invalid or already-processed elements.
    if (
      !element ||
      element.nodeType !== 1 ||
      IGNORED_TAGS.includes(element.tagName)
    ) {
      return;
    }

    const style = window.getComputedStyle(element);

    // Ignore elements that are not visible.
    if (style.display === "none" || style.visibility === "hidden") {
      return;
    }

    const bgLightness = getColorLightness(style.backgroundColor);

    if (bgLightness === -1) return; // Skip transparent backgrounds, they'll inherit the parent's black.

    const isLight = bgLightness > LIGHT_BACKGROUND_THRESHOLD;
    const isDarkGrey = bgLightness > 0 && bgLightness < DARK_GREY_BG_THRESHOLD;

    if (isLight || isDarkGrey) {
      // If the element has no background image, we can safely use the 'background' shorthand property.
      // This is more powerful and overrides combined properties like `background: linear-gradient(...) #FFF;`.
      if (style.backgroundImage === "none") {
        element.style.setProperty(
          "background",
          TARGET_BACKGROUND_COLOR,
          "important",
        );
      } else {
        // If it has a background image, only change the color to avoid removing the image.
        element.style.setProperty(
          "background-color",
          TARGET_BACKGROUND_COLOR,
          "important",
        );
      }

      // Adjust text color for readability if it's dark.
      const textLightness = getColorLightness(style.color);
      if (textLightness !== -1 && textLightness < DARK_TEXT_THRESHOLD) {
        element.style.setProperty("color", TARGET_TEXT_COLOR, "important");
      }

      // Adjust border colors.
      const borderTargets = [
        "border-color",
        "border-top-color",
        "border-right-color",
        "border-bottom-color",
        "border-left-color",
      ];
      for (const prop of borderTargets) {
        const borderLightness = getColorLightness(style[prop]);
        // Check if the border is not already dark. Removed redundant '> 0' check.
        if (borderLightness > DARK_GREY_BG_THRESHOLD) {
          element.style.setProperty(prop, TARGET_BORDER_COLOR, "important");
        }
      }
    }
  }

  /**
   * Traverses a node and its children (including inside Shadow DOMs) to apply the black mode.
   * @param {Node} rootNode - The starting node (usually document.body or a new element).
   */
  function applyBlackModeToTree(rootNode) {
    if (!rootNode || typeof rootNode.querySelectorAll !== "function") {
      return;
    }

    // Process the root node itself first (important for single added nodes and shadow roots).
    if (rootNode.nodeType === 1) {
      processElement(rootNode);
    }

    const elements = rootNode.querySelectorAll("*");
    elements.forEach((el) => {
      processElement(el);
      // If an element has a shadow root, we need to recursively process its contents too.
      if (el.shadowRoot) {
        applyBlackModeToTree(el.shadowRoot);
      }
    });
  }

  /**
   * Applies the dark theme logic to a given document (e.g., the main document or an iframe's document).
   * @param {Document} doc - The document to process.
   */
  function applyThemeToDocument(doc) {
    if (
      !doc ||
      !doc.documentElement ||
      doc.documentElement.dataset.globalBlackApplied
    ) {
      return;
    }
    console.log(
      "Global Black: Applying theme to new document...",
      doc.location?.href,
    );
    doc.documentElement.dataset.globalBlackApplied = "true";

    // 1. Inject the main style sheet into the new document.
    const newStyle = doc.createElement("style");
    newStyle.id = "pure-black-mode-global-style-injected";
    newStyle.textContent = style.textContent; // `style` is the global style from the parent script.
    doc.documentElement.appendChild(newStyle);

    // 2. Run a full conversion on the new document's body.
    applyBlackModeToTree(doc.documentElement);

    // 3. Set up a new observer for dynamic content within that document.
    const newObserver = new MutationObserver((mutations) => {
      (doc.defaultView || window).requestAnimationFrame(() => {
        // Use iframe's rAF if available
        for (const mutation of mutations) {
          if (mutation.type === "childList") {
            processNewlyAddedNodes(mutation.addedNodes);
          } else if (mutation.type === "attributes") {
            if (mutation.target) {
              applyBlackModeToTree(mutation.target);
            }
          }
        }
      });
    });

    newObserver.observe(doc.documentElement, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: ["class", "style"],
    });

    // When the iframe's window unloads, disconnect the observer.
    doc.defaultView?.addEventListener(
      "unload",
      () => {
        newObserver.disconnect();
        console.log(
          "Global Black: Cleaned up observer for document:",
          doc.location?.href,
        );
      },
      { once: true },
    );
  }

  /**
   * Finds and processes embeddable elements like iframes, frames, and objects.
   * @param {HTMLElement} element - The embeddable element to process.
   */
  function processEmbed(element) {
    if (element.dataset.globalBlackEmbedProcessed) {
      return;
    }
    element.dataset.globalBlackEmbedProcessed = "true";

    const setup = () => {
      try {
        const contentDoc = element.contentDocument;
        if (contentDoc) {
          applyThemeToDocument(contentDoc);
        }
      } catch (e) {
        console.warn(
          "Global Black: Could not access embed content. It may be cross-origin.",
          element,
        );
      }
    };

    try {
      const contentDoc = element.contentDocument;
      if (contentDoc && contentDoc.readyState === "complete") {
        setup();
      } else {
        element.addEventListener("load", setup, { once: true });
      }
    } catch (e) {
      console.warn(
        "Global Black: Could not access embed on initial check. It may be cross-origin.",
        element,
      );
    }
  }

  /**
   * Processes a list of newly added DOM nodes and their descendants.
   * This function is typically called by the MutationObserver.
   * @param {NodeList} nodes - A list of nodes that have been added to the DOM.
   */
  function processNewlyAddedNodes(nodes) {
    nodes.forEach((node) => {
      applyBlackModeToTree(node);

      if (node.nodeType === 1) {
        const tagName = node.tagName.toUpperCase();
        if (["IFRAME", "FRAME", "EMBED", "OBJECT"].includes(tagName)) {
          processEmbed(node);
        }
        node
          .querySelectorAll?.("iframe, frame, embed, object")
          .forEach(processEmbed);
      }
    });
  }

  function runFullConversion() {
    console.log("Global Black: Running full page conversion...");
    applyBlackModeToTree(document.documentElement);
    document
      .querySelectorAll("iframe, frame, embed, object")
      .forEach(processEmbed);
  }

  // --- OBSERVER FOR DYNAMIC CONTENT ---
  // This is the key to handling modern, dynamic websites.
  const observer = new MutationObserver((mutations) => {
    // Use requestAnimationFrame to batch all mutations that happen in a single frame.
    // This prevents performance issues and ensures the script doesn't miss anything
    // on pages that add many elements at once.
    window.requestAnimationFrame(() => {
      for (const mutation of mutations) {
        if (mutation.type === "childList") {
          // When new nodes are added, process them and all their children.
          processNewlyAddedNodes(mutation.addedNodes);
        } else if (mutation.type === "attributes") {
          // If an element's class or style changes, its color might have changed.
          // Re-run the process on that single element AND ITS DESCENDANTS.
          // This is crucial because a class/style change on a parent can affect children's computed styles.
          if (mutation.target) {
            applyBlackModeToTree(mutation.target); // Changed from processElement
          }
        }
      }
    });
  });

  // --- INITIALIZATION ---
  // The main styles are already injected. Now we wait for the DOM to be ready for deep traversal.
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", runFullConversion, {
      once: true,
    });
  } else {
    // If the script is injected after the page is loaded (e.g., via console).
    runFullConversion();
  }

  // --- ADDED: Re-run the conversion after all resources have loaded ---
  // This catches elements that are styled or loaded by JS after DOMContentLoaded.
  window.addEventListener("load", runFullConversion);

  // Start observing for changes after the initial conversion.
  observer.observe(document.documentElement, {
    childList: true,
    subtree: true,
    attributes: true,
    attributeFilter: ["class", "style"], // Only watch for attributes that are likely to affect appearance.
  });

  // Clean up when the user navigates away or closes the tab.
  window.addEventListener("unload", () => {
    if (observer) {
      observer.disconnect();
    }
    // Also remove the new load listener
    window.removeEventListener("load", runFullConversion);

    if (tempDiv && tempDiv.parentNode) {
      tempDiv.parentNode.removeChild(tempDiv);
    }
    if (style && style.parentNode) {
      style.parentNode.removeChild(style);
    }
    console.log("Global Black: Cleaned up and disconnected.");
  });
})();