Files
Torn-Training-Tracker/src/main.js
T
Claude bb33bcbb61 fix(main): broaden anchor selector and show inline "can't anchor" note
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>
2026-06-01 18:45:40 -05:00

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