Статистика карт animeSSS
// ==UserScript==
// @name ASSStat Card
// @version 1.1
// @description Статистика карт animeSSS
// @author SoulUA
// @match https://animesss.com/*
// @match https://animesss.tv/*
// @license MIT
// @grant none
// @namespace https://greasyfork.org/users/1467651
// ==/UserScript==
(function () {
'use strict';
function clampNumber(value, min, max, fallback) {
const n = Number(value);
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, Math.trunc(n)));
}
function readNumberSetting(key, fallback, min, max) {
return clampNumber(localStorage.getItem(key), min, max, fallback);
}
const CONFIG = {
STATS_CACHE_TTL: 3 * 24 * 60 * 60 * 1000,
SCAN_CONCURRENCY: 4,
AUTO_CONCURRENCY: readNumberSetting('ch_auto_concurrency', 2, 1, 6),
SCAN_DELAY_MIN: 50,
SCAN_DELAY_MAX: 110,
AUTO_DELAY_MIN: 80,
AUTO_DELAY_MAX: 180,
MUTATION_DEBOUNCE: 350,
ROOT_MARGIN: '300px',
COOLDOWN_429: 5000,
DEBUG: false
};
const CARD_SELECTOR = [
'.remelt__inventory-item',
'.lootbox__card',
'.anime-cards__item',
'.trade__inventory-item',
'.trade__main-item',
'.card-filter-list__card',
'.deck__item',
'.history__body-item',
'.card-pack__card'
].join(', ');
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
const rand = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
const log = (...args) => CONFIG.DEBUG && console.log('[CardASS]', ...args);
let toast = { el: null, tid: null };
let isAutoStatsEnabled = localStorage.getItem('ch_auto_stats') !== 'false';
let isPinned = localStorage.getItem('ch_is_pinned') !== 'false'; // По умолчанию лупа закреплена
let serverCooldownUntil = 0;
let mutationTimer = null;
let menuEl = null;
const pendingRoots = new Set();
const memoryCache = new Map();
const inflightStats = new Map();
const autoQueue = [];
let autoWorkersActive = 0;
function showToast(msg, type = 'temp', opt = {}) {
if (window.location.pathname.includes('/pm/')) return;
if (toast.el) toast.el.remove();
const el = document.createElement('div');
el.className = 'ch-toast';
document.body.appendChild(el);
toast.el = el;
if (type === 'progress') {
el.innerHTML = `
<div class="ch-spin"></div>
<span>${opt.cur} / ${opt.tot}</span>
`;
} else {
el.innerHTML = `<span>${msg}</span>`;
}
requestAnimationFrame(() => el.classList.add('show'));
if (toast.tid) clearTimeout(toast.tid);
if (!opt.sticky) {
toast.tid = setTimeout(() => {
el.classList.remove('show');
setTimeout(() => {
if (el.parentNode) el.remove();
if (toast.el === el) toast.el = null;
}, 300);
}, opt.dur || 2500);
}
}
function readCache(id) {
const key = `cId:${id}`;
const mem = memoryCache.get(key);
if (mem && Date.now() < mem.exp) return mem.data;
if (mem) memoryCache.delete(key);
const raw = localStorage.getItem(key);
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
if (!parsed || Date.now() > parsed.exp) {
localStorage.removeItem(key);
return null;
}
memoryCache.set(key, parsed);
return parsed.data;
} catch (e) {
localStorage.removeItem(key);
return null;
}
}
function writeCache(id, data) {
const key = `cId:${id}`;
const payload = {
data,
exp: Date.now() + CONFIG.STATS_CACHE_TTL
};
memoryCache.set(key, payload);
try {
localStorage.setItem(key, JSON.stringify(payload));
} catch (e) {
log('localStorage write failed', e);
}
}
function gcCache(force = false) {
const now = Date.now();
Object.keys(localStorage).forEach(key => {
if (!key.startsWith('cId:')) return;
if (force) {
localStorage.removeItem(key);
return;
}
try {
const item = JSON.parse(localStorage.getItem(key));
if (!item || now > item.exp) localStorage.removeItem(key);
} catch (e) {
localStorage.removeItem(key);
}
});
if (force) memoryCache.clear();
}
async function fetchStats(id, retry = true) {
const cached = readCache(id);
if (cached) return cached;
const now = Date.now();
if (serverCooldownUntil > now) await sleep(serverCooldownUntil - now);
try {
const url = `${window.location.origin}/cards/users/?id=${encodeURIComponent(id)}`;
const response = await fetch(url, { credentials: 'same-origin' });
if (response.status === 429) {
serverCooldownUntil = Date.now() + CONFIG.COOLDOWN_429;
showToast('Пауза: сервер ограничил запросы', 'temp', { dur: 2500 });
if (retry) {
await sleep(CONFIG.COOLDOWN_429);
return fetchStats(id, false);
}
return null;
}
if (!response.ok) return null;
const html = await response.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const data = {
p: doc.querySelector('#owners-count')?.textContent.trim() || '0',
n: doc.querySelector('#owners-need')?.textContent.trim() || '0',
t: doc.querySelector('#owners-trade')?.textContent.trim() || '0'
};
writeCache(id, data);
return data;
} catch (e) {
log('fetchStats failed', e);
return null;
}
}
function loadStats(id) {
const cached = readCache(id);
if (cached) return Promise.resolve(cached);
if (inflightStats.has(id)) return inflightStats.get(id);
const promise = fetchStats(id).finally(() => {
inflightStats.delete(id);
});
inflightStats.set(id, promise);
return promise;
}
function getCID(card) {
let id = card.getAttribute('data-card-id') || card.getAttribute('card-id') || card.getAttribute('data-id');
if (id) return id;
const link = card.tagName === 'A' ? card : card.querySelector('a[href*="/cards/"]');
if (!link?.href) return null;
try {
const url = new URL(link.href, window.location.origin);
id = url.searchParams.get('id');
if (id) return id;
const match = url.pathname.match(/\/cards\/(\d+)/);
return match ? match[1] : null;
} catch (e) {
const match = link.href.match(/(?:\/cards\/(?:users\/\?id=)?|[?&]id=)(\d+)/);
return match ? match[1] : null;
}
}
function getStatsBox(card) {
let box = card.querySelector(':scope > .card-stats');
if (!box) {
box = document.createElement('div');
box.className = 'card-stats placeholder';
box.innerHTML = '<span>Спрос</span>';
card.appendChild(box);
}
return box;
}
function renderStats(card, data) {
const box = getStatsBox(card);
box.className = 'card-stats mini';
box.onclick = null;
box.innerHTML = `
<div class="stat-row">
<span title="Имеют">👥 ${data.p}</span>
<span title="Хотят">❤️ ${data.n}</span>
<span title="Обмен">🔄 ${data.t}</span>
</div>
`;
return true;
}
function tryRenderCached(card, knownId = null) {
const id = knownId || getCID(card);
if (!id) return false;
const cached = readCache(id);
if (!cached) return false;
renderStats(card, cached);
try { cardObserver.unobserve(card); } catch (e) {}
return true;
}
function setError(card, text = 'Ошибка') {
const box = getStatsBox(card);
box.className = 'card-stats error';
box.innerHTML = `<span>${text}</span>`;
box.onclick = async e => {
e.preventDefault();
e.stopPropagation();
box.className = 'card-stats placeholder';
box.innerHTML = '<span>Спрос</span>';
await processCard(card);
};
}
async function applyStats(id, card) {
const cached = readCache(id);
if (cached) return renderStats(card, cached);
const data = await loadStats(id);
if (!data) {
setError(card);
return false;
}
return renderStats(card, data);
}
async function processCard(card, knownId = null) {
if (!card || card.dataset.chBusy === '1') return false;
if (card.closest('.lc_chat_li')) return false;
if (card.querySelector(':scope > .card-stats.mini')) return true;
if (tryRenderCached(card, knownId)) return true;
const box = getStatsBox(card);
card.dataset.chBusy = '1';
box.className = 'card-stats placeholder loading';
box.innerHTML = '';
try {
const id = knownId || getCID(card);
if (!id) {
setError(card, 'Нет ID');
return false;
}
return await applyStats(id, card);
} finally {
delete card.dataset.chBusy;
}
}
function enqueueCard(card) {
if (!card || card.dataset.chQueued === '1' || card.dataset.chBusy === '1') return;
if (card.querySelector(':scope > .card-stats.mini')) return;
const id = getCID(card);
if (id && tryRenderCached(card, id)) return;
card.dataset.chQueued = '1';
autoQueue.push(card);
startAutoWorkers();
}
function startAutoWorkers() {
while (autoWorkersActive < CONFIG.AUTO_CONCURRENCY && autoQueue.length > 0) {
runAutoWorker();
}
}
async function runAutoWorker() {
autoWorkersActive++;
try {
while (autoQueue.length > 0) {
const card = autoQueue.shift();
try {
if (!card || !card.isConnected) continue;
if (card.querySelector(':scope > .card-stats.mini')) continue;
const id = getCID(card);
if (id && tryRenderCached(card, id)) continue;
await processCard(card, id);
await sleep(rand(CONFIG.AUTO_DELAY_MIN, CONFIG.AUTO_DELAY_MAX));
} catch (e) {
log('auto worker error', e);
} finally {
if (card) delete card.dataset.chQueued;
}
}
} finally {
autoWorkersActive--;
if (autoQueue.length > 0) startAutoWorkers();
}
}
const cardObserver = new IntersectionObserver(entries => {
for (const entry of entries) {
if (!entry.isIntersecting || !isAutoStatsEnabled) continue;
const card = entry.target;
const box = card.querySelector(':scope > .card-stats.placeholder');
if (!box || box.classList.contains('loading')) continue;
cardObserver.unobserve(card);
enqueueCard(card);
}
}, { rootMargin: CONFIG.ROOT_MARGIN });
function addPlaceholder(card) {
if (!card || card.closest('.lc_chat_li')) return;
if (card.querySelector(':scope > .card-stats')) return;
if (tryRenderCached(card)) return;
const box = document.createElement('div');
box.className = 'card-stats placeholder';
box.innerHTML = '<span>Спрос</span>';
box.onclick = async e => {
e.preventDefault();
e.stopPropagation();
await processCard(card);
};
card.appendChild(box);
cardObserver.observe(card);
}
function processRoot(root = document) {
if (!root) return;
if (root.nodeType === Node.ELEMENT_NODE && root.matches?.(CARD_SELECTOR)) {
addPlaceholder(root);
}
root.querySelectorAll?.(CARD_SELECTOR).forEach(addPlaceholder);
}
function isNearViewport(el) {
const rect = el.getBoundingClientRect();
return rect.bottom >= -300 && rect.top <= window.innerHeight + 300;
}
function triggerVisibleAutoLoad() {
if (!isAutoStatsEnabled) return;
document.querySelectorAll(CARD_SELECTOR).forEach(card => {
const box = card.querySelector(':scope > .card-stats.placeholder');
if (box && isNearViewport(card) && !box.classList.contains('loading')) {
cardObserver.unobserve(card);
enqueueCard(card);
}
});
startAutoWorkers();
}
async function scanAllCards() {
closeContextMenu();
const cards = Array.from(document.querySelectorAll(CARD_SELECTOR)).filter(card => {
if (!card.offsetParent || card.closest('.lc_chat_li')) return false;
if (card.querySelector(':scope > .card-stats.mini')) return false;
return true;
});
if (!cards.length) {
showToast('Все проверено или нет новых карт');
return;
}
const mainBtn = document.getElementById('ch-main');
mainBtn?.classList.add('working');
const items = cards.map(card => {
cardObserver.unobserve(card);
const id = getCID(card);
return { card, id, cached: id ? Boolean(readCache(id)) : false };
});
const total = items.length;
let done = 0;
showToast('', 'progress', { cur: 0, tot: total, sticky: true });
const updateProgress = () => {
done++;
const counter = toast.el?.querySelector('span');
if (counter) counter.textContent = `${done} / ${total}`;
};
const noIdItems = items.filter(item => !item.id);
const cachedItems = items.filter(item => item.id && item.cached);
const networkItems = items.filter(item => item.id && !item.cached);
for (const item of cachedItems) {
await processCard(item.card, item.id);
updateProgress();
}
for (const item of noIdItems) {
setError(item.card, 'Нет ID');
updateProgress();
}
const workerCount = Math.min(CONFIG.SCAN_CONCURRENCY, networkItems.length);
async function worker() {
while (networkItems.length) {
const item = networkItems.shift();
try {
await processCard(item.card, item.id);
} catch (e) {
log('scan worker error', e);
setError(item.card);
}
updateProgress();
await sleep(rand(CONFIG.SCAN_DELAY_MIN, CONFIG.SCAN_DELAY_MAX));
}
}
await Promise.all(Array.from({ length: workerCount }, () => worker()));
mainBtn?.classList.remove('working');
showToast('Готово');
}
function toggleAutoMode() {
isAutoStatsEnabled = !isAutoStatsEnabled;
localStorage.setItem('ch_auto_stats', String(isAutoStatsEnabled));
updateMainTitle();
showToast(isAutoStatsEnabled ? 'Авто-прогрузка ВКЛ' : 'Только ручной режим');
if (isAutoStatsEnabled) triggerVisibleAutoLoad();
}
function setAutoConcurrency(value) {
CONFIG.AUTO_CONCURRENCY = clampNumber(value, 1, 6, 2);
localStorage.setItem('ch_auto_concurrency', String(CONFIG.AUTO_CONCURRENCY));
showToast(`Авто-потоки: ${CONFIG.AUTO_CONCURRENCY}`);
if (isAutoStatsEnabled) {
triggerVisibleAutoLoad();
startAutoWorkers();
}
}
function renderWorkerButtons(current) {
return [1, 2, 3, 4, 5, 6].map(num => `
<button class="ch-choice ${current === num ? 'active' : ''}" data-auto-workers="${num}" type="button">
${num}
</button>
`).join('');
}
function updateMainTitle() {
const main = document.getElementById('ch-main');
if (!main) return;
let title = isAutoStatsEnabled
? `Сканировать. Авто: ВКЛ. Потоки: ${CONFIG.AUTO_CONCURRENCY}.`
: `Сканировать. Авто: ВЫКЛ. Потоки: ${CONFIG.AUTO_CONCURRENCY}.`;
title += ` Меню: ПКМ / Долгий тап. ${isPinned ? '(Закреплена)' : '(Плывущая)'}`;
main.title = title;
}
function closeContextMenu() {
if (menuEl) {
menuEl.remove();
menuEl = null;
}
}
function openContextMenu(anchor) {
closeContextMenu();
const menu = document.createElement('div');
menu.className = 'ch-menu';
menu.innerHTML = `
<button class="ch-menu-item" data-action="scan" type="button">
<span>🔍</span>
<b>Сканировать</b>
</button>
<button class="ch-menu-item" data-action="togglePin" type="button">
<span>${isPinned ? '🧲' : '📌'}</span>
<b>${isPinned ? 'Сделать плывущей' : 'Закрепить позицию'}</b>
</button>
<button class="ch-menu-item" data-action="toggle" type="button">
<span>${isAutoStatsEnabled ? '🤖' : '🖐️'}</span>
<b>${isAutoStatsEnabled ? 'Авто-режим включён' : 'Ручной режим'}</b>
</button>
<div class="ch-menu-section">
<div class="ch-menu-title">Авто-потоки</div>
<div class="ch-choice-grid">
${renderWorkerButtons(CONFIG.AUTO_CONCURRENCY)}
</div>
</div>
<button class="ch-menu-item danger" data-action="clear" type="button">
<span>🗑️</span>
<b>Очистить кэш</b>
</button>
`;
document.body.appendChild(menu);
menuEl = menu;
const anchorRect = anchor.getBoundingClientRect();
const menuRect = menu.getBoundingClientRect();
let left = anchorRect.left + anchorRect.width / 2 - menuRect.width / 2;
let top = anchorRect.top - menuRect.height - 10;
left = Math.max(8, Math.min(left, window.innerWidth - menuRect.width - 8));
if (top < 8) top = anchorRect.bottom + 10;
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
requestAnimationFrame(() => menu.classList.add('show'));
menu.onclick = e => {
const autoButton = e.target.closest('[data-auto-workers]');
if (autoButton) {
e.preventDefault();
e.stopPropagation();
setAutoConcurrency(autoButton.dataset.autoWorkers);
updateMainTitle();
openContextMenu(anchor);
return;
}
const button = e.target.closest('[data-action]');
if (!button) return;
e.preventDefault();
e.stopPropagation();
const action = button.dataset.action;
if (action === 'scan') {
scanAllCards();
} else if (action === 'togglePin') {
isPinned = !isPinned;
localStorage.setItem('ch_is_pinned', String(isPinned));
updateMainTitle();
showToast(isPinned ? 'Позиция закреплена' : 'Режим: Плывущая лупа');
openContextMenu(anchor);
} else if (action === 'toggle') {
toggleAutoMode();
openContextMenu(anchor);
} else if (action === 'clear') {
gcCache(true);
showToast('Кэш статистики очищен');
closeContextMenu();
}
};
}
function buildUI() {
if (document.getElementById('ch-fab-container')) return;
const css = document.createElement('style');
css.id = 'ch-style';
css.textContent = `
:root {
--ch-acc: #00e5ff;
--ch-bg: rgba(18, 18, 22, 0.92);
--ch-brd: rgba(255, 255, 255, 0.12);
}
#ch-fab-container {
position: fixed;
z-index: 10000;
user-select: none;
touch-action: none;
}
.ch-btn {
display: flex;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
/* --- Ликвидгласс дизайн (Liquid Glass) --- */
background: radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.1), rgba(18, 18, 22, 0.4));
backdrop-filter: blur(14px) saturate(140%);
-webkit-backdrop-filter: blur(14px) saturate(140%);
border: 1px solid rgba(255, 255, 255, 0.15);
border-top: 1px solid rgba(255, 255, 255, 0.4);
border-left: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
color: var(--ch-acc);
cursor: pointer;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5), inset 0 4px 12px rgba(255, 255, 255, 0.15), inset 0 -4px 10px rgba(0, 0, 0, 0.2);
transition: transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 0.3s ease, border-color 0.3s ease;
outline: none;
font-size: 21px;
}
.ch-btn:hover {
background: radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.15), rgba(0, 229, 255, 0.1));
border-color: rgba(0, 229, 255, 0.4);
border-top-color: rgba(0, 229, 255, 0.6);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.6), inset 0 4px 12px rgba(0, 229, 255, 0.2), 0 0 15px rgba(0, 229, 255, 0.25);
}
.ch-btn:active {
transform: scale(0.9);
}
#ch-main.working {
animation: chPulse 1.2s infinite ease-in-out;
pointer-events: none;
}
@keyframes chPulse {
0% { transform: scale(1); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5), 0 0 5px var(--ch-acc); }
50% { transform: scale(1.1); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5), 0 0 20px var(--ch-acc); }
100% { transform: scale(1); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5), 0 0 5px var(--ch-acc); }
}
.ch-menu {
position: fixed;
min-width: 215px;
padding: 8px;
border: 1px solid var(--ch-brd);
border-radius: 14px;
background: var(--ch-bg);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.55);
z-index: 999999;
opacity: 0;
transform: translateY(8px) scale(0.96);
transition: opacity 0.16s ease, transform 0.16s ease;
}
.ch-menu.show { opacity: 1; transform: translateY(0) scale(1); }
.ch-menu-item {
width: 100%; display: flex; align-items: center; gap: 10px;
padding: 10px 12px; border: 0; border-radius: 10px;
background: transparent; color: #fff; cursor: pointer;
font-size: 13px; text-align: left;
}
.ch-menu-item:hover { background: rgba(255, 255, 255, 0.08); }
.ch-menu-item span { width: 22px; text-align: center; }
.ch-menu-item b { font-weight: 700; }
.ch-menu-item.danger b { color: #ff6b81; }
.ch-menu-section {
padding: 8px 4px 6px; margin-top: 4px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.ch-menu-title {
margin: 0 4px 7px; color: rgba(255, 255, 255, 0.72);
font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.4px;
}
.ch-choice-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 5px; }
.ch-choice {
height: 28px; border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 8px; background: rgba(255, 255, 255, 0.06);
color: #fff; cursor: pointer; font-size: 12px; font-weight: 700;
}
.ch-choice:hover { background: rgba(255, 255, 255, 0.12); }
.ch-choice.active { background: var(--ch-acc); border-color: var(--ch-acc); color: #071014; }
.card-stats {
position: relative; width: 100%; box-sizing: border-box;
margin-top: 4px; padding: 4px 2px; border: 1px solid var(--ch-brd);
border-radius: 4px; background: rgba(22, 22, 28, 0.95);
color: #fff; z-index: 5; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
}
.card-stats.placeholder, .card-stats.error {
height: 26px; display: flex; align-items: center;
justify-content: center; cursor: pointer; background: rgba(255, 255, 255, 0.05);
}
.card-stats.placeholder span, .card-stats.error span { opacity: 0.85; font-size: 11px; font-weight: 700; }
.card-stats.error span { color: #ff4757; }
.stat-row { display: flex; justify-content: center; align-items: center; flex-wrap: wrap; width: 100%; gap: 2px 6px; }
.stat-row span { display: flex; align-items: center; gap: 2px; font-weight: 500; font-size: 10px; letter-spacing: -0.5px; }
.card-stats.loading::after {
content: ''; width: 14px; height: 14px; border: 2px solid var(--ch-acc);
border-top-color: transparent; border-radius: 50%; animation: chSpin 0.8s linear infinite;
}
.ch-toast {
position: fixed; left: 50%; bottom: 30px; transform: translateX(-50%) translateY(20px);
display: flex; align-items: center; gap: 10px; padding: 8px 20px;
border: 1px solid var(--ch-acc); border-radius: 30px; background: var(--ch-bg);
color: #fff; opacity: 0; transition: all 0.3s; z-index: 999999;
font-size: 14px; font-weight: 700;
}
.ch-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.ch-spin {
width: 14px; height: 14px; border: 2px solid #fff;
border-top-color: var(--ch-acc); border-radius: 50%; animation: chSpin 0.8s linear infinite;
}
@keyframes chSpin { to { transform: rotate(360deg); } }
`;
document.head.appendChild(css);
const container = document.createElement('div');
container.id = 'ch-fab-container';
document.body.appendChild(container);
const main = document.createElement('button');
main.id = 'ch-main';
main.className = 'ch-btn';
main.type = 'button';
main.innerHTML = '🔍';
container.appendChild(main);
updateMainTitle();
restorePanelPosition(container);
enableDragging(container, main);
enableContextMenu(main);
main.onclick = e => {
e.preventDefault();
if (main.dataset.moved === '1') { main.dataset.moved = '0'; return; }
if (main.dataset.menuOpened === '1') { main.dataset.menuOpened = '0'; return; }
scanAllCards();
};
}
function restorePanelPosition(container) {
const x = localStorage.getItem('ch_x');
const y = localStorage.getItem('ch_y');
if (!x || !y) {
// Если координаты не сохранены — ставим дефолтную позицию (около строки поиска как на скрине)
container.style.left = '235px';
container.style.top = '12px';
container.style.right = 'auto';
container.style.bottom = 'auto';
} else {
container.style.left = x;
container.style.top = y;
container.style.right = 'auto';
container.style.bottom = 'auto';
}
}
function enableDragging(container, handle) {
let dragging = false;
let moved = false;
let x0 = 0, y0 = 0, l0 = 0, t0 = 0;
const getPoint = e => e.touches ? e.touches[0] : e;
const start = e => {
const point = getPoint(e);
const rect = container.getBoundingClientRect();
x0 = point.clientX;
y0 = point.clientY;
l0 = rect.left;
t0 = rect.top;
dragging = true;
moved = false;
handle.dataset.moved = '0';
};
const move = e => {
// Если включено закрепление (isPinned), блокируем возможность перетаскивать
if (!dragging || isPinned) return;
const point = getPoint(e);
const dx = point.clientX - x0;
const dy = point.clientY - y0;
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) moved = true;
if (!moved) return;
closeContextMenu();
const rect = container.getBoundingClientRect();
const maxLeft = window.innerWidth - rect.width;
const maxTop = window.innerHeight - rect.height;
const left = Math.min(Math.max(l0 + dx, 0), Math.max(maxLeft, 0));
const top = Math.min(Math.max(t0 + dy, 0), Math.max(maxTop, 0));
container.style.left = `${left}px`;
container.style.top = `${top}px`;
container.style.right = 'auto';
container.style.bottom = 'auto';
handle.dataset.moved = '1';
};
const end = () => {
if (!dragging) return;
dragging = false;
if (moved) {
localStorage.setItem('ch_x', container.style.left);
localStorage.setItem('ch_y', container.style.top);
setTimeout(() => { handle.dataset.moved = '0'; }, 250);
}
};
handle.addEventListener('touchstart', start, { passive: true });
document.addEventListener('touchmove', move, { passive: true });
document.addEventListener('touchend', end);
handle.addEventListener('mousedown', start);
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', end);
}
function enableContextMenu(main) {
let longPressTimer = null;
let sx = 0, sy = 0;
main.addEventListener('contextmenu', e => {
e.preventDefault();
e.stopPropagation();
main.dataset.menuOpened = '1';
openContextMenu(main);
});
main.addEventListener('touchstart', e => {
const touch = e.touches[0];
sx = touch.clientX;
sy = touch.clientY;
clearTimeout(longPressTimer);
longPressTimer = setTimeout(() => {
main.dataset.menuOpened = '1';
openContextMenu(main);
}, 550);
}, { passive: true });
main.addEventListener('touchmove', e => {
const touch = e.touches[0];
if (Math.abs(touch.clientX - sx) > 8 || Math.abs(touch.clientY - sy) > 8) {
clearTimeout(longPressTimer);
}
}, { passive: true });
main.addEventListener('touchend', () => { clearTimeout(longPressTimer); });
document.addEventListener('click', e => {
if (!menuEl) return;
if (e.target.closest('.ch-menu') || e.target.closest('#ch-main')) return;
closeContextMenu();
});
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeContextMenu(); });
window.addEventListener('resize', closeContextMenu);
window.addEventListener('scroll', closeContextMenu, true);
}
function observeDomChanges() {
const domObserver = new MutationObserver(mutations => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) pendingRoots.add(node);
}
}
if (!pendingRoots.size) return;
clearTimeout(mutationTimer);
mutationTimer = setTimeout(() => {
const roots = Array.from(pendingRoots);
pendingRoots.clear();
roots.forEach(processRoot);
triggerVisibleAutoLoad();
}, CONFIG.MUTATION_DEBOUNCE);
});
domObserver.observe(document.body, { childList: true, subtree: true });
}
function init() {
gcCache();
buildUI();
processRoot(document);
triggerVisibleAutoLoad();
observeDomChanges();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
})();