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