Menu refinements per review: - "Open" now navigates into a folder (rescope); the separate "Navigate into" item is removed. Zip → expand inline (can't navigate in); file → preview. Inline expand stays on single-click / chevron / arrow keys. - "New markdown file" → "New file". - New folder / New file / Rename / Delete are now HIDDEN when the user lacks the create/write/delete capability (folded into appliesTo) instead of shown greyed — a guest gets a lean menu; users who can still see them. New folder/file also remain on the toolbar. - "Edit access rules…" is shown only when the user can actually edit them (admin verb 'a' or subtree/site admin) — hidden otherwise, not greyed. - Removed "Copy path" / "Copy name" — the info box (hovercard) carries the name and a clickable URL now. Info box (hovercard): dropped the on-disk "Path" row; the "URL" is rendered as a clickable hyperlink (via the existing kvLink helper) — the shareable reference, openable or right-click-to-copy. Tests updated: file row omits New folder/file + Copy + Navigate; permission- gated Rename/Delete are HIDDEN for a read-only server node and PRESENT for a read/write/delete node (pure menuModel unit). All browse+conflict+diff green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
333 lines
14 KiB
JavaScript
333 lines
14 KiB
JavaScript
// 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 <body>; 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 ──
|
|
|
|
var escapeHtml = window.app.modules.util.escapeHtml;
|
|
var fmtSize = window.app.modules.util.fmtSize;
|
|
|
|
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';
|
|
}
|
|
|
|
var VERB_NAMES = { r: 'read', w: 'write', c: 'create', d: 'delete', a: 'admin' };
|
|
function verbsLabel(verbs) {
|
|
return ['r', 'w', 'c', 'd', 'a']
|
|
.filter(function (v) { return verbs.indexOf(v) !== -1; })
|
|
.map(function (v) { return VERB_NAMES[v]; })
|
|
.join(', ');
|
|
}
|
|
// permsValue renders the per-entry verb set the principal holds here.
|
|
// Server mode: node.verbs ("rwcda" subset). Offline (FS-API) mode has
|
|
// no ACL — access is whatever the filesystem grants.
|
|
function permsValue(verbs) {
|
|
if (typeof verbs !== 'string') {
|
|
return state.source === 'fs' ? 'local folder (filesystem)' : 'unknown';
|
|
}
|
|
if (!verbs) return 'none (read-only)';
|
|
return verbsLabel(verbs) + ' (' + verbs + ')';
|
|
}
|
|
|
|
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 /<project>/.archive/<tracking>.html
|
|
// URL is the latest issued version (highest base rev), and
|
|
// /<project>/.archive/<tracking>_<rev>.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 += '<div class="tree-hovercard__sep"></div>';
|
|
} 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');
|
|
|
|
// ── Effective access for the current principal at this location ──
|
|
// "Your permissions" is the per-entry verb set (sync, from the
|
|
// listing). "Your roles" is cascade-scoped — it can differ by
|
|
// location — so it needs a path-scoped fetch; render a placeholder
|
|
// that fillRoles() updates once /.profile/access?path= resolves.
|
|
html += '<div class="tree-hovercard__sep"></div>';
|
|
html += kv('Your permissions', permsValue(node.verbs));
|
|
if (state.source === 'server') {
|
|
html += '<span class="tree-hovercard__key">Your roles</span>'
|
|
+ '<span class="tree-hovercard__val" id="hc-roles">…</span>';
|
|
}
|
|
|
|
// URL last (longest, most likely to wrap) — rendered as a clickable
|
|
// link the user can open or right-click to copy. The on-disk path is
|
|
// intentionally omitted; the URL is the shareable reference.
|
|
if (node.url) html += kvLink('URL', node.url, node.url);
|
|
|
|
return html;
|
|
}
|
|
|
|
function kv(key, val, mono) {
|
|
return '<span class="tree-hovercard__key">' + escapeHtml(key) + '</span>'
|
|
+ '<span class="tree-hovercard__val'
|
|
+ (mono ? ' tree-hovercard__val--mono' : '')
|
|
+ '">' + escapeHtml(val) + '</span>';
|
|
}
|
|
|
|
// kvLink — value rendered as an <a> 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 '<span class="tree-hovercard__key">' + escapeHtml(key) + '</span>'
|
|
+ '<span class="tree-hovercard__val tree-hovercard__val--mono">'
|
|
+ '<a href="' + escapeHtml(href) + '" target="_blank" rel="noopener">'
|
|
+ escapeHtml(label)
|
|
+ '</a>'
|
|
+ '</span>';
|
|
}
|
|
|
|
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 = ''
|
|
+ '<div class="tree-hovercard__header">'
|
|
+ '<div class="tree-hovercard__title">' + escapeHtml(primary) + '</div>'
|
|
+ (secondary
|
|
? '<div class="tree-hovercard__sub">' + escapeHtml(secondary) + '</div>'
|
|
: '')
|
|
+ '</div>'
|
|
+ '<div class="tree-hovercard__list">' + buildRowsHtml(node) + '</div>';
|
|
}
|
|
|
|
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');
|
|
fillRoles(row, node);
|
|
}
|
|
|
|
// Async-fill the "Your roles" row from the path-scoped access view
|
|
// (zddc.cap.at memoises per path, so repeat hovers are instant).
|
|
// Bails if the card has moved to another row before the fetch lands.
|
|
async function fillRoles(row, node) {
|
|
if (state.source !== 'server') return;
|
|
if (!window.zddc || !window.zddc.cap) return;
|
|
var tree = window.app.modules.tree;
|
|
var path = tree ? tree.pathFor(node) : '';
|
|
if (!path) return;
|
|
var view;
|
|
try { view = await window.zddc.cap.at(path); } catch (_e) { return; }
|
|
if (currentRow !== row) return;
|
|
var el = card && card.querySelector('#hc-roles');
|
|
if (!el) return;
|
|
var roles = (view && Array.isArray(view.path_roles)) ? view.path_roles : [];
|
|
el.textContent = roles.length ? roles.join(', ') : 'none';
|
|
}
|
|
|
|
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 };
|
|
})();
|