Logging Handler & UI Overlay

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

Este script não deve ser instalado diretamente. É uma biblioteca destinada a ser incluída por outros scripts através da diretiva de metadados // @require https://update.greasyfork.org/scripts/588114/1893327/Logging%20Handler%20%20UI%20Overlay.js

Terá de instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

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

Terá de instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Terá de instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Terá de instalar uma extensão como Tampermonkey para instalar este script.

Terá de instalar uma extensão de gestão de scripts de utilizador para instalar este script.

(Já tenho um gestor de scripts de utilizador, deixe-me instalá-lo!)

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

(Já tenho um gestor de estilos de utilizador, deixe-me instalá-lo!)

// ==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.4.0
// @license        BSD-0
// @grant          none
// ==/UserScript==

(function () {
    'use strict';

    const PANEL_ID = 'logui-panel';
    const RESTORE_ID = 'logui-restore';
    const STYLE_ID = 'logui-style';
    const BODY_CLASS = 'logui-body';
    const LOG_CLASS = 'logui-log';
    const VISIBLE_ATTR = 'data-logui-visible';
    // Panel visibility lives on documentElement, not on the panel node: it is
    // the one surface every sandbox shares *and* which exists before any
    // logger does, so a controller script with no logger of its own (the
    // logging-ui controls shim) can flip every instance at once. The panel
    // node keeps VISIBLE_ATTR mirrored for backwards compatibility with 1.0.x
    // instances that may still be loaded alongside.
    const STATE_ATTR = 'data-logui-panel';
    // Presence marker so the shim can tell a page with loggers from one
    // without, and register its menu command only on the former.
    const CONSUMERS_ATTR = 'data-logui-consumers';
    // Minimised is a page-wide state for the same reason visibility is, but a
    // separate one: minimising must not disturb the persisted on/off preference.
    const MIN_ATTR = 'data-logui-minimized';
    const DEFAULT_MAX_LINES = 500;
    // Grace period before a hand-resized log viewer springs back to its default
    // height, long enough that briefly leaving the panel does not undo the drag.
    const RESIZE_RESET_MS = 3000;
    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;
    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: hidden;
}
#${PANEL_ID}:hover { background: rgba(0, 0, 0, 0.30); }
#${PANEL_ID} .logui-header {
    flex: 0 0 auto;
    display: flex;
    justify-content: flex-end;
    gap: 4px;
    padding: 3px 4px;
    border-bottom: 1px solid rgba(255, 255, 255, 0.12);
    /* Matches the log region's dimming so the chrome never reads as louder
       than the content it frames. */
    opacity: 0.30;
    transition: opacity 120ms ease;
}
#${PANEL_ID}:hover .logui-header { opacity: 1; }
.logui-btn {
    /* Buttons are the only part of the overlay that must accept clicks. */
    pointer-events: auto;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 14px;
    height: 14px;
    padding: 0;
    color: #cad0c6;
    background: rgba(0, 0, 0, 0.35);
    border: 1px solid rgba(255, 255, 255, 0.30);
    border-radius: 3px;
    font: inherit;
    line-height: 1;
    cursor: pointer;
}
.logui-btn:hover {
    color: #fff;
    background: rgba(0, 0, 0, 0.60);
}
#${RESTORE_ID} {
    position: fixed;
    top: 20px;
    right: 40px;
    z-index: 2147483647;
    display: none;
    width: 16px;
    height: 16px;
    opacity: 0.30;
    transition: opacity 120ms ease;
}
#${RESTORE_ID}:hover { opacity: 1; }
#${PANEL_ID} .${LOG_CLASS} {
    position: relative;
    height: 20vh;
    /* resize needs a non-visible overflow; the inner body does the scrolling
       so the native grip stays pinned to the region's corner. */
    overflow: hidden;
    resize: vertical;
    min-height: 3em;
    /* Dimmed until hovered: legible on demand, unobtrusive otherwise. Opacity
       lives here alone so per-level colours stay a single flat value. Hover
       also satisfies the panel :hover rule via propagation, and re-enables
       events so the log can be scrolled. */
    opacity: 0.30;
    pointer-events: auto;
    transition: opacity 120ms ease;
}
#${PANEL_ID} .${LOG_CLASS}:hover { opacity: 1; }
#${PANEL_ID} .${BODY_CLASS} {
    height: 100%;
    padding: 6px 8px;
    word-break: break-word;
    overflow-y: auto;
    scrollbar-width: none;
}
#${PANEL_ID} .${BODY_CLASS}::-webkit-scrollbar { display: none; }
#${PANEL_ID} .logui-jump {
    position: absolute;
    right: 6px;
    width: 16px;
    height: 16px;
    border-radius: 50%;
    background: rgba(0, 0, 0, 0.25);
}
#${PANEL_ID} .logui-jump-top { top: 6px; }
#${PANEL_ID} .logui-jump-bottom { bottom: 6px; }
#${PANEL_ID} .logui-line {
    margin: 0 0 3px;
    white-space: pre-wrap;
}
#${PANEL_ID} .logui-time { color: #cad0c6; 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; }

/* Mobile: a 22.5vw panel is unreadably narrow on a phone, so span the viewport
   less a small margin and scale the type down until the canonical worst-case
   line — "8:09:20 AM" + a four-character tag + a full append message, ~53
   monospace columns plus 12px of inter-span margin — fits on one line.
   Solving 53 * 0.6 * fs + 12 <= 100vw - 42 (margin + padding + borders) gives
   the divisor below; 11px stays the ceiling on wider touch screens.
   Height holds five entries at up to two lines each, capped at a third of the
   viewport. */
@media (hover: none) and (pointer: coarse) {
    #${PANEL_ID} {
        top: 12px;
        right: 12px;
        width: calc(100vw - 24px);
        max-width: calc(100vw - 24px);
        font-size: min(11px, calc((100vw - 56px) / 32));
    }
    #${PANEL_ID} .${LOG_CLASS} { height: min(33vh, calc(14.5em + 27px)); }
    #${RESTORE_ID} { top: 12px; right: 12px; }
}
`;

    // 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;
        }
    }

    // shared state
    ////////////////

    // An absent attribute means nobody has expressed an opinion on this page
    // yet, so the caller falls back to its own persisted preference.
    function readShared() {
        const root = document.documentElement;
        if (!root || !root.hasAttribute(STATE_ATTR)) return null;
        return root.getAttribute(STATE_ATTR) === 'true';
    }

    function writeShared(on) {
        const root = document.documentElement;
        if (!root) return;
        if (root.getAttribute(STATE_ATTR) !== String(on)) {
            root.setAttribute(STATE_ATTR, String(on));
        }
    }

    // Tags accumulate so the shim can name what it is controlling, and so a
    // second instance with the same tag does not widen the list.
    function announce(tag) {
        const root = document.documentElement;
        if (!root) return;
        const seen = (root.getAttribute(CONSUMERS_ATTR) || '')
            .split(/\s+/).filter(Boolean);
        if (seen.indexOf(tag) >= 0) return;
        seen.push(tag);
        root.setAttribute(CONSUMERS_ATTR, seen.join(' '));
    }

    function isMinimized() {
        const root = document.documentElement;
        return !!root && root.getAttribute(MIN_ATTR) === 'true';
    }

    function setMinimized(on) {
        const root = document.documentElement;
        if (root) root.setAttribute(MIN_ATTR, String(on));
        refreshVisibility();
    }

    // Single arbiter of what is on screen: the panel when enabled and expanded,
    // the restore stub when enabled and minimised, neither when disabled.
    function refreshVisibility() {
        const on = readShared() !== false;
        const min = isMinimized();
        const panel = document.getElementById(PANEL_ID);
        const restore = document.getElementById(RESTORE_ID);
        if (panel) panel.style.display = on && !min ? 'flex' : 'none';
        if (restore) restore.style.display = on && min ? 'flex' : 'none';
    }

    function watchShared(onChange) {
        const root = document.documentElement;
        if (!root || !window.MutationObserver) return;
        new MutationObserver(() => {
            const shared = readShared();
            if (shared !== null) onChange(shared);
        }).observe(root, { attributes: true, attributeFilter: [STATE_ATTR] });
    }

    // 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 header = document.createElement('div');
        header.className = 'logui-header';
        header.appendChild(button('logui-minimize', '−', 'Minimise log panel',
            () => setMinimized(true)));
        header.appendChild(button('logui-close', '✕', 'Close log panel', () => {
            // Closing also clears the minimised flag, so re-enabling the panel
            // from the menu brings back the panel rather than the stub.
            const root = document.documentElement;
            if (root) root.setAttribute(MIN_ATTR, 'false');
            writeShared(false);
            refreshVisibility();
        }));
        panel.appendChild(header);

        const log = document.createElement('div');
        log.className = LOG_CLASS;
        const body = document.createElement('div');
        body.className = BODY_CLASS;
        log.appendChild(body);
        log.appendChild(button('logui-jump logui-jump-top', '↑',
            'Jump to top', () => { body.scrollTop = 0; }));
        log.appendChild(button('logui-jump logui-jump-bottom', '↓',
            'Jump to bottom', () => { body.scrollTop = body.scrollHeight; }));
        panel.appendChild(log);

        bindResizeReset(log);
        bindMiddleClickPassthrough(panel, body);
        host.appendChild(panel);

        const restore = button('', '+', 'Reopen log panel', () => setMinimized(false));
        restore.id = RESTORE_ID;
        host.appendChild(restore);

        return panel;
    }

    function button(className, glyph, title, onClick) {
        const el = document.createElement('button');
        el.type = 'button';
        el.className = ('logui-btn ' + className).trim();
        el.textContent = glyph;
        el.title = title;
        el.addEventListener('click', (event) => {
            event.preventDefault();
            event.stopPropagation();
            onClick();
        });
        return el;
    }

    // A hand-dragged height is treated as a temporary affordance, not a
    // preference: once the pointer has been away long enough to mean the reader
    // is done, drop the inline height and let the stylesheet default return.
    function bindResizeReset(log) {
        let timer = null;
        log.addEventListener('mouseenter', () => {
            if (timer !== null) {
                clearTimeout(timer);
                timer = null;
            }
        });
        log.addEventListener('mouseleave', () => {
            if (timer !== null) clearTimeout(timer);
            timer = setTimeout(() => {
                timer = null;
                log.style.height = '';
            }, RESIZE_RESET_MS);
        });
    }

    // The body keeps pointer-events enabled so log lines stay selectable and
    // scrollable, which also swallows middle clicks aimed at the page beneath.
    // Firefox only opens a new tab for a *trusted* middle click, so a synthetic
    // auxclick on the element underneath would do nothing; instead resolve the
    // link ourselves and open it. Bound on the panel, once, by whichever
    // instance built it.
    function bindMiddleClickPassthrough(panel, body) {
        function elementUnder(x, y) {
            const prev = body.style.pointerEvents;
            body.style.pointerEvents = 'none';
            const hit = document.elementFromPoint(x, y);
            body.style.pointerEvents = prev;
            return hit && !panel.contains(hit) ? hit : null;
        }

        function linkUnder(event) {
            const hit = elementUnder(event.clientX, event.clientY);
            const anchor = hit && hit.closest ? hit.closest('a[href]') : null;
            if (!anchor) return null;
            const href = typeof anchor.href === 'string'
                ? anchor.href
                : anchor.getAttribute('href');
            if (!href || /^javascript:/i.test(href)) return null;
            return new URL(href, document.baseURI).href;
        }

        // Suppressing the default here also kills Firefox's autoscroll cursor,
        // which would otherwise fire before auxclick.
        panel.addEventListener('mousedown', (event) => {
            if (event.button !== 1) return;
            if (linkUnder(event)) event.preventDefault();
        }, true);

        panel.addEventListener('auxclick', (event) => {
            if (event.button !== 1) return;
            const href = linkUnder(event);
            if (!href) return;
            event.preventDefault();
            event.stopPropagation();
            if (typeof GM_openInTab === 'function') {
                GM_openInTab(href, { active: false, insert: true });
            } else {
                window.open(href, '_blank', 'noopener');
            }
        }, true);
    }

    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;
        // Precedence: a page-wide choice already in force, then this script's
        // own persisted one, then the caller's default.
        const shared = readShared();
        let panelOn = shared !== null
            ? shared
            : typeof stored.panel === 'boolean'
                ? stored.panel
                : cfg.panel !== false;
        announce(tag);

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

        // Push this instance's notion of visibility onto documentElement, so
        // sibling instances and the controls shim follow, then reflect it on
        // the panel node itself.
        function applyPanel(build) {
            writeShared(panelOn);
            if (!panelSink) return null;
            const panel = build ? ensurePanel() : document.getElementById(PANEL_ID);
            if (!panel) return null;
            refreshVisibility();
            if (panel.getAttribute(VISIBLE_ATTR) !== String(panelOn)) {
                panel.setAttribute(VISIBLE_ATTR, String(panelOn));
            }
            return panel;
        }

        // The converse: adopt a flip made anywhere else on the page. Only the
        // display is touched here — rebuilding via applyPanel would write the
        // attribute straight back and re-enter this observer.
        function watchPanel() {
            watchShared((on) => {
                if (on === panelOn) return;
                panelOn = on;
                refreshVisibility();
                persist();
            });
        }

        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;
        }

        // consoleOnly keeps a line out of the overlay without keeping it out of
        // devtools — for payloads that would drown the panel but are the whole
        // point of having the console sink on.
        function emit(level, args, consoleOnly) {
            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 && !consoleOnly) 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),
            consoleOnly: {
                debug: (...args) => emit('debug', args, true),
                info: (...args) => emit('info', args, true),
                warn: (...args) => emit('warn', args, true),
                error: (...args) => emit('error', args, true),
            },
            clear() {
                const body = panelBody(document.getElementById(PANEL_ID));
                if (body) body.textContent = '';
            },
            toggleConsole() {
                consoleOn = !consoleOn;
                persist();
                return consoleOn;
            },
            togglePanel() {
                panelOn = !panelOn;
                if (panelOn && isMinimized()) setMinimized(false);
                persist();
                applyPanel(panelOn);
                return panelOn;
            },
            get consoleEnabled() { return consoleOn; },
            set consoleEnabled(on) {
                consoleOn = !!on;
                persist();
            },
            get panelEnabled() { return panelOn; },
            set panelEnabled(on) {
                panelOn = !!on;
                if (panelOn && isMinimized()) setMinimized(false);
                persist();
                applyPanel(panelOn);
            },
            get element() { return document.getElementById(PANEL_ID); },
            // Convenience wiring for the panel toggle, skipped silently when
            // the caller did not grant GM_registerMenuCommand. Since 1.1.0 the
            // controls shim owns this command page-wide; a consumer calling it
            // here just duplicates the entry. Console output is a per-script
            // concern now and has no command.
            registerMenuCommands(opts) {
                if (typeof GM_registerMenuCommand !== 'function') return false;
                const o = opts || {};
                GM_registerMenuCommand(o.panelLabel || 'Toggle log panel', () => {
                    const on = logger.togglePanel();
                    logger.info(`log panel ${on ? 'enabled' : 'disabled'}`);
                });
                return true;
            },
        };
        logger.log = logger.info;

        // Watch first and unconditionally: documentElement exists from
        // document-start, and a panel-less instance still has to keep
        // panelEnabled honest for whatever settings UI reads it.
        watchPanel();

        // Build eagerly -- hidden if the prevailing preference says so -- so a
        // later toggle has nothing to construct.
        if (panelSink && !applyPanel(true)) {
            document.addEventListener(
                'DOMContentLoaded', () => applyPanel(true), { once: true });
        }

        return logger;
    }

    // ATTRS is published for the controls shim, which has to reason about the
    // page's loggers without owning one of them.
    window.LoggingUI = {
        create,
        LEVELS,
        ATTRS: { state: STATE_ATTR, consumers: CONSUMERS_ATTR },
    };
})();