ZDDC/shared/context-menu.js
ZDDC 94b2e29448 feat(browse): SPA overhaul — context menu, YAML editor, icons, hovercard, deep links, autofilter
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>
2026-05-14 12:12:42 -05:00

381 lines
14 KiB
JavaScript

// shared/context-menu.js — generic context-menu framework exposed on
// window.zddc.menu. Built so every ZDDC tool can drop a right-click
// menu (or any programmatically-opened menu) onto its UI without
// shipping its own implementation.
//
// API:
// window.zddc.menu.open({ x, y, items, context })
// window.zddc.menu.close()
//
// `items` is an array (or a function returning an array, evaluated
// against `context` at open-time). Each entry is one of:
// { label, action, icon?, accel?, disabled?, visible?, danger? }
// — a normal menu item; `action(ctx)` fires on click/Enter.
// { label, checked, action, ... }
// — toggle item; `checked` may be a bool or a fn(ctx). Renders
// a ✓ in the gutter when truthy.
// { label, items, ... }
// — submenu; `items` may itself be an array or fn(ctx).
// { separator: true }
// — horizontal divider. Leading/trailing/duplicate separators
// are collapsed automatically so callers can build items
// conditionally without managing dividers.
//
// Any of `label`, `checked`, `visible`, `disabled`, and `items` may
// be a function — each is invoked with the context object so callers
// can render fully context-aware menus from a single declarative
// config.
//
// Keyboard: ArrowUp/Down move within a menu, ArrowRight opens a
// submenu, ArrowLeft / Escape backs up one level (or closes if
// already at the root), Enter / Space activates. Click-outside,
// window blur, scroll, and resize all dismiss.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.menu) return;
var SUBMENU_HOVER_MS = 180;
// Open menu stack — index 0 is the root, deeper entries are
// nested submenus. Each frame: { el, depth, parentRow? }.
var stack = [];
var rootContext = null;
var submenuTimer = null;
function resolve(val, ctx) {
return typeof val === 'function' ? val(ctx) : val;
}
function close() {
if (submenuTimer) { clearTimeout(submenuTimer); submenuTimer = null; }
for (var i = 0; i < stack.length; i++) {
var fr = stack[i];
if (fr.el && fr.el.parentNode) fr.el.parentNode.removeChild(fr.el);
}
stack = [];
rootContext = null;
document.removeEventListener('mousedown', onDocMouseDown, true);
document.removeEventListener('keydown', onDocKeyDown, true);
// blur is bound WITHOUT capture so we only react to the window
// itself losing focus — capturing would also fire when any
// inner element blurs (which happens every time the user moves
// the mouse between menu rows, since hover focuses the row).
window.removeEventListener('blur', close);
window.removeEventListener('resize', close, true);
window.removeEventListener('scroll', onDocScroll, true);
}
function open(opts) {
opts = opts || {};
close();
rootContext = opts.context || {};
var items = resolve(opts.items, rootContext) || [];
var el = buildMenu(items, rootContext, 0);
document.body.appendChild(el);
position(el, opts.x || 0, opts.y || 0, null);
stack.push({ el: el, depth: 0 });
document.addEventListener('mousedown', onDocMouseDown, true);
document.addEventListener('keydown', onDocKeyDown, true);
window.addEventListener('blur', close);
window.addEventListener('resize', close, true);
window.addEventListener('scroll', onDocScroll, true);
focusFirst(el);
}
// ── Building ─────────────────────────────────────────────────────────
function collapseSeparators(items) {
var out = [];
for (var i = 0; i < items.length; i++) {
var it = items[i];
if (it && it.separator) {
if (out.length === 0) continue;
if (out[out.length - 1].separator) continue;
out.push(it);
} else if (it) {
out.push(it);
}
}
while (out.length && out[out.length - 1].separator) out.pop();
return out;
}
function buildMenu(items, ctx, depth) {
var menu = document.createElement('div');
menu.className = 'zddc-menu';
menu.setAttribute('role', 'menu');
menu.dataset.depth = String(depth);
// Suppress the native context menu over our own menu.
menu.addEventListener('contextmenu', function (e) { e.preventDefault(); });
var filtered = items.filter(function (it) {
if (!it) return false;
if (it.separator) return true;
if ('visible' in it && !resolve(it.visible, ctx)) return false;
return true;
});
var pruned = collapseSeparators(filtered);
for (var i = 0; i < pruned.length; i++) {
menu.appendChild(buildRow(pruned[i], ctx, depth));
}
return menu;
}
function buildRow(item, ctx, depth) {
if (item.separator) {
var sep = document.createElement('div');
sep.className = 'zddc-menu__sep';
sep.setAttribute('role', 'separator');
return sep;
}
var hasSub = !!item.items;
var isToggle = ('checked' in item);
var disabled = 'disabled' in item ? !!resolve(item.disabled, ctx) : false;
var row = document.createElement('div');
row.className = 'zddc-menu__item';
if (item.danger) row.classList.add('zddc-menu__item--danger');
if (hasSub) row.classList.add('zddc-menu__item--has-sub');
if (disabled) {
row.classList.add('is-disabled');
row.setAttribute('aria-disabled', 'true');
}
row.setAttribute('role',
hasSub ? 'menuitem'
: (isToggle ? 'menuitemcheckbox' : 'menuitem'));
row.tabIndex = -1;
// Check gutter — present on every row so columns align.
var check = document.createElement('span');
check.className = 'zddc-menu__check';
if (isToggle) {
var on = !!resolve(item.checked, ctx);
if (on) {
check.textContent = '✓';
row.classList.add('is-checked');
row.setAttribute('aria-checked', 'true');
} else {
row.setAttribute('aria-checked', 'false');
}
}
row.appendChild(check);
// Icon column.
var icon = document.createElement('span');
icon.className = 'zddc-menu__icon';
if (item.icon) icon.textContent = item.icon;
row.appendChild(icon);
// Label.
var label = document.createElement('span');
label.className = 'zddc-menu__label';
label.textContent = String(resolve(item.label, ctx) || '');
row.appendChild(label);
// Accelerator hint (visual only; no binding).
var accel = document.createElement('span');
accel.className = 'zddc-menu__accel';
if (item.accel) accel.textContent = item.accel;
row.appendChild(accel);
// Submenu arrow.
var arrow = document.createElement('span');
arrow.className = 'zddc-menu__arrow';
if (hasSub) arrow.textContent = '▸';
row.appendChild(arrow);
if (!disabled) {
row.addEventListener('mouseenter', function () {
// Hovering any row in a menu collapses deeper menus
// (so traversing siblings closes a previously-opened
// submenu) and re-focuses this row for keyboard nav.
closeBelow(depth);
if (submenuTimer) { clearTimeout(submenuTimer); submenuTimer = null; }
if (hasSub) {
submenuTimer = setTimeout(function () {
openSubmenu(row, item, ctx, depth + 1, false);
}, SUBMENU_HOVER_MS);
}
try { row.focus({ preventScroll: true }); } catch (_e) { row.focus(); }
});
row.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
if (submenuTimer) { clearTimeout(submenuTimer); submenuTimer = null; }
if (hasSub) {
openSubmenu(row, item, ctx, depth + 1, true);
return;
}
activate(item, ctx);
});
}
return row;
}
function activate(item, ctx) {
try {
if (typeof item.action === 'function') item.action(ctx);
} finally {
close();
}
}
function openSubmenu(parentRow, parentItem, ctx, depth, takeFocus) {
closeBelow(depth - 1);
var items = resolve(parentItem.items, ctx) || [];
var el = buildMenu(items, ctx, depth);
document.body.appendChild(el);
var rect = parentRow.getBoundingClientRect();
// Slight overlap so pointer-cross feels continuous.
position(el, rect.right - 2, rect.top - 4, parentRow);
stack.push({ el: el, depth: depth, parentRow: parentRow });
if (takeFocus) focusFirst(el);
}
function closeBelow(depth) {
while (stack.length && stack[stack.length - 1].depth > depth) {
var fr = stack.pop();
if (fr.el && fr.el.parentNode) fr.el.parentNode.removeChild(fr.el);
}
}
// ── Positioning ──────────────────────────────────────────────────────
function position(el, x, y, parentRow) {
// Fixed so we ignore document scroll; measure after layout.
el.style.position = 'fixed';
el.style.left = '0px';
el.style.top = '0px';
el.style.visibility = 'hidden';
var rect = el.getBoundingClientRect();
var w = rect.width;
var h = rect.height;
var vw = window.innerWidth;
var vh = window.innerHeight;
var leftX = x;
if (leftX + w > vw - 4) {
if (parentRow) {
var pr = parentRow.getBoundingClientRect();
leftX = pr.left - w + 2; // flip submenu to the left
} else {
leftX = Math.max(4, x - w); // flip root menu left of cursor
}
}
if (leftX < 4) leftX = 4;
var topY = y;
if (topY + h > vh - 4) topY = Math.max(4, vh - h - 4);
if (topY < 4) topY = 4;
el.style.left = leftX + 'px';
el.style.top = topY + 'px';
el.style.visibility = '';
}
// ── Focus + keyboard ─────────────────────────────────────────────────
function focusable(menuEl) {
return Array.prototype.slice.call(
menuEl.querySelectorAll('.zddc-menu__item:not(.is-disabled)'));
}
function focusFirst(menuEl) {
var items = focusable(menuEl);
if (items.length) {
try { items[0].focus({ preventScroll: true }); }
catch (_e) { items[0].focus(); }
}
}
function onDocMouseDown(e) {
for (var i = 0; i < stack.length; i++) {
if (stack[i].el.contains(e.target)) return;
}
close();
}
// Scroll listener uses capture so scrolls inside any element (the
// tree pane, the document, etc.) dismiss the menu — its position
// is fixed and would otherwise hang over stale content. Scrolls
// that originate inside the menu itself (a future tall submenu)
// are ignored.
function onDocScroll(e) {
var t = e.target;
for (var i = 0; i < stack.length; i++) {
if (stack[i].el === t || (t && t.nodeType === 1 && stack[i].el.contains(t))) {
return;
}
}
close();
}
function onDocKeyDown(e) {
if (!stack.length) return;
var top = stack[stack.length - 1];
var items = focusable(top.el);
var active = document.activeElement;
var idx = items.indexOf(active);
switch (e.key) {
case 'Escape':
e.preventDefault();
if (stack.length > 1) {
var fr = stack.pop();
if (fr.el.parentNode) fr.el.parentNode.removeChild(fr.el);
if (fr.parentRow) fr.parentRow.focus();
} else {
close();
}
return;
case 'ArrowDown':
e.preventDefault();
if (!items.length) return;
items[idx < 0 ? 0 : (idx + 1) % items.length].focus();
return;
case 'ArrowUp':
e.preventDefault();
if (!items.length) return;
items[idx < 0 ? items.length - 1
: (idx - 1 + items.length) % items.length].focus();
return;
case 'Home':
e.preventDefault();
if (items.length) items[0].focus();
return;
case 'End':
e.preventDefault();
if (items.length) items[items.length - 1].focus();
return;
case 'ArrowRight':
if (active && active.classList.contains('zddc-menu__item--has-sub')) {
e.preventDefault();
active.click();
}
return;
case 'ArrowLeft':
if (stack.length > 1) {
e.preventDefault();
var fr2 = stack.pop();
if (fr2.el.parentNode) fr2.el.parentNode.removeChild(fr2.el);
if (fr2.parentRow) fr2.parentRow.focus();
}
return;
case 'Enter':
case ' ':
if (active) {
e.preventDefault();
active.click();
}
return;
}
}
window.zddc.menu = { open: open, close: close };
})();