Major upgrade to the browse tool's UX, plus a few shared modules other tools can adopt. User-facing: - Right-click context menu on tree rows AND empty pane space. Traditional file-manager grouping (Open / Download / New / Rename-Delete / Copy / Tree ops / View). Items stay visible but disabled when not applicable so muscle memory carries. Generic shared/context-menu.js framework supports normal items, toggles, submenus, separators, danger styling. - YAML editor for .yaml / .yml / .zddc files (CodeMirror 5 vendored at shared/vendor/codemirror-yaml.min.*). js-yaml lint on every change for parse errors. For .zddc cascade files, an additional schema-aware lint pass flags unknown keys, bad enum values, and wrong types. - Per-row drag-drop upload using webkitGetAsEntry (folder uploads work recursively). Per-row drop indicator; doc-level overlay still fires for blank-space drops at drop_target scopes. - New folder / New markdown file context-menu items (server mode). Rename + Delete with native confirm() dialog. File-API helpers removeNode / renameNode use the existing PUT/POST/DELETE endpoints. - Hover info card with the row's full metadata (ZDDC fields + filesystem info + path/URL). Interactive — mouse into it, drag-select text, Ctrl/Cmd-C or right-click → Copy. 200ms grace before dismiss. - Autofilter input at the top of the tree pane. Same grammar as archive's column filters (zddc.filter.parse / matches). Filters files; folders without matches collapse out. Non-matching folders force-open visually when descendants match, without mutating the user's actual expand state. - Two-line ZDDC label: title-first, tracking/rev/status as monospace meta below. Icon column anchors to the title line. Chevron is a Lucide outline `chevron-right` SVG, rotated 90° on `.expanded`. - File-type Lucide icon sprite (shared/icons.js — 16 outline glyphs, ~5 KB). PDF / Word / Spreadsheet / Slides / Image / Video / Audio / CAD / Web / Config / Code / Archive get distinct icons; folders tinted with --primary. - Header wraps gracefully at narrow viewports (shared/base.css flex-wrap + title min-width:0 ellipsis). Body becomes flex column in browse so a wrapping header doesn't break #appMain height. - Markdown editor opens in WYSIWYG mode by default. YAML front-matter + TOC sidebar reworked: flexbox layout (single visible resizer between FM and TOC), both bodies overflow:auto for X+Y scrollbars. - `?file=<path>` deep links open browse pre-positioned at a specific file. Multi-segment paths walk into subdirectories on the way. Auto-flips Show hidden when a segment is dot/underscore-prefixed. - Refresh + show-hidden toggle preserve expansion / selection / preview pinning. Path-keyed snapshot survives a re-fetched listing. - "Add Local Directory" → "Use Local Directory" across the four tools that have it (browse, archive, classifier, +transmittal comment). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
258 lines
9.7 KiB
JavaScript
258 lines
9.7 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 (kept local so this module is self-contained) ──
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/&/g, '&').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);
|
|
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');
|
|
|
|
// 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 '<span class="tree-hovercard__key">' + escapeHtml(key) + '</span>'
|
|
+ '<span class="tree-hovercard__val'
|
|
+ (mono ? ' tree-hovercard__val--mono' : '')
|
|
+ '">' + escapeHtml(val) + '</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');
|
|
}
|
|
|
|
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 };
|
|
})();
|