// hovercard.js — rich-metadata tooltip for tree rows. // // Replaces the native title="…" attribute with a custom card that // surfaces every field we know about for a row: parsed ZDDC fields // (trackingNumber / revision / status / title / date), type, size, // modTime, on-server path, and URL. A delayed reveal (~350 ms) keeps // the card out of the way during fast traversal; it dismisses on // any click, right-click, scroll, or row change. // // Singleton DOM element appended to ; positioned fixed. (function () { 'use strict'; if (!window.app || !window.app.modules) return; var SHOW_DELAY_MS = 350; // Grace period after the cursor leaves the row before the card // hides. Lets the user move INTO the card to select / copy text; // the card cancels this timer on mouseenter. var HIDE_DELAY_MS = 200; var state = window.app.state; var card = null; var showTimer = null; var hideTimer = null; var currentRow = null; function ensureCard() { if (card) return card; card = document.createElement('div'); card.className = 'tree-hovercard'; card.setAttribute('aria-hidden', 'true'); // Mouse interaction inside the card: cancel any pending hide // so the user can stay in it as long as they want, then re- // schedule on leave. Pointer-events:auto in the CSS lets the // mouse enter; user-select:text (default) lets them drag a // selection; right-click inside fires the browser's native // Copy menu since we never call preventDefault for it here. card.addEventListener('mouseenter', cancelHide); card.addEventListener('mouseleave', scheduleHide); document.body.appendChild(card); return card; } function cancelHide() { if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; } } function scheduleHide() { cancelHide(); hideTimer = setTimeout(hide, HIDE_DELAY_MS); } function hide() { if (showTimer) { clearTimeout(showTimer); showTimer = null; } cancelHide(); if (card) card.classList.remove('is-visible'); currentRow = null; } // ── Formatting (kept local so this module is self-contained) ── function escapeHtml(s) { return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function fmtSize(bytes) { if (bytes == null) return ''; if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; if (bytes < 1024 * 1024 * 1024) { return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; } return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; } function fmtDate(d) { if (!d) return ''; var pad = function (n) { return n < 10 ? '0' + n : '' + n; }; return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()); } function typeLabelFor(node) { if (node.isDir) return 'Folder'; if (node.isZip) return 'Zip archive'; if (node.ext) return node.ext.toUpperCase() + ' file'; return 'File'; } function buildRowsHtml(node) { var tree = window.app.modules.tree; var z = window.zddc; var parsed = null; if (z) { parsed = node.isDir ? z.parseFolder(node.name) : z.parseFilename(node.name); } var html = ''; // ZDDC fields first when the basename parses. if (parsed && parsed.valid) { if (parsed.date) html += kv('Date', parsed.date, true); if (parsed.trackingNumber) html += kv('Tracking number', parsed.trackingNumber, true); if (parsed.revision) html += kv('Revision', parsed.revision, true); if (parsed.status) html += kv('Status', parsed.status, true); if (parsed.title) html += kv('Title', parsed.title); // Archive references — the //.archive/.html // URL is the latest issued version (highest base rev), and // //.archive/_.html pins the exact // revision the user is currently hovering. The dispatcher // canonicalises both forms to project-root so links work // from any depth. if (parsed.trackingNumber) { var fullPath = tree ? tree.pathFor(node) : ''; var rel = fullPath.replace(/^\/+|\/+$/g, ''); var firstSeg = rel ? rel.split('/')[0] : ''; if (firstSeg) { var encProject = encodeURIComponent(firstSeg); var encTracking = encodeURIComponent(parsed.trackingNumber); var latestUrl = '/' + encProject + '/.archive/' + encTracking + '.html'; var latestLbl = '.archive/' + parsed.trackingNumber + '.html'; html += kvLink('Latest', latestUrl, latestLbl); if (!node.isDir && parsed.revision) { var encRev = encodeURIComponent(parsed.revision); var inspectUrl = '/' + encProject + '/.archive/' + encTracking + '_' + encRev + '.html'; var inspectLbl = '.archive/' + parsed.trackingNumber + '_' + parsed.revision + '.html'; html += kvLink('This revision', inspectUrl, inspectLbl); } } } html += '
'; } else if (node.displayName) { // Operator-supplied display name — only useful as info if // it differs from the on-disk name. html += kv('Display name', node.displayName); } html += kv('Type', typeLabelFor(node)); if (!node.isDir) html += kv('Filename', node.name, true); if (!node.isDir && node.size != null) html += kv('Size', fmtSize(node.size)); if (node.modTime) html += kv('Modified', fmtDate(node.modTime)); if (node.virtual) html += kv('Virtual', 'Not yet created on disk'); // Path comes last (longest, most likely to wrap). var path = tree ? tree.pathFor(node) : ''; if (path) html += kv('Path', path, true); if (node.url && node.url !== path) html += kv('URL', node.url, true); return html; } function kv(key, val, mono) { return '' + escapeHtml(key) + '' + '' + escapeHtml(val) + ''; } // kvLink — value rendered as an the user can click (opens in // a new tab so the hover context isn't lost) or right-click to // copy. Used for the .archive references on ZDDC files. function kvLink(key, href, label) { return '' + escapeHtml(key) + '' + '' + '' + escapeHtml(label) + '' + ''; } function render(node) { var z = window.zddc; var parsed = z ? (node.isDir ? z.parseFolder(node.name) : z.parseFilename(node.name)) : null; var primary, secondary = ''; if (parsed && parsed.valid) { primary = parsed.title; var parts = node.isDir ? [parsed.date, parsed.trackingNumber, parsed.status] : [parsed.trackingNumber, parsed.revision, parsed.status]; secondary = parts.filter(Boolean).join(' · '); } else if (node.displayName) { primary = node.displayName; } else { primary = node.name; } card.innerHTML = '' + '
' + '
' + escapeHtml(primary) + '
' + (secondary ? '
' + escapeHtml(secondary) + '
' : '') + '
' + '
' + buildRowsHtml(node) + '
'; } function position(row) { // Two-pass measure: temporarily make visible-but-invisible so // we can read offsetWidth / offsetHeight, compute placement, // then reveal at the final coordinates. card.style.left = '0px'; card.style.top = '0px'; card.style.visibility = 'hidden'; card.classList.add('is-visible'); var cw = card.offsetWidth; var ch = card.offsetHeight; var rect = row.getBoundingClientRect(); var GAP = 8; var x = rect.right + GAP; if (x + cw > window.innerWidth - GAP) { x = rect.left - cw - GAP; } if (x < GAP) { // Fallback: anchor under the row (last resort when the // pane is wide enough that neither side fits). x = Math.max(GAP, Math.min(rect.left, window.innerWidth - cw - GAP)); } var y = rect.top; if (y + ch > window.innerHeight - GAP) { y = Math.max(GAP, window.innerHeight - ch - GAP); } if (y < GAP) y = GAP; card.style.left = x + 'px'; card.style.top = y + 'px'; card.style.visibility = ''; } function showFor(row, node) { ensureCard(); render(node); position(row); card.classList.add('is-visible'); } function init() { var treeBody = document.getElementById('treeBody'); if (!treeBody) return; treeBody.addEventListener('mouseover', function (e) { // Returning to the tree from the card cancels any pending // hide; the show logic below handles row changes. cancelHide(); var row = e.target.closest('.tree-row'); if (row === currentRow) return; // Row → row or row → empty space — reset. if (showTimer) { clearTimeout(showTimer); showTimer = null; } if (card) card.classList.remove('is-visible'); currentRow = row || null; if (!row) return; showTimer = setTimeout(function () { if (currentRow !== row) return; var id = parseInt(row.dataset.id, 10); var node = state.nodes.get(id); if (node) showFor(row, node); }, SHOW_DELAY_MS); }); // Leaving the tree schedules a hide rather than hiding // immediately, so the cursor has time to traverse the gap to // the card. The card's own mouseenter cancels the hide. treeBody.addEventListener('mouseleave', scheduleHide); treeBody.addEventListener('contextmenu', hide); window.addEventListener('scroll', hide, true); window.addEventListener('resize', hide); document.addEventListener('keydown', function (e) { if (e.key === 'Escape') hide(); }); // Click anywhere outside the card dismisses it. Clicks INSIDE // the card are allowed through so the user can drag-select // text, right-click for the browser's native Copy menu, or // hit Ctrl/Cmd-C. document.addEventListener('mousedown', function (e) { if (!card || !card.classList.contains('is-visible')) return; if (card.contains(e.target)) return; hide(); }, true); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } window.app.modules.hovercard = { hide: hide }; })();