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>
381 lines
14 KiB
JavaScript
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 };
|
|
})();
|