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/1899921/Logging%20Handler%20%20UI%20Overlay.js을(를) 사용하여 포함하는 라이브러리입니다.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 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.5.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. panelOnly is the mirror image:
        // for a variant of a line the console already received in another form
        // (a redacted copy, say), so the pair never double-prints anywhere and
        // no caller has to flip consoleEnabled — whose setter persists —
        // around a single emit.
        function emit(level, args, only) {
            if (LEVELS.indexOf(level) < minLevel) return;
            if (consoleOn && only !== 'panel') {
                // 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 && only !== 'console') 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, 'console'),
                info: (...args) => emit('info', args, 'console'),
                warn: (...args) => emit('warn', args, 'console'),
                error: (...args) => emit('error', args, 'console'),
            },
            panelOnly: {
                debug: (...args) => emit('debug', args, 'panel'),
                info: (...args) => emit('info', args, 'panel'),
                warn: (...args) => emit('warn', args, 'panel'),
                error: (...args) => emit('error', args, 'panel'),
            },
            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 },
    };
})();