Logging Handler & UI Overlay

Logger that optionally mirror to a on-page UI overlay shared by every page match-common userscript

此脚本不应直接安装。它是供其他脚本使用的外部库,要使用该库请加入元指令 // @require https://update.greasyfork.org/scripts/588114/1882370/Logging%20Handler%20%20UI%20Overlay.js

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name           Logging Handler & UI Overlay
// @namespace      861ddd094884eac5bea7a3b12e074f34
// @description    Logger that optionally mirror to a on-page UI overlay shared by every page match-common userscript
// @author         Anonymous, Claude Opus 4.8
// @version        1.0.0
// @license        BSD-0
// @grant          none
// ==/UserScript==

(function () {
    'use strict';

    const PANEL_ID = 'logui-panel';
    const STYLE_ID = 'logui-style';
    const BODY_CLASS = 'logui-body';
    const VISIBLE_ATTR = 'data-logui-visible';
    const DEFAULT_MAX_LINES = 500;
    const DEFAULT_PREFS_KEY = 'logui_prefs';
    const LEVELS = ['debug', 'info', 'warn', 'error'];

    const CSS = `
#${PANEL_ID} {
    position: fixed;
    top: 20px;
    right: 40px;
    width: 420px;
    max-width: 22.5vw;
    height: 20vh;
    z-index: 2147483647;
    display: flex;
    flex-direction: column;
    background: rgba(0, 0, 0, 0.10);
    border: 1px solid rgba(255, 255, 255, 0.18);
    border-radius: 6px;
    font: 11px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    /* Click-through, so the panel never steals input from the page beneath. */
    pointer-events: none;
    overflow-y: auto;
    scrollbar-width: none;
}
#${PANEL_ID}::-webkit-scrollbar { display: none; }
#${PANEL_ID}:hover { background: rgba(0, 0, 0, 0.30); }
#${PANEL_ID} .${BODY_CLASS} {
    flex: 1 1 auto;
    min-height: 0;
    padding: 6px 8px;
    word-break: break-word;
    overflow-y: auto;
    scrollbar-width: none;
    /* Dimmed until hovered: legible on demand, unobtrusive otherwise. Opacity
       lives on the body alone so per-level colours stay a single flat value.
       Hover here still satisfies the :hover rule above via propagation, and
       re-enables events so the log can be scrolled. */
    opacity: 0.30;
    pointer-events: auto;
    transition: opacity 120ms ease;
}
#${PANEL_ID} .${BODY_CLASS}:hover { opacity: 1; }
#${PANEL_ID} .logui-line {
    margin: 0 0 3px;
    white-space: pre-wrap;
}
#${PANEL_ID} .logui-time { color: #9aa0a6; margin-right: 6px; }
#${PANEL_ID} .logui-tag { margin-right: 6px; font-weight: 600; }
#${PANEL_ID} .logui-debug { color: #e6e6e6; }
#${PANEL_ID} .logui-info  { color: #6fb3ff; }
#${PANEL_ID} .logui-warn  { color: #ffc857; }
#${PANEL_ID} .logui-error { color: #ff6b6b; }
`;

    // storage
    ///////////

    // The calling script owns the grants, so probe rather than assume. A script
    // that granted neither still gets a working logger, just a forgetful one.
    const hasGet = typeof GM_getValue === 'function';
    const hasSet = typeof GM_setValue === 'function';

    function readPrefs(key) {
        if (!hasGet) return {};
        try {
            const raw = GM_getValue(key, null);
            if (!raw) return {};
            const parsed = JSON.parse(raw);
            return parsed && typeof parsed === 'object' ? parsed : {};
        } catch (e) {
            return {};
        }
    }

    function writePrefs(key, value) {
        if (!hasSet) return false;
        try {
            GM_setValue(key, JSON.stringify(value));
            return true;
        } catch (e) {
            return false;
        }
    }

    // panel
    /////////

    // Whichever instance logs first builds the panel; the rest adopt it. Both
    // lookups are by document id, which is the only channel sandboxed scripts
    // reliably share.
    function ensurePanel() {
        const host = document.body || document.documentElement;
        if (!host) return null;

        let panel = document.getElementById(PANEL_ID);
        if (panel) return panel;

        if (!document.getElementById(STYLE_ID)) {
            const style = document.createElement('style');
            style.id = STYLE_ID;
            style.textContent = CSS;
            (document.head || host).appendChild(style);
        }

        panel = document.createElement('div');
        panel.id = PANEL_ID;
        const body = document.createElement('div');
        body.className = BODY_CLASS;
        panel.appendChild(body);
        host.appendChild(panel);
        return panel;
    }

    function panelBody(panel) {
        return panel ? panel.querySelector('.' + BODY_CLASS) : null;
    }

    // Deterministic hue per tag: the same tag is the same colour in every
    // script and on every page, with no shared allocation table. Saturation and
    // lightness are fixed high enough to stay readable on the dark panel.
    function tagColour(tag) {
        let hash = 0;
        for (let i = 0; i < tag.length; i++) {
            hash = (hash * 31 + tag.charCodeAt(i)) >>> 0;
        }
        return `hsl(${hash % 360}, 65%, 70%)`;
    }

    function formatArg(value) {
        if (typeof value === 'string') return value;
        if (value instanceof Error) return value.stack || String(value);
        try {
            return JSON.stringify(value);
        } catch (e) {
            return String(value);
        }
    }

    // instances
    /////////////

    function create(config) {
        const cfg = config || {};
        const tag = String(cfg.tag || cfg.name || 'log');
        const consolePrefix = cfg.name ? `[${cfg.name}] ` : `[${tag}] `;
        const prefsKey = cfg.prefsKey || DEFAULT_PREFS_KEY;
        const maxLines = cfg.maxLines > 0 ? cfg.maxLines : DEFAULT_MAX_LINES;
        // A surface where the panel makes no sense (a secondary @match, say) can
        // opt this instance out of the panel sink while keeping the console one.
        const panelSink = cfg.panelSink !== false;
        const minLevel = LEVELS.indexOf(cfg.minLevel) >= 0
            ? LEVELS.indexOf(cfg.minLevel) : 0;
        const colour = tagColour(tag);

        const stored = readPrefs(prefsKey);
        let consoleOn = typeof stored.console === 'boolean'
            ? stored.console
            : cfg.console !== false;
        let panelOn = typeof stored.panel === 'boolean'
            ? stored.panel
            : cfg.panel !== false;

        function persist() {
            writePrefs(prefsKey, { console: consoleOn, panel: panelOn });
        }

        // Push this instance's notion of visibility onto the shared node, and
        // stamp it so sibling instances can follow.
        function applyPanel(build) {
            const panel = build ? ensurePanel() : document.getElementById(PANEL_ID);
            if (!panel) return null;
            panel.style.display = panelOn ? 'flex' : 'none';
            if (panel.getAttribute(VISIBLE_ATTR) !== String(panelOn)) {
                panel.setAttribute(VISIBLE_ATTR, String(panelOn));
            }
            return panel;
        }

        // The converse: adopt a sibling's flip. Watching the attribute rather
        // than the style keeps this from firing on our own display writes.
        function watchPanel(panel) {
            if (!panel || !window.MutationObserver) return;
            new MutationObserver(() => {
                const shared = panel.getAttribute(VISIBLE_ATTR) === 'true';
                if (shared === panelOn) return;
                panelOn = shared;
                panel.style.display = panelOn ? 'flex' : 'none';
                persist();
            }).observe(panel, { attributes: true, attributeFilter: [VISIBLE_ATTR] });
        }

        function appendLine(level, args) {
            const panel = applyPanel(true);
            const body = panelBody(panel);
            if (!body) return;

            // Only chase the newest line when the reader is already parked at
            // the bottom; otherwise leave a manual scroll-up undisturbed.
            const atBottom =
                body.scrollHeight - body.scrollTop - body.clientHeight < 4;

            const line = document.createElement('div');
            line.className = 'logui-line logui-' + level;

            const time = document.createElement('span');
            time.className = 'logui-time';
            time.textContent = new Date().toLocaleTimeString();
            line.appendChild(time);

            const label = document.createElement('span');
            label.className = 'logui-tag';
            label.style.color = colour;
            label.textContent = tag;
            line.appendChild(label);

            line.appendChild(document.createTextNode(args.map(formatArg).join(' ')));
            body.appendChild(line);

            // Newest lines settle at the bottom; older ones are trimmed so a
            // long-lived session cannot grow the node list without bound.
            while (body.childElementCount > maxLines) {
                body.removeChild(body.firstChild);
            }
            if (atBottom) body.scrollTop = body.scrollHeight;
        }

        function emit(level, args) {
            if (LEVELS.indexOf(level) < minLevel) return;
            if (consoleOn) {
                // Objects are handed to the console unstringified, so devtools
                // keeps them inspectable; only the panel flattens them.
                const sink = console[level] || console.log;
                sink.call(console, consolePrefix, ...args);
            }
            if (panelOn && panelSink) appendLine(level, args);
        }

        const logger = {
            debug: (...args) => emit('debug', args),
            info: (...args) => emit('info', args),
            warn: (...args) => emit('warn', args),
            error: (...args) => emit('error', args),
            clear() {
                const body = panelBody(document.getElementById(PANEL_ID));
                if (body) body.textContent = '';
            },
            toggleConsole() {
                consoleOn = !consoleOn;
                persist();
                return consoleOn;
            },
            togglePanel() {
                panelOn = !panelOn;
                persist();
                applyPanel(panelOn);
                return panelOn;
            },
            get consoleEnabled() { return consoleOn; },
            set consoleEnabled(on) {
                consoleOn = !!on;
                persist();
            },
            get panelEnabled() { return panelOn; },
            set panelEnabled(on) {
                panelOn = !!on;
                persist();
                applyPanel(panelOn);
            },
            get element() { return document.getElementById(PANEL_ID); },
            // Convenience wiring for the two toggles, skipped silently when the
            // caller did not grant GM_registerMenuCommand.
            registerMenuCommands(opts) {
                if (typeof GM_registerMenuCommand !== 'function') return false;
                const o = opts || {};
                GM_registerMenuCommand(o.consoleLabel || 'Toggle console logging', () => {
                    const on = logger.toggleConsole();
                    // Announce through whichever sink is still live.
                    logger.info(`console logging ${on ? 'enabled' : 'disabled'}`);
                });
                GM_registerMenuCommand(o.panelLabel || 'Toggle log panel', () => {
                    const on = logger.togglePanel();
                    logger.info(`log panel ${on ? 'enabled' : 'disabled'}`);
                });
                return true;
            },
        };
        logger.log = logger.info;

        // Build eagerly -- hidden if this instance's preference says so -- so
        // there is always a node to watch for sibling flips, and so a later
        // toggle has nothing to construct.
        if (panelSink) {
            const panel = applyPanel(true);
            if (panel) {
                watchPanel(panel);
            } else {
                document.addEventListener('DOMContentLoaded', () => {
                    watchPanel(applyPanel(true));
                }, { once: true });
            }
        }

        return logger;
    }

    window.LoggingUI = { create, LEVELS };
})();