Floating Torn Ranked War scanner widget with local price history, ROI estimates, and page scanning.
// ==UserScript==
// @name Torn RW Scanner Widget
// @namespace https://openai.com/
// @version 1.1.0
// @description Floating Torn Ranked War scanner widget with local price history, ROI estimates, and page scanning.
// @match https://www.torn.com/*
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_notification
// ==/UserScript==
(() => {
'use strict';
const STORE_KEYS = {
db: 'rwScanner.db.v1',
cfg: 'rwScanner.cfg.v1',
pos: 'rwScanner.pos.v1',
collapsed: 'rwScanner.collapsed.v1',
};
const DEFAULT_CFG = {
refreshMinMs: 60000,
refreshMaxMs: 90000,
notifyRoi: 25,
notifyProfit: 10000000,
enabled: true,
};
const state = {
db: loadJSON(STORE_KEYS.db, {}),
cfg: { ...DEFAULT_CFG, ...loadJSON(STORE_KEYS.cfg, {}) },
results: [],
lastScanAt: 0,
scanTimer: null,
noticeCooldown: new Map(),
collapsed: !!loadJSON(STORE_KEYS.collapsed, false),
};
const style = `
#rwScannerWidget {
position: fixed;
z-index: 999999;
left: 12px;
top: 12px;
width: 320px;
max-width: calc(100vw - 12px);
max-height: calc(100vh - 12px);
background: rgba(24, 24, 24, 0.96);
color: #e9e9e9;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 16px;
box-shadow: 0 12px 30px rgba(0,0,0,0.45);
font: 13px/1.35 system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
overflow: hidden;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
touch-action: none;
}
#rwScannerWidget * { box-sizing: border-box; }
#rwScannerWidget.collapsed {
width: 120px !important;
max-height: none;
border-radius: 14px;
}
#rwScannerWidget.collapsed .rw-body { display: none; }
#rwScannerWidget.collapsed .rw-head {
padding: 8px 8px;
}
#rwScannerWidget.collapsed .rw-title {
font-size: 13px;
}
#rwScannerWidget.collapsed .rw-count {
display: inline-flex;
}
.rw-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
cursor: move;
background: linear-gradient(180deg, rgba(255,255,255,0.07), rgba(255,255,255,0.03));
border-bottom: 1px solid rgba(255,255,255,0.08);
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.rw-head-left {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
gap: 2px;
}
.rw-head-right {
display: flex;
align-items: center;
gap: 6px;
flex: 0 0 auto;
}
.rw-title {
font-weight: 800;
font-size: 14px;
line-height: 1.1;
white-space: nowrap;
}
.rw-pill {
font-size: 11px;
padding: 3px 8px;
border-radius: 999px;
background: rgba(255,255,255,0.08);
color: #dfe7f5;
white-space: nowrap;
}
.rw-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 40px;
}
.rw-body {
padding: 10px 12px 12px;
overflow: auto;
max-height: calc(100vh - 58px);
}
.rw-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.rw-btn {
appearance: none;
border: 0;
border-radius: 12px;
padding: 8px 10px;
background: #2d7ef7;
color: #fff;
font-weight: 700;
cursor: pointer;
font-size: 12px;
}
.rw-btn.secondary { background: rgba(255,255,255,0.08); }
.rw-btn.danger { background: #c94242; }
.rw-btn:disabled { opacity: .6; cursor: not-allowed; }
.rw-status {
color: #aab4c3;
margin-bottom: 8px;
font-size: 12px;
word-break: break-word;
}
.rw-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin: 8px 0;
}
.rw-card {
padding: 8px 10px;
border-radius: 12px;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.06);
}
.rw-card .label { font-size: 11px; color: #9ca9bb; }
.rw-card .value {
font-size: 15px;
font-weight: 800;
margin-top: 2px;
word-break: break-word;
}
.rw-list {
margin-top: 8px;
display: grid;
gap: 8px;
}
.rw-item {
border-radius: 12px;
padding: 8px 10px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.06);
}
.rw-item.top { outline: 1px solid rgba(45,126,247,0.45); }
.rw-item .name {
font-weight: 800;
margin-bottom: 2px;
word-break: break-word;
}
.rw-item .meta {
color: #aab4c3;
font-size: 12px;
display: flex;
justify-content: space-between;
gap: 10px;
}
.rw-mini {
font-size: 11px;
color: #9ca9bb;
margin-top: 4px;
}
.rw-foot {
margin-top: 8px;
color: #7f8b9d;
font-size: 11px;
}
.rw-badge {
color: #68d391;
font-weight: 800;
}
#rwScannerWidget.rw-alert-good {
box-shadow: 0 0 0 1px rgba(245, 158, 11, 0.35), 0 0 18px rgba(245, 158, 11, 0.15), 0 12px 30px rgba(0,0,0,0.45);
}
#rwScannerWidget.rw-alert-great {
box-shadow: 0 0 0 1px rgba(249, 115, 22, 0.45), 0 0 22px rgba(249, 115, 22, 0.2), 0 12px 30px rgba(0,0,0,0.45);
}
#rwScannerWidget.rw-alert-snipe {
animation: rwPulse 1s infinite;
box-shadow: 0 0 0 1px rgba(239, 68, 68, 0.5), 0 0 26px rgba(239, 68, 68, 0.25), 0 12px 30px rgba(0,0,0,0.45);
}
@keyframes rwPulse {
0% { transform: scale(1); }
50% { transform: scale(1.02); }
100% { transform: scale(1); }
}
`;
GM_addStyle(style);
const widget = document.createElement('div');
widget.id = 'rwScannerWidget';
widget.className = state.collapsed ? 'collapsed' : '';
widget.innerHTML = `
<div class="rw-head" id="rwDragHandle">
<div class="rw-head-left">
<div class="rw-title">RW Scanner</div>
<div class="rw-pill" id="rwScanState">Idle</div>
</div>
<div class="rw-head-right">
<div class="rw-pill rw-count" id="rwCountPill">0</div>
<button class="rw-btn secondary" id="rwToggle" title="Collapse/expand">â</button>
</div>
</div>
<div class="rw-body">
<div class="rw-row">
<button class="rw-btn" id="rwScanNow">Scan Now</button>
<button class="rw-btn secondary" id="rwClear">Clear DB</button>
</div>
<div class="rw-status" id="rwStatus">Waiting for scan...</div>
<div class="rw-grid">
<div class="rw-card"><div class="label">Best ROI</div><div class="value" id="rwBestRoi">â</div></div>
<div class="rw-card"><div class="label">Best Profit</div><div class="value" id="rwBestProfit">â</div></div>
</div>
<div class="rw-list" id="rwList"></div>
<div class="rw-foot">Scans the current Torn page every 60â90s and learns from what it sees.</div>
</div>
`;
document.documentElement.appendChild(widget);
const els = {
scanState: widget.querySelector('#rwScanState'),
countPill: widget.querySelector('#rwCountPill'),
scanNow: widget.querySelector('#rwScanNow'),
toggle: widget.querySelector('#rwToggle'),
clear: widget.querySelector('#rwClear'),
status: widget.querySelector('#rwStatus'),
bestRoi: widget.querySelector('#rwBestRoi'),
bestProfit: widget.querySelector('#rwBestProfit'),
list: widget.querySelector('#rwList'),
head: widget.querySelector('#rwDragHandle'),
};
let drag = {
pointerId: null,
startX: 0,
startY: 0,
offsetX: 0,
offsetY: 0,
moved: false,
active: false,
};
applySavedPosition();
syncCollapsedUI();
els.scanNow.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
runScan(true);
});
els.toggle.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
toggleWidget();
});
els.clear.addEventListener('click', () => {
if (!confirm('Clear local RW price history?')) return;
state.db = {};
saveJSON(STORE_KEYS.db, state.db);
render([]);
setStatus('Local DB cleared.');
});
makeDraggable(widget, els.head);
if ('Notification' in window && Notification.permission === 'default') {
// Optional request only when browser supports it; ignored if blocked.
// This keeps the script quiet unless the user already allowed notifications.
}
scheduleNextScan();
runScan(false);
function toggleWidget() {
state.collapsed = !state.collapsed;
widget.classList.toggle('collapsed', state.collapsed);
saveJSON(STORE_KEYS.collapsed, state.collapsed);
syncCollapsedUI();
}
function syncCollapsedUI() {
els.toggle.textContent = state.collapsed ? '+' : 'â';
els.toggle.title = state.collapsed ? 'Expand' : 'Collapse';
els.countPill.textContent = String(state.results.length || 0);
widget.classList.toggle('collapsed', state.collapsed);
}
function setStatus(msg) {
els.status.textContent = msg;
els.scanState.textContent = msg.length > 30 ? msg.slice(0, 30) + 'â¦' : msg;
}
function scheduleNextScan() {
if (state.scanTimer) clearTimeout(state.scanTimer);
const min = Math.max(10000, Number(state.cfg.refreshMinMs) || DEFAULT_CFG.refreshMinMs);
const max = Math.max(min, Number(state.cfg.refreshMaxMs) || DEFAULT_CFG.refreshMaxMs);
const delay = Math.floor(min + Math.random() * (max - min));
state.scanTimer = setTimeout(() => {
runScan(false);
scheduleNextScan();
}, delay);
}
async function runScan(manual) {
if (!state.cfg.enabled) return;
setStatus(manual ? 'Manual scanâ¦' : 'Scanningâ¦');
try {
const findings = [];
const url = location.href;
const text = document.body ? document.body.innerText : '';
const detail = parseDetailPage(text);
if (detail) {
const key = makeKey(detail.name, detail.bonus);
const observation = {
key,
name: detail.name,
bonus: detail.bonus,
quality: detail.quality,
source: detail.source,
price: detail.value || detail.buy || detail.sell || detail.listedPrice || null,
buy: detail.buy || null,
sell: detail.sell || null,
value: detail.value || null,
seenAt: Date.now(),
};
if (observation.price) {
recordObservation(observation);
findings.push(buildFinding(observation, detail));
}
} else {
const cards = extractVisibleCards();
for (const card of cards) {
const key = makeKey(card.name, card.bonus);
const observation = {
key,
name: card.name,
bonus: card.bonus,
quality: card.quality,
source: inferSource(url, text),
price: card.price,
buy: null,
sell: null,
value: null,
seenAt: Date.now(),
};
recordObservation(observation);
findings.push(buildFinding(observation, null));
}
}
state.lastScanAt = Date.now();
state.results = compactAndSort(findings);
render(state.results);
maybeNotify(state.results);
setStatus(`Scanned ${state.results.length} item${state.results.length === 1 ? '' : 's'} at ${new Date().toLocaleTimeString()}`);
} catch (err) {
console.error('[RW Scanner]', err);
setStatus(`Scan failed: ${String(err.message || err).slice(0, 36)}`);
}
}
function inferSource(url, text) {
const u = url.toLowerCase();
if (u.includes('item') && u.includes('market')) return 'market';
if (u.includes('auction')) return 'auction';
if (/buy:\s*\$[\d,]+/i.test(text) && /bonus:/i.test(text)) return 'detail';
return 'page';
}
function parseDetailPage(text) {
const hasKnownFields = /\bBuy:\s*\$[\d,]+/i.test(text) || /\bSell:\s*\$[\d,]+/i.test(text) || /\bBonus:\s*/i.test(text);
if (!hasKnownFields) return null;
const name =
firstMatch(text, /^\s*([A-Za-z0-9 '\-\.]{3,})\s*$/m) ||
firstMatch(text, /^(?:Buy|Sell|Value|Circ|Damage|Accuracy|Stealth|Bonus|Quality)\s*$/m) ||
guessTitle(text);
const buy = money(firstMatch(text, /\bBuy:\s*\$([\d,]+)/i));
const sell = money(firstMatch(text, /\bSell:\s*\$([\d,]+)/i));
const value = money(firstMatch(text, /\bValue:\s*\$([\d,]+)/i));
const quality = firstMatch(text, /\bQuality:\s*([\d.]+)%\s*([A-Za-z]+)?/i);
const bonus = firstMatch(text, /\bBonus:\s*(?:[\u2605\u2726\u2730\u2747\u26ab\u2694\ufe0f\uD83D\uDD2B\uD83D\uDCA5\uD83D\uDCAA\uD83D\uDCA8\uD83D\uDD2A\uD83E\uDDE1\uD83D\uDC80]?\s*)?(.+?)(?:\n|$)/i);
const rarity = firstMatch(text, /\((?:Very\s+)?(?:Common|Uncommon|Rare|Extremely\s+Rare|Mythical|Legendary|Freakish|Oddball)\s+\d+\)/i);
if (!name) return null;
const qMatch = quality ? quality.match(/([\d.]+)%\s*([A-Za-z]+)?/i) : null;
const qValue = qMatch ? Number(qMatch[1]) : null;
const qColor = qMatch ? (qMatch[2] || '').trim() : null;
return {
source: 'detail',
name: cleanName(name),
buy,
sell,
value,
bonus: cleanBonus(bonus),
quality: qValue ? { value: qValue, label: qColor } : null,
rarity,
listedPrice: value || sell || buy || null,
};
}
function extractVisibleCards() {
const candidates = [];
const elements = Array.from(document.querySelectorAll('a, button, div, li, article, section, span')).slice(0, 6000);
for (const el of elements) {
if (!isVisible(el)) continue;
const rect = el.getBoundingClientRect();
if (rect.width < 120 || rect.height < 70 || rect.width > window.innerWidth * 0.98) continue;
const txt = normalizeText(el.innerText || el.textContent || '');
if (!txt || txt.length < 8 || txt.length > 320) continue;
if (!/\$[\d,]+/.test(txt)) continue;
if (!/[A-Za-z]/.test(txt)) continue;
const lines = txt.split('\n').map(s => s.trim()).filter(Boolean);
if (lines.length < 2) continue;
const name = cleanName(lines.find(line => /[A-Za-z]/.test(line) && !/^\$/.test(line)) || lines[0]);
const price = parseFirstMoney(txt);
if (!name || !price) continue;
const bonus = findBonusHint(lines);
const quality = findQualityHint(lines);
candidates.push({ name, price, bonus, quality, text: txt, rect });
}
const seen = new Set();
return candidates.filter(c => {
const key = `${c.name}|${c.price}|${Math.round(c.rect.top / 50)}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
}).slice(0, 100);
}
function findBonusHint(lines) {
const line = lines.find(l => /\b(?:Grace|Parry|Deadeye|Warlord|Overclocked|Steel\s+Fist|Finesse|Anarchy|Berserk|Serrated|Precision|Ruthless|Outlaw|Puncture|Hawkeye|Juggernaut|Tactician|Rampage)\b/i.test(l));
return line ? cleanBonus(line) : null;
}
function findQualityHint(lines) {
const line = lines.find(l => /\b\d{2,3}(?:\.\d+)?%\b/i.test(l) || /\bYellow\b/i.test(l));
return line || null;
}
function buildFinding(observation, detail) {
const key = observation.key;
const hist = state.db[key] || [];
const estimated = estimateValue(key, observation.price, detail);
const profit = estimated != null && observation.price != null ? estimated - observation.price : null;
const roi = estimated != null && observation.price > 0 ? (profit / observation.price) * 100 : null;
const qualityText = observation.quality
? `${observation.quality.value.toFixed(2)}%${observation.quality.label ? ' ' + observation.quality.label : ''}`
: (detail?.quality ? `${detail.quality.value.toFixed(2)}%${detail.quality.label ? ' ' + detail.quality.label : ''}` : 'â');
return {
...observation,
estimated,
profit,
roi,
samples: hist.length,
qualityText,
rating: roi == null ? 'Unknown' : roi >= 60 ? 'Snipe' : roi >= 40 ? 'Great' : roi >= 25 ? 'Good' : roi >= 10 ? 'Watch' : 'Skip',
};
}
function estimateValue(key, currentPrice, detail) {
const hist = state.db[key] || [];
const prices = hist.map(x => x.price).filter(n => Number.isFinite(n) && n > 0);
if (prices.length) return median(prices);
if (detail?.value) return detail.value;
if (detail?.sell) return detail.sell;
if (detail?.buy) return Math.round(detail.buy * 0.62);
return currentPrice || null;
}
function recordObservation(observation) {
const { key, price, name, bonus, quality, source, seenAt } = observation;
if (!key || !Number.isFinite(price)) return;
if (!state.db[key]) state.db[key] = [];
state.db[key].push({
price: Math.round(price),
name,
bonus: bonus || null,
quality: quality || null,
source: source || 'page',
seenAt: seenAt || Date.now(),
});
if (state.db[key].length > 120) state.db[key] = state.db[key].slice(-120);
saveJSON(STORE_KEYS.db, state.db);
}
function maybeNotify(findings) {
const top = findings.find(x => Number.isFinite(x.roi) && x.roi >= state.cfg.notifyRoi && Number.isFinite(x.profit) && x.profit >= state.cfg.notifyProfit);
if (!top) return;
const cooldownKey = top.key;
const last = state.noticeCooldown.get(cooldownKey) || 0;
if (Date.now() - last < 10 * 60 * 1000) return;
state.noticeCooldown.set(cooldownKey, Date.now());
const msg = `${top.name} | ROI ${fmtPct(top.roi)} | +${fmtMoney(top.profit)}`;
if (typeof GM_notification === 'function') {
GM_notification({ title: 'RW deal found', text: msg, timeout: 7000 });
} else if ('Notification' in window && Notification.permission === 'granted') {
new Notification('RW deal found', { body: msg });
}
}
function render(findings) {
const list = [...findings]
.sort((a, b) => (b.roi ?? -Infinity) - (a.roi ?? -Infinity) || (b.profit ?? -Infinity) - (a.profit ?? -Infinity))
.slice(0, 5);
state.results = list;
syncCollapsedUI();
els.countPill.textContent = String(list.length);
els.bestRoi.textContent = list[0]?.roi != null ? fmtPct(list[0].roi) : 'â';
els.bestProfit.textContent = list[0]?.profit != null ? `+${fmtMoney(list[0].profit)}` : 'â';
widget.classList.remove('rw-alert-good', 'rw-alert-great', 'rw-alert-snipe');
if (list[0] && Number.isFinite(list[0].roi)) {
if (list[0].roi >= 60) widget.classList.add('rw-alert-snipe');
else if (list[0].roi >= 40) widget.classList.add('rw-alert-great');
else if (list[0].roi >= 25) widget.classList.add('rw-alert-good');
}
if (!list.length) {
els.list.innerHTML = `<div class="rw-item"><div class="name">No RW items found yet</div><div class="meta"><span>Open a market, auction, or detail page</span><span>then press Scan Now</span></div></div>`;
return;
}
els.list.innerHTML = list.map((x, idx) => `
<div class="rw-item ${idx === 0 ? 'top' : ''}">
<div class="name">${escapeHTML(x.name)}${x.bonus ? ` <span class="rw-badge">⢠${escapeHTML(x.bonus)}</span>` : ''}</div>
<div class="meta"><span>${escapeHTML(x.source)}${x.samples != null ? ` ⢠${x.samples} hist` : ''}</span><span>${escapeHTML(x.rating || 'â')}</span></div>
<div class="meta"><span>Price: ${x.price != null ? fmtMoney(x.price) : 'â'}</span><span>Est: ${x.estimated != null ? fmtMoney(x.estimated) : 'â'}</span></div>
<div class="meta"><span>Profit: ${x.profit != null ? (x.profit >= 0 ? '+' : '') + fmtMoney(x.profit) : 'â'}</span><span>ROI: ${x.roi != null ? fmtPct(x.roi) : 'â'}</span></div>
<div class="rw-mini">${escapeHTML(x.qualityText || 'â')}</div>
</div>
`).join('');
}
function compactAndSort(findings) {
const map = new Map();
for (const f of findings) {
if (!f.key) continue;
const prev = map.get(f.key);
if (!prev || compareFinding(f, prev) < 0) map.set(f.key, f);
}
return [...map.values()].sort((a, b) => compareFinding(b, a));
}
function compareFinding(a, b) {
const ar = Number.isFinite(a.roi) ? a.roi : -9999;
const br = Number.isFinite(b.roi) ? b.roi : -9999;
if (ar !== br) return ar - br;
const ap = Number.isFinite(a.profit) ? a.profit : -999999999;
const bp = Number.isFinite(b.profit) ? b.profit : -999999999;
return ap - bp;
}
function makeKey(name, bonus) {
return `${slug(name)}|${slug(bonus || 'no-bonus')}`;
}
function slug(s) {
return String(s || '')
.toLowerCase()
.replace(/&/g, ' and ')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'unknown';
}
function cleanName(s) {
return String(s || '')
.replace(/\s+/g, ' ')
.replace(/^[â¢Â·\-\s]+|[â¢Â·\-\s]+$/g, '')
.trim();
}
function cleanBonus(s) {
return String(s || '')
.replace(/\s+/g, ' ')
.replace(/^Bonus:\s*/i, '')
.replace(/[\u{1F300}-\u{1FAFF}]/gu, '')
.trim();
}
function parseFirstMoney(text) {
const m = String(text || '').match(/\$([\d,]+(?:\.\d+)?)/);
return m ? money(m[1]) : null;
}
function money(s) {
if (s == null) return null;
const n = Number(String(s).replace(/[^\d.]/g, ''));
return Number.isFinite(n) ? Math.round(n) : null;
}
function fmtMoney(n) {
if (!Number.isFinite(n)) return 'â';
const abs = Math.abs(n);
const sign = n < 0 ? '-' : '';
if (abs >= 1e9) return sign + '$' + (abs / 1e9).toFixed(abs >= 10e9 ? 1 : 2) + 'b';
if (abs >= 1e6) return sign + '$' + (abs / 1e6).toFixed(abs >= 10e6 ? 1 : 2) + 'm';
if (abs >= 1e3) return sign + '$' + Math.round(abs / 1e3) + 'k';
return sign + '$' + Math.round(abs).toLocaleString();
}
function fmtPct(n) {
if (!Number.isFinite(n)) return 'â';
const sign = n >= 0 ? '+' : '';
return `${sign}${n.toFixed(1)}%`;
}
function median(arr) {
const nums = arr.filter(Number.isFinite).slice().sort((a, b) => a - b);
const mid = Math.floor(nums.length / 2);
if (!nums.length) return null;
return nums.length % 2 ? nums[mid] : Math.round((nums[mid - 1] + nums[mid]) / 2);
}
function firstMatch(text, regex) {
const m = String(text || '').match(regex);
return m ? (m[1] ?? m[0]) : null;
}
function guessTitle(text) {
const lines = String(text || '').split('\n').map(s => s.trim()).filter(Boolean);
return cleanName(lines.find(line => line.length > 2 && line.length < 60 && !/^\$/.test(line)) || 'Unknown RW');
}
function normalizeText(text) {
return String(text || '')
.replace(/\u00a0/g, ' ')
.replace(/[\t\r]+/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function escapeHTML(s) {
return String(s || '').replace(/[&<>'"]/g, ch => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[ch]));
}
function isVisible(el) {
if (!el || !el.isConnected) return false;
const st = getComputedStyle(el);
if (st.display === 'none' || st.visibility === 'hidden' || st.opacity === '0') return false;
const rect = el.getBoundingClientRect();
return rect.bottom > 0 && rect.right > 0 && rect.top < (window.innerHeight || document.documentElement.clientHeight) && rect.left < (window.innerWidth || document.documentElement.clientWidth);
}
function applySavedPosition() {
const pos = loadJSON(STORE_KEYS.pos, null);
if (!pos) return;
if (Number.isFinite(pos.left)) widget.style.left = `${pos.left}px`;
if (Number.isFinite(pos.top)) widget.style.top = `${pos.top}px`;
}
function makeDraggable(root, handle) {
handle.addEventListener('pointerdown', (e) => {
if (e.target.closest('button')) return;
const rect = root.getBoundingClientRect();
drag.pointerId = e.pointerId;
drag.startX = e.clientX;
drag.startY = e.clientY;
drag.offsetX = e.clientX - rect.left;
drag.offsetY = e.clientY - rect.top;
drag.moved = false;
drag.active = true;
handle.setPointerCapture(e.pointerId);
root.style.transition = 'none';
e.preventDefault();
});
window.addEventListener('pointermove', (e) => {
if (!drag.active || e.pointerId !== drag.pointerId) return;
const dx = Math.abs(e.clientX - drag.startX);
const dy = Math.abs(e.clientY - drag.startY);
if (dx > 5 || dy > 5) drag.moved = true;
const left = e.clientX - drag.offsetX;
const top = e.clientY - drag.offsetY;
const maxLeft = Math.max(0, window.innerWidth - root.offsetWidth);
const maxTop = Math.max(0, window.innerHeight - root.offsetHeight);
root.style.left = `${clamp(left, 0, maxLeft)}px`;
root.style.top = `${clamp(top, 0, maxTop)}px`;
});
window.addEventListener('pointerup', (e) => {
if (e.pointerId !== drag.pointerId) return;
if (!drag.active) return;
drag.active = false;
root.style.transition = '';
if (!drag.moved) {
toggleWidget();
return;
}
snapToCorner(root);
saveJSON(STORE_KEYS.pos, {
left: Math.round(parseFloat(root.style.left) || 0),
top: Math.round(parseFloat(root.style.top) || 0),
});
});
window.addEventListener('pointercancel', () => {
drag.active = false;
root.style.transition = '';
});
}
function snapToCorner(root) {
const rect = root.getBoundingClientRect();
const w = rect.width || root.offsetWidth || 320;
const h = rect.height || root.offsetHeight || 160;
const margin = 6;
const centerX = rect.left + w / 2;
const centerY = rect.top + h / 2;
const targetLeft = centerX < window.innerWidth / 2 ? margin : Math.max(margin, window.innerWidth - w - margin);
const targetTop = centerY < window.innerHeight / 2 ? margin : Math.max(margin, window.innerHeight - h - margin);
root.style.left = `${clamp(targetLeft, margin, Math.max(margin, window.innerWidth - w - margin))}px`;
root.style.top = `${clamp(targetTop, margin, Math.max(margin, window.innerHeight - h - margin))}px`;
}
function clamp(n, min, max) {
return Math.max(min, Math.min(max, n));
}
function loadJSON(key, fallback) {
try {
const raw = typeof GM_getValue === 'function' ? GM_getValue(key, null) : localStorage.getItem(key);
if (!raw) return fallback;
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
return parsed ?? fallback;
} catch {
return fallback;
}
}
function saveJSON(key, value) {
try {
const raw = JSON.stringify(value);
if (typeof GM_setValue === 'function') GM_setValue(key, raw);
else localStorage.setItem(key, raw);
} catch (err) {
console.warn('[RW Scanner] save failed', err);
}
}
})();