9cdd7a15f40c41874620a7d2bc907d77489f7746
braney
Sat Sep 26 17:43:32 2026 -0700
docent: setVis:, rightClick:, and drag: then: none / dialog: auto, refs #37892, #38035, #38071, #38087
setVis: sets a track's visibility with its own dropdown under the image. The change
event fires and the page's handler runs, with no navigation. track: asks for the mode in
a URL, so it cannot see what that handler does to the page.
rightClick: opens the track right-click menu and picks an entry by its text. An exact
match wins over a substring. A miss fails the run and lists the entries on offer.
drag: then: none leaves the drag-select dialog open for later click: steps. dialog: auto
hands the selection to dragSelect.selectEnd(), so the page decides between the dialog
and a straight zoom instead of the verb forcing the dialog on.
A track name ending in * now matches a row id by prefix, for custom tracks. shot: also
captures an open right-click menu.
diff --git src/hg/utils/docent/docent.js src/hg/utils/docent/docent.js
index bece0ad1c9f..7659a4ba4ed 100755
--- src/hg/utils/docent/docent.js
+++ src/hg/utils/docent/docent.js
@@ -685,61 +685,68 @@
async function shot(name) {
const p = path.join(STILLDIR, name + '.png');
// Drop any click ripple still fading: it reads as a red blob over whatever was just
// clicked. It belongs to the video, not to a figure.
await page.evaluate(() => document.querySelectorAll('.__ripple').forEach(e => e.remove())).catch(() => {});
// If a mouseover tooltip is currently up, capture the image + tooltip together
// (the tooltip is appended to
, so an #imgTbl element shot would clip it).
const clip = await page.evaluate(() => {
const im = document.getElementById('imgTbl');
if (!im) return null;
// Floating overlays to capture together with the image: the mouseover tooltip,
// and any visible jQuery-UI dialog (e.g. the drag-select "Zoom In / Highlight" box).
const overlays = [];
const tip = window.__docentTip && window.__docentTip();
if (tip) overlays.push(tip);
- for (const d of document.querySelectorAll('.ui-dialog')) if (d.offsetWidth > 0) overlays.push(d);
+ for (const d of document.querySelectorAll('.ui-dialog, .context-menu')) if (d.offsetWidth > 0) overlays.push(d);
if (!overlays.length) return null;
const a = im.getBoundingClientRect();
let x = a.left, y = a.top, x2 = a.right, y2 = a.bottom;
for (const o of overlays) { const r = o.getBoundingClientRect(); x = Math.min(x, r.left); y = Math.min(y, r.top); x2 = Math.max(x2, r.right); y2 = Math.max(y2, r.bottom); }
return { x: Math.max(0, x - 4), y: Math.max(0, y - 4), width: (x2 - x) + 8, height: (y2 - y) + 8 };
});
if (clip) {
await page.screenshot({ path: p, clip });
} else if (await page.locator('#imgTbl').count()) {
await page.locator('#imgTbl').screenshot({ path: p });
} else {
// Not a tracks page (hgc detail page, an external page a link led to, ...). An element
// shot of would run the whole scrolling page -- thousands of pixels tall and
// unusable as a figure. Capture the viewport only, i.e. the top of the page.
await page.screenshot({ path: p });
}
shotSecs.push((Date.now() - T_REC) / 1000);
console.log('SHOT', p);
await sleep(SHOTHOLD);
}
// Resolve a track name to its DOM key + data-image and row bounding boxes. On a
// quickLift/Convert target the tracks come from a hub, so ids gain a dynamic
// `hub__` prefix -- match by suffix so the YAML can just say `track: quickLiftChain`.
// A container draws nothing itself, so if the name given is one, fall through to the
// leaves trackDb lists under it and take the first that is actually on the page.
async function trackBox(t) {
const cands = [t, ...await tdbLeaves(t)];
let key = null;
for (const c of cands) {
key = await page.evaluate(k => {
+ // A trailing * matches by prefix, for a custom track, whose id carries a random
+ // number no script can know in advance (ct_rm38087_723 is ct_rm38087_*).
+ if (k.endsWith('*')) {
+ const pre = [...document.querySelectorAll('[id^="img_data_"]')]
+ .find(e => e.id.startsWith('img_data_' + k.slice(0, -1)));
+ return pre ? pre.id.replace('img_data_', '') : null;
+ }
if (document.getElementById('img_data_' + k)) return k;
const el = [...document.querySelectorAll('[id^="img_data_"]')]
.find(e => e.id === 'img_data_' + k || e.id.endsWith('_' + k));
return el ? el.id.replace('img_data_', '') : null;
}, c);
if (key) { if (c !== t) console.log(`track ${t}: drawn by "${c}"`); break; }
}
if (!key) throw new Error(`track "${t}" not shown (no #img_data_ for ${cands.join(', ')})`);
const img = await page.locator(`#img_data_${key}`).first().boundingBox({ timeout: 8000 }).catch(() => null);
const row = await page.locator(`#imgTbl tr#tr_${key}`).first().boundingBox({ timeout: 8000 }).catch(() => null);
if (!img || !row) throw new Error(`track "${t}" not shown (need #img_data_${key} + #tr_${key})`);
// Everything hgTracks reports about the image -- map-box coords, mouseOver spans,
// insideX -- is in the pixels of the image the SERVER drew, which is not the size the
// page shows it at when the image is scaled (SCALE). imgPx is that ratio, so those
// numbers can be turned into page coordinates: 1 normally, 1/SCALE for a print render.
@@ -1767,31 +1774,37 @@
}
const msg = bad.join('; ') + `\n drawn: ${seen.rows.join(', ') || '(none)'}`;
if (o.warn) console.warn('EXPECT (warning only):', msg);
else throw new Error(msg);
}
// Shift+drag across the track image to open the browser's own drag-select dialog
// ("Zoom In / Single Highlight / ..."), then act on it. The usual form gives one
// genomic region and zooms: drag: chr7:155,806,100-155,806,557
// Any other action needs the map form, which is also how you pass shot:/track:
// drag: {range: "chr7:155,806,100-155,806,557", then: highlight}
// Endpoints that are not genomic coords use a fraction
// across the view (fromFrac:/toFrac:) or a raw pixel (fromX:/toX:) instead.
// Optional `track:` picks the row the drag runs over (y); default is the middle of
// the image. `shot:` captures the open dialog (e.g. the Figure 1A drag-select box).
// `then:` = zoom (default, clicks Zoom In) | highlight (Single Highlight) | cancel
- // (Escape, leaves the view unchanged).
+ // (Escape, leaves the view unchanged) | none (leaves the dialog OPEN, so later steps can
+ // tick its checkbox or press one of its other buttons with click:).
+ // `dialog: auto` stops the verb forcing the dialog on. By default it arms
+ // hgTracks.enableHighlightingDialog and opens the dialog itself, so a figure always gets
+ // one. With auto the selection is handed to the page's own dragSelect.selectEnd(), which
+ // opens the dialog only if the user has not switched it off and otherwise zooms straight
+ // away, so a script can see what the "Don't show this again" checkbox did (#38071).
async function drag(o) {
// A bare string is the region; `range:` is the same thing with room for other
// keys. Both expand to the from:/to: endpoints the rest of this function uses.
if (typeof o === 'string') o = { range: o };
o = o || {};
if (o.range != null) {
const m = String(o.range).match(/^\s*(.+):([\d,]+)\s*-\s*([\d,]+)\s*$/);
if (!m) throw new Error(`drag: range "${o.range}" is not chrom:start-end`);
o = Object.assign({}, o, { from: `${m[1]}:${m[2]}`, to: `${m[1]}:${m[3]}` });
}
const img = await page.locator('img[id^="img_data_"]').first().boundingBox({ timeout: 8000 }).catch(() => null);
const tbl = await page.locator('#imgTbl').first().boundingBox({ timeout: 8000 }).catch(() => null);
if (!img || !tbl) throw new Error('drag: track image not shown (need #imgTbl)');
const coordFrac = at => page.evaluate(a => {
try { const s = hgTracks.winStart, e = hgTracks.winEnd;
@@ -1832,65 +1845,103 @@
// The selected genomic range: from coord endpoints we already have it; otherwise
// derive it from the view fractions below (inside the browser).
const coordNum = v => +String(v).replace(/.*:/, '').replace(/,/g, '');
let posStr = null;
if (typeof o.from === 'string' && o.from.includes(':') && o.to != null) {
const chrom = o.from.split(':')[0], a = coordNum(o.from), b = coordNum(o.to);
posStr = `${chrom}:${Math.min(a, b)}-${Math.max(a, b)}`;
}
// Visible cursor sweep across the selection (no button-down — a real drag would
// just PAN the image). The drag-select band is grown UNDER the cursor as it moves,
// so it reads like a genuine shift+drag rather than popping in at the end; the
// dialog is then raised via the browser's own dragSelect entry point, the same way
// highlight_shot.js does it.
await glide(x1, y); await sleep(200);
// Arm the highlighting dialog and open a zero-width selection at the start point.
- await page.evaluate(({ ix1, y1, y2 }) => {
+ const auto = o.dialog === 'auto';
+ if (o.dialog != null && !auto) throw new Error(`drag: dialog: takes only "auto", not "${o.dialog}"`);
+ await page.evaluate(({ ix1, y1, y2, auto }) => {
try {
- hgTracks.enableHighlightingDialog = true;
+ if (!auto) hgTracks.enableHighlightingDialog = true;
dragSelect.startTime = Date.now();
$(imageV2.imgTbl).imgAreaSelect({ x1: ix1, y1, x2: ix1, y2, show: true });
} catch (_) {}
- }, { ix1: x1 - tbl.x, y1, y2 });
+ }, { ix1: x1 - tbl.x, y1, y2, auto });
// Sweep to the end, widening the band to the cursor's x at every step.
const dsteps = Math.max(10, Math.round(Math.abs(x2 - x1) / 9));
for (let i = 1; i <= dsteps; i++) {
const px = x1 + (x2 - x1) * i / dsteps;
await page.mouse.move(px, y);
await page.evaluate(({ ix1, ix2, y1, y2 }) => {
try { $(imageV2.imgTbl).imgAreaSelect({ x1: Math.min(ix1, ix2), y1, x2: Math.max(ix1, ix2), y2, show: true }); } catch (_) {}
}, { ix1: x1 - tbl.x, ix2: px - tbl.x, y1, y2 });
await sleep(15);
}
cur.x = x2; cur.y = y;
await sleep(200);
+ if (auto) {
+ // Let the page decide: the same call imgAreaSelect makes when the mouse is released,
+ // with an event that carries no modifier keys. It either opens the dialog or zooms.
+ const before = await page.evaluate(() => { try { return hgTracks.winStart + '-' + hgTracks.winEnd; } catch (e) { return ''; } });
+ const was = page.url();
+ const r = await page.evaluate(({ ix1, ix2, y1, y2, ey }) => {
+ try {
+ const tb = imageV2.imgTbl[0], ofs = jQuery(tb).offset();
+ const ev = { altKey: false, metaKey: false, ctrlKey: false, shiftKey: true,
+ pageX: ofs.left + ix2, pageY: ofs.top + ey };
+ const selection = { x1: Math.min(ix1, ix2), x2: Math.max(ix1, ix2), y1, y2,
+ width: Math.abs(ix2 - ix1), height: y2 - y1, event: ev };
+ dragSelect.selectEnd(tb, selection, ev);
+ return true;
+ } catch (e) { return String((e && e.message) || e); }
+ }, { ix1: x1 - tbl.x, ix2: x2 - tbl.x, y1, y2, ey: y - tbl.y });
+ if (r !== true) throw new Error('drag: dragSelect.selectEnd failed (' + r + ')');
+ const how = await Promise.race([
+ page.waitForSelector('#dragSelectDialog:visible', { timeout: 10000 }).then(() => 'dialog'),
+ page.waitForFunction(prev => { try { return (hgTracks.winStart + '-' + hgTracks.winEnd) !== prev; } catch (e) { return false; } },
+ before, { timeout: 10000 }).then(() => 'zoom'),
+ page.waitForFunction(u => location.href !== u, was, { timeout: 10000 }).then(() => 'zoom'),
+ ]).catch(() => null);
+ if (!how) throw new Error('drag: dialog: auto -- neither the dialog nor a zoom followed the selection');
+ if (how === 'zoom') {
+ console.log('drag: no dialog, the page zoomed straight away');
+ await page.waitForSelector('#imgTbl'); await sleep(400);
+ await captureState();
+ return;
+ }
+ console.log('drag: the page opened the drag-select dialog');
+ } else {
// Band is fully drawn — now raise the Drag-and-select dialog.
const res = await page.evaluate(({ f1, f2, posStr }) => {
try {
let pos = posStr;
if (!pos) { const s = hgTracks.winStart, e = hgTracks.winEnd;
const c1 = Math.round(s + (e - s) * f1), c2 = Math.round(s + (e - s) * f2);
pos = hgTracks.chromName + ':' + (Math.min(c1, c2) + 1) + '-' + Math.max(c1, c2); }
dragSelect.selectionEndDialog(pos);
return true;
} catch (e) { return String((e && e.message) || e); }
}, { f1: (x1 - dataLeft) / dataW, f2: (x2 - dataLeft) / dataW, posStr });
const up = await page.waitForSelector('#dragSelectDialog:visible', { timeout: 4000 }).then(() => true).catch(() => false);
if (!up) throw new Error('drag: drag-select dialog did not open' + (res === true ? '' : ' (' + res + ')'));
+ }
await sleep(400);
if (o.shot) await shot(o.shot);
const act = o.then || 'zoom';
+ if (!['zoom', 'highlight', 'cancel', 'none'].includes(act))
+ throw new Error(`drag: then: takes zoom, highlight, cancel or none, not "${act}"`);
+ if (act === 'none') return;
if (act === 'cancel') { await page.keyboard.press('Escape'); }
else {
const label = (act === 'highlight') ? 'Single Highlight' : 'Zoom In';
const sel = `.ui-dialog-buttonset button:has-text("${label}")`;
// "Zoom In" either full-submits the form OR does an AJAX in-place update
// (imageV2.inPlaceUpdate) that never fires a page load -- so don't wait on load;
// wait for the live hgTracks window to actually change from its pre-click value.
const before = await page.evaluate(() => { try { return hgTracks.winStart + '-' + hgTracks.winEnd; } catch (e) { return ''; } });
await clickGlide(sel);
if (act !== 'highlight') {
await page.waitForFunction(prev => {
try { return (hgTracks.winStart + '-' + hgTracks.winEnd) !== prev; } catch (e) { return false; }
}, before, { timeout: 8000 }).catch(() => {});
await page.waitForSelector('#imgTbl'); await sleep(400);
}
@@ -2497,30 +2548,109 @@
await page.waitForSelector('#imgTbl', { timeout: 20000 }).catch(() => {});
await captureState();
}
if (arg.shot) { await shot(arg.shot); return; }
} else {
// Plain selector click. Strip target=_blank first so an external link (e.g. a
// dbSNP id -> NIH) navigates in THIS tab instead of a popup we can't screenshot,
// then wait out the navigation so a following shot captures the destination page
// (a no-op if the click didn't navigate).
await page.evaluate(s => document.querySelectorAll(s).forEach(e => e.removeAttribute('target')), arg).catch(() => {});
await clickGlide(arg);
await page.waitForLoadState('load').catch(() => {});
await captureState();
}
break;
+ case 'rightClick': {
+ // Open hgTracks' right-click menu over a track and, with pick:, choose an entry by a
+ // substring of its text. Addressed like click: -- item:/title: by identity, or
+ // at:/frac:/x: for a bare point on the row. The menu is the only way in to what it
+ // offers (Delete Custom Track, Hide all other tracks, Move to top ...), and each
+ // entry runs in place, so the verb waits out the page's ajax rather than a load.
+ // With no pick: the menu is left open, for a shot: of it or an expect: on its text.
+ if (!arg || typeof arg !== 'object' || !arg.track)
+ throw new Error('rightClick: takes {track: , item:|title:|at:|frac:|x:, pick: