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: }'); + const named = arg.item ?? arg.title; + const it = (named != null) + ? await itemXY(arg.track, named, arg.title != null && arg.item == null, false) + : await posXY(arg.track, arg); + await glide(it.x, it.y); await sleep(200); + await page.mouse.click(it.x, it.y, { button: 'right' }); + const menuUp = await page.waitForSelector('.context-menu:visible', { timeout: 8000 }).then(() => true).catch(() => false); + if (!menuUp) throw new Error(`rightClick: no menu came up over ${arg.track}`); + await sleep(300); + if (arg.pick == null) { + if (arg.shot) { await shot(arg.shot); return; } + break; + } + const offered = await page.$$eval('.context-menu-item', els => els + .filter(e => e.offsetWidth > 0).map(e => (e.innerText || e.textContent || '').trim())); + // An exact entry wins over a substring, so pick: Hide is the visibility entry and + // not "Hide all other tracks". + const want = String(arg.pick).trim().toLowerCase(); + let i = offered.findIndex(t => t.toLowerCase() === want); + if (i < 0) i = offered.findIndex(t => t.toLowerCase().includes(want)); + if (i < 0) throw new Error(`rightClick: no menu entry "${arg.pick}"; the menu offered: ${offered.join(' | ')}`); + console.log('rightClick:', arg.track, '->', offered[i]); + if (arg.shot) await shot(arg.shot); + const entry = page.locator('.context-menu-item:visible').nth(i); + const box = await entry.boundingBox(); + if (box) { await glide(box.x + Math.min(40, box.width / 2), box.y + box.height / 2); await sleep(200); } + const was = page.url(); + await entry.click(); + // An entry answers with an in-place redraw, an hgc/hgTrackUi dialog, or a full + // navigation (View image, Configure). Wait for the page to go quiet either way. + await page.waitForFunction(u => location.href !== u || !window.jQuery || jQuery.active === 0, + was, { timeout: 20000 }).catch(() => {}); + await page.waitForLoadState('load').catch(() => {}); + await page.waitForSelector('#imgTbl', { timeout: 20000 }).catch(() => {}); + await sleep(300); + await captureState(); + break; + } + case 'setVis': { + // Set a track's visibility with its own dropdown under the image, IN PLACE: the + // select changes and its change event fires, and the page's handler does the rest, + // with no navigation. `track:` is the other half: it asks for the mode in a URL and + // draws a fresh page, so it can never see what the dropdown's handler does to the + // page it is on -- the Visible Tracks list keeping a hidden track (#38035), say. + // {: , ...} in order; `in: visible` uses the copy in the Visible Tracks + // group, `in: group` the one in the track's own group (a visible track has both). + if (!arg || typeof arg !== 'object') throw new Error('setVis: takes {: }'); + const where = arg.in; + if (where != null && where !== 'visible' && where !== 'group') + throw new Error(`setVis: in: takes visible or group, not "${where}"`); + for (const [name, mode] of Object.entries(arg)) { + if (name === 'in' || name === 'shot') continue; + const own = `select[name="${name}"]`; + const sel = (where === 'visible') ? `[id^="visible-"] ${own}` + : (where === 'group') ? `${own}:not([id^="visible-"] ${own})` : own; + const opts = await page.locator(sel).first() + .evaluate(el => [...el.options].map(o => o.value)).catch(() => null); + if (!opts) throw new Error(`setVis: no visibility dropdown for ${name}` + + (where ? ` in the ${where === 'visible' ? 'Visible Tracks' : 'its own'} group` : '') + ' on this page'); + if (!opts.includes(String(mode))) throw new Error(`setVis: ${name} offers ${opts.join('/')}, not ${mode}`); + console.log('setVis:', `${name}=${mode}`, where ? `(${where})` : ''); + await openSelectVisible(sel, String(mode), 6, true); + } + // The handler posts the new value to the cart and may redraw; let that finish. + await page.waitForFunction(() => !window.jQuery || jQuery.active === 0, null, { timeout: 20000 }).catch(() => {}); + await sleep(300); + await captureState(); + if (arg.shot) { await shot(arg.shot); return; } + break; + } case 'hover': await glideTo(arg); await page.hover(arg); break; case 'fill': { // Type into an arbitrary form field: {: }, one or more pairs, in order. // The named verbs type into the boxes they own (position, hub URL, hub search); this is // for any other box, such as the session name on the Sessions page save card (#38311). // The field is emptied first, so a value left from an earlier step cannot run into it. if (!arg || typeof arg !== 'object') throw new Error('fill: takes {: }'); for (const [sel, text] of Object.entries(arg)) { await glideTo(sel); await page.fill(sel, ''); await typeIn(page, sel, text); } break; } case 'wait': {