bb33bcbb61
findAnchorElement used a narrow selector ('button[name="train"],
a[href*="train"]') that often missed Torn's actual gym page DOM.
When it missed, applyMode fell through to dialog.setMode('free'),
snapping the dialog to the default bottom-right position — which the
user perceived as a 'bounce' when clicking 'Above training UI'.
Widen the selector to a priority-ordered candidate list and prefer the
form ancestor of any matched element. As a last-ditch, look for a form
inside the gym panel. This covers more of Torn's gym-page variations.
When the anchor selector still misses, do NOT snap to the default free
position. Instead, keep the dialog where it is, set anchorError on the
state, and let the dialog render an inline note so the user gets
visible feedback explaining what happened.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
153 lines
4.3 KiB
JavaScript
153 lines
4.3 KiB
JavaScript
import { Store } from './store.js';
|
|
import { Dialog } from './ui.js';
|
|
import { currentAttribute } from './dom.js';
|
|
import { startRequestInterceptor } from './interceptor.js';
|
|
|
|
function findAnchorElement() {
|
|
// Try several selectors in priority order. Torn's gym page structure
|
|
// varies; we cast a wide net and return the first match.
|
|
const candidates = [
|
|
'form[action*="train"]',
|
|
'form.train-form',
|
|
'form[class*="train"]',
|
|
'[class*="train-button"]',
|
|
'button[class*="train"]',
|
|
'a[class*="train"]',
|
|
'button[name="train"]',
|
|
'a[href*="train"]',
|
|
];
|
|
for (const sel of candidates) {
|
|
const el = document.querySelector(sel);
|
|
if (el) {
|
|
// Prefer the form ancestor if the match is a button/link, since we
|
|
// want to anchor above the whole form, not just the button.
|
|
return el.closest('form') || el;
|
|
}
|
|
}
|
|
// Last-ditch: any element inside the gym panel that looks like the
|
|
// training form.
|
|
const panel = document.querySelector('.gym, #gym, [class*="gym-"], [class*="Gym"]');
|
|
if (panel) {
|
|
const form = panel.querySelector('form');
|
|
if (form) return form;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function start() {
|
|
const store = new Store({
|
|
storage: localStorage,
|
|
onWarn: (m) => console.warn(m),
|
|
});
|
|
const prefs = store.getPrefs();
|
|
|
|
const dialog = new Dialog({
|
|
onTargetChange: (v) => {
|
|
const attr = currentAttribute()?.attr;
|
|
if (!attr) return;
|
|
store.setTarget(attr, v);
|
|
render();
|
|
},
|
|
onModeChange: (m) => {
|
|
store.setMode(m);
|
|
prefs.mode = m;
|
|
applyMode();
|
|
},
|
|
onPosChange: (pos) => store.setPos(pos),
|
|
onClose: () => dialog.destroy(),
|
|
});
|
|
|
|
dialog.mount({ initialMode: prefs.mode, initialPos: prefs.pos });
|
|
applyMode();
|
|
|
|
let lastSnapshot = null;
|
|
let lastAttr = null;
|
|
let lastDelta = 0;
|
|
let anchorError = null;
|
|
|
|
function snapshot() {
|
|
const a = currentAttribute();
|
|
if (!a) {
|
|
return { error: "Couldn't read attribute — Torn may have updated the page." };
|
|
}
|
|
lastAttr = a.attr;
|
|
const summary = store.getSummary(a.attr);
|
|
return {
|
|
attr: a.attr,
|
|
gym: a.gym,
|
|
current: a.current,
|
|
target: store.getTarget(a.attr),
|
|
perTrain: lastDelta,
|
|
summary,
|
|
warn: store._saveDisabled ? 'saving disabled this session' : null,
|
|
anchorError: anchorError,
|
|
};
|
|
}
|
|
|
|
function render() {
|
|
lastSnapshot = snapshot();
|
|
dialog.render(lastSnapshot);
|
|
}
|
|
|
|
function applyMode() {
|
|
if (prefs.mode === 'anchored') {
|
|
const el = findAnchorElement();
|
|
if (el) {
|
|
const rect = el.getBoundingClientRect();
|
|
dialog.setMode('anchored', { canAnchor: true });
|
|
dialog._positionAnchored(rect);
|
|
// observe
|
|
const ro = new ResizeObserver(() => {
|
|
if (prefs.mode === 'anchored') dialog._positionAnchored(el.getBoundingClientRect());
|
|
});
|
|
ro.observe(el);
|
|
anchorError = null;
|
|
return;
|
|
}
|
|
// Anchor selector missed — don't snap to default, just keep current
|
|
// position and show a note.
|
|
anchorError = "Couldn't find the training form on this page.";
|
|
render();
|
|
return;
|
|
}
|
|
anchorError = null;
|
|
dialog.setMode('free');
|
|
}
|
|
|
|
// initial paint
|
|
render();
|
|
|
|
// watch DOM for attribute changes
|
|
let pending = false;
|
|
const observer = new MutationObserver(() => {
|
|
if (pending) return;
|
|
pending = true;
|
|
requestAnimationFrame(() => {
|
|
pending = false;
|
|
const a = currentAttribute();
|
|
if (a && (a.attr !== lastAttr || a.current !== lastSnapshot?.current)) render();
|
|
});
|
|
});
|
|
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
|
|
|
|
// intercept train requests
|
|
const prev = currentAttribute()?.current ?? 0;
|
|
startRequestInterceptor({
|
|
prevValue: prev,
|
|
currentAttr: lastAttr,
|
|
onTrain: ({ attr, delta, ts }) => {
|
|
store.recordTrain(attr, delta, ts);
|
|
lastDelta = delta;
|
|
render();
|
|
},
|
|
onParseFail: (url) => console.warn('[tat] could not parse train response from', url),
|
|
});
|
|
}
|
|
|
|
if (location.hash === '#tat-test') {
|
|
// Self-test path: the in-browser tests live in the bundled userscript.
|
|
// The bundle runs the test block; nothing to do here.
|
|
} else if (/\/gym\.php(\?|$)/.test(location.pathname + location.search)) {
|
|
start();
|
|
}
|