Logging Handler & UI Overlay

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

Tento skript by neměl být instalován přímo. Jedná se o knihovnu, kterou by měly jiné skripty využívat pomocí meta příkazu // @require https://update.greasyfork.org/scripts/588114/1882370/Logging%20Handler%20%20UI%20Overlay.js

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

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

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

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

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

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