您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
DOM/FPS индикатор: перетаскивание, двойной клик — компактный кружок, позиция и состояние сохраняются; спарклайн ms/кадр (опц.)
// ==UserScript== // @name DOM + FPS Indicator (Draggable + Minimize) // @namespace https://github.com/aket0r/ // @version 2.1b // @description DOM/FPS индикатор: перетаскивание, двойной клик — компактный кружок, позиция и состояние сохраняются; спарклайн ms/кадр (опц.) // @author aket0r // @match http://*/* // @match https://*/* // @exclude https://chat.openai.com/* // @exclude https://chatgpt.com/* // @grant none // @license MIT // @icon https://raw.githubusercontent.com/aket0r/dom-indicator-loading/main/DOM-indicator-loading.png // ==/UserScript== (() => { 'use strict'; // ===== Настройки ===== const DOM_THRESHOLDS = { warn: 15000, danger: 30000 }; const DOM_UPDATE_EVERY_MS = 1000; const FPS_ENABLED = true; const FPS_WINDOW = 60; const FPS_UI_UPDATE_MS = 1000; const SPARKLINE_ENABLED = true; const SPARK = { length: 120, width: 140, height: 28, padX: 4, padY: 3, clampMs: { min: 8, max: 100 } }; const LS_KEY = 'dom_fps_indicator_state_v12'; // позиция/минимизация // ===== Ранний выход для iframes ===== if (window.top !== window.self) return; // ===== Состояние UI (позиция/минимизация) ===== const state = loadState() || { x: null, y: null, minimized: false }; function saveState() { try { localStorage.setItem(LS_KEY, JSON.stringify(state)); } catch {} } function loadState() { try { return JSON.parse(localStorage.getItem(LS_KEY) || 'null'); } catch { return null; } } // ===== FPS-модуль ===== const FPSMeter = (() => { let rafId = null; let last = 0; let samples = []; let lastUiUpdate = 0; const msBuf = []; function loop(ts) { if (!last) last = ts; const delta = ts - last; last = ts; if (delta > 0 && delta < 250) { const fps = 1000 / delta; samples.push(fps); if (samples.length > FPS_WINDOW) samples.shift(); if (SPARKLINE_ENABLED) { msBuf.push(delta); if (msBuf.length > SPARK.length) msBuf.shift(); } } if (FPS_ENABLED && ts - lastUiUpdate >= FPS_UI_UPDATE_MS) { lastUiUpdate = ts; updateFPSLine(getStats(), msBuf); if (SPARKLINE_ENABLED) drawSparkline(msBuf); } rafId = requestAnimationFrame(loop); } function getStats() { if (samples.length === 0) return { avg: 0, min: 0, max: 0 }; let sum = 0, min = Infinity, max = -Infinity; for (const v of samples) { sum += v; if (v < min) min = v; if (v > max) max = v; } return { avg: sum / samples.length, min, max }; } function start() { if (rafId != null || !FPS_ENABLED) return; samples = []; last = 0; lastUiUpdate = 0; rafId = requestAnimationFrame(loop); } document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') last = performance.now(); }); return { start }; })(); // ===== UI бейдж (перетаскиваемый + компактный режим) ===== function initBadge() { console.log(`%c[${new Date().toLocaleString()}] DOM + FPS indicator loaded.`, 'color: lime;'); let badge = document.getElementById('dom-indicator'); if (badge) return; // Контейнер badge = document.createElement('div'); badge.id = 'dom-indicator'; badge.style.cssText = ` position: fixed; bottom: 20px; right: 80px; background: #222; color: #0f0; font-family: monospace; padding: 6px 10px; border-radius: 8px; font-size: 13px; z-index: 2147483647; box-shadow: 0 0 4px rgba(0,0,0,0.4); user-select: none; pointer-events: auto; /* ВАЖНО: кликабельно */ line-height: 1.25; white-space: nowrap; cursor: grab; /* перетаскивание */ `; // Внутреннее содержимое const domLine = document.createElement('div'); domLine.id = 'dom-line'; domLine.textContent = 'DOM nodes: loading...'; const fpsLine = document.createElement('div'); fpsLine.id = 'fps-line'; if (FPS_ENABLED) fpsLine.textContent = 'FPS: --.- (ms: --.-)'; const spark = document.createElement('canvas'); spark.id = 'fps-spark'; spark.width = SPARK.width; spark.height = SPARK.height; spark.style.cssText = 'display:block;margin-top:4px;opacity:.9;'; badge.appendChild(domLine); if (FPS_ENABLED) badge.appendChild(fpsLine); if (SPARKLINE_ENABLED) badge.appendChild(spark); document.body.prepend(badge); // Применить сохранённую позицию/режим if (state.x !== null && state.y !== null) { applyPosition(badge, state.x, state.y); } else { // дефолтная позиция — правый-низ (уже задана через bottom/right) clampToViewport(badge); } if (state.minimized) { setMinimized(badge, true); } // Обработчики перетаскивания makeDraggable(badge); // Двойной клик — переключить режим (полный/компакт) badge.addEventListener('dblclick', () => { setMinimized(badge, !state.minimized); saveState(); }); // Запуск DOM-счётчика setInterval(updateDOMLine, DOM_UPDATE_EVERY_MS); // На ресайз — не уезжаем за экран window.addEventListener('resize', () => clampToViewport(badge)); } function setMinimized(badge, minimized) { state.minimized = minimized; const domLine = badge.querySelector('#dom-line'); const fpsLine = badge.querySelector('#fps-line'); const spark = badge.querySelector('#fps-spark'); if (minimized) { // Компактный кружок — только число DOM badge.style.width = '42px'; badge.style.height = '42px'; badge.style.borderRadius = '999px'; badge.style.padding = '0'; badge.style.display = 'flex'; badge.style.alignItems = 'center'; badge.style.justifyContent = 'center'; badge.style.cursor = 'grab'; // Покажем только число (без «DOM nodes: ») const count = document.querySelectorAll('*').length; domLine.textContent = `${count}`; domLine.style.display = 'block'; domLine.style.fontWeight = '700'; domLine.style.fontSize = '14px'; if (fpsLine) fpsLine.style.display = 'none'; if (spark) spark.style.display = 'none'; } else { // Полный режим badge.style.width = ''; badge.style.height = ''; badge.style.borderRadius = '8px'; badge.style.padding = '6px 10px'; badge.style.display = 'block'; badge.style.cursor = 'grab'; // Вернём текстовую метку const count = document.querySelectorAll('*').length; domLine.textContent = `DOM nodes: ${count}`; domLine.style.fontWeight = ''; domLine.style.fontSize = ''; if (fpsLine) fpsLine.style.display = ''; if (spark && SPARKLINE_ENABLED) spark.style.display = 'block'; } } function updateDOMLine() { const badge = document.getElementById('dom-indicator'); const domLine = document.getElementById('dom-line'); if (!badge || !domLine) return; const count = document.querySelectorAll('*').length; if (state.minimized) { domLine.textContent = `${count}`; } else { domLine.textContent = `DOM nodes: ${count}`; } if (count > DOM_THRESHOLDS.danger) { badge.style.color = '#f55'; badge.style.background = '#300'; } else if (count > DOM_THRESHOLDS.warn) { badge.style.color = '#ff0'; badge.style.background = '#442'; } else { badge.style.color = '#0f0'; badge.style.background = '#222'; } } function updateFPSLine(stats, msBuf) { if (!FPS_ENABLED) return; const el = document.getElementById('fps-line'); const badge = document.getElementById('dom-indicator'); if (!el || !badge) return; const avg = stats.avg || 0; const ms = avg > 0 ? (1000 / avg) : 0; if (!state.minimized) { el.textContent = `FPS: ${avg.toFixed(1)} (ms: ${ms.toFixed(1)})`; } // Лёгкая подсветка по усреднённому ms, если фон дефолтный const bg = badge.style.background; const looksDefault = !bg || bg === '#222' || bg === 'rgb(34, 34, 34)'; if (looksDefault) { if (ms <= 18) { badge.style.background = '#1f2a1f'; badge.style.color = '#aef1ae'; } else if (ms <= 25) { badge.style.background = '#2a281f'; badge.style.color = '#ffe9a6'; } else { badge.style.background = '#2a1f1f'; badge.style.color = '#ffb3b3'; } } } // ===== Спарклайн ===== function drawSparkline(msBuf) { if (!SPARKLINE_ENABLED || state.minimized) return; const canvas = document.getElementById('fps-spark'); if (!canvas) return; const ctx = canvas.getContext('2d', { alpha: true }); ctx.imageSmoothingEnabled = false; const W = canvas.width, H = canvas.height; const px = SPARK.padX, py = SPARK.padY; const plotW = W - px * 2, plotH = H - py * 2; ctx.clearRect(0, 0, W, H); if (!msBuf || msBuf.length < 2) return; let min = Math.min(...msBuf); let max = Math.max(...msBuf); min = Math.max(min, SPARK.clampMs.min); max = Math.min(Math.max(max, min + 1), SPARK.clampMs.max); ctx.globalAlpha = 0.15; ctx.fillStyle = '#ffffff'; const ms60 = 1000 / 60, ms30 = 1000 / 30; const y60 = py + (plotH * (max - ms60) / (max - min)); const y30 = py + (plotH * (max - ms30) / (max - min)); ctx.fillRect(px, Math.max(py, Math.min(H - py - 1, y60)), plotW, 1); ctx.fillRect(px, Math.max(py, Math.min(H - py - 1, y30)), plotW, 1); ctx.globalAlpha = 1; const lastMs = msBuf[msBuf.length - 1]; const stroke = lastMs <= 18 ? '#aef1ae' : lastMs <= 25 ? '#ffe9a6' : '#ffb3b3'; ctx.lineWidth = 1; ctx.strokeStyle = stroke; ctx.beginPath(); for (let i = 0; i < msBuf.length; i++) { const ms = Math.min(Math.max(msBuf[i], min), max); const x = px + (i / (SPARK.length - 1)) * plotW; const y = py + (plotH * (max - ms) / (max - min)); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); const grad = ctx.createLinearGradient(0, py, 0, H - py); grad.addColorStop(0, 'rgba(255,255,255,0.18)'); grad.addColorStop(1, 'rgba(255,255,255,0.02)'); ctx.fillStyle = grad; ctx.lineTo(px + plotW, H - py); ctx.lineTo(px, H - py); ctx.closePath(); ctx.fill(); } // ===== Перетаскивание ===== function makeDraggable(el) { let dragging = false; let startX = 0, startY = 0; let startLeft = 0, startTop = 0; // Если позиция сохранена — используем left/top, а не bottom/right if (state.x !== null && state.y !== null) { el.style.left = `${state.x}px`; el.style.top = `${state.y}px`; el.style.right = 'auto'; el.style.bottom = 'auto'; } const onDown = (clientX, clientY) => { dragging = true; el.style.cursor = 'grabbing'; const rect = el.getBoundingClientRect(); startX = clientX; startY = clientY; startLeft = rect.left + window.scrollX; startTop = rect.top + window.scrollY; // Переключаемся на абсолютные координаты el.style.left = `${startLeft}px`; el.style.top = `${startTop}px`; el.style.right = 'auto'; el.style.bottom = 'auto'; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); document.addEventListener('touchmove', onTouchMove, { passive: false }); document.addEventListener('touchend', onTouchEnd); }; const onMouseDown = (e) => { // ЛКМ if (e.button !== 0) return; onDown(e.clientX, e.clientY); e.preventDefault(); }; const onTouchStart = (e) => { const t = e.touches[0]; if (!t) return; onDown(t.clientX, t.clientY); }; const onMove = (clientX, clientY) => { if (!dragging) return; const dx = clientX - startX; const dy = clientY - startY; const newLeft = startLeft + dx; const newTop = startTop + dy; applyPosition(el, newLeft, newTop); }; const onMouseMove = (e) => { onMove(e.clientX, e.clientY); e.preventDefault(); }; const onTouchMove = (e) => { const t = e.touches[0]; if (!t) return; onMove(t.clientX, t.clientY); e.preventDefault(); }; const finishDrag = () => { if (!dragging) return; dragging = false; el.style.cursor = 'grab'; clampToViewport(el); saveState(); document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); document.removeEventListener('touchmove', onTouchMove); document.removeEventListener('touchend', onTouchEnd); }; const onMouseUp = () => finishDrag(); const onTouchEnd = () => finishDrag(); el.addEventListener('mousedown', onMouseDown); el.addEventListener('touchstart', onTouchStart, { passive: true }); } function applyPosition(el, x, y) { // Безопасные границы (с учётом размеров элемента) const rect = el.getBoundingClientRect(); const minLeft = 0; const minTop = 0; const maxLeft = window.innerWidth - rect.width; const maxTop = window.innerHeight - rect.height; const clampedX = Math.max(minLeft, Math.min(x, maxLeft)) | 0; const clampedY = Math.max(minTop, Math.min(y, maxTop)) | 0; el.style.left = `${clampedX}px`; el.style.top = `${clampedY}px`; el.style.right = 'auto'; el.style.bottom = 'auto'; state.x = clampedX; state.y = clampedY; } function clampToViewport(el) { if (state.x === null || state.y === null) return; applyPosition(el, state.x, state.y); saveState(); } // ===== Старт ===== window.addEventListener('load', () => { setTimeout(() => { initBadge(); if (FPS_ENABLED) FPSMeter.start(); }, 200); }); })();