ZDDC/classifier/js/dir-picker.js
ZDDC c718334d25 fix(browse,classifier): backdrop dismiss no longer fires on a drag out of an input
Selecting text in a dialog input by click-dragging and releasing the mouse
outside the dialog closed it: the browser fires a `click` whose target is the
backdrop (mousedown was inside, mouseup outside), and the dismiss handler keyed
solely on `e.target === backdrop`.

Guard every backdrop click-to-close with a mousedown flag — close only when the
press ALSO started on the backdrop (a genuine backdrop click), not a drag that
began inside the dialog. Applied to the browse New file/folder party picker (the
reported case) and the other browse create dialogs (create/accept-transmittal,
stage), plus the classifier dialogs that share the pattern (copy chooser,
dir-picker, and the paste/match modal — whose textarea is a prime drag target).
The conflict/history dialogs already used mousedown and were unaffected.

Build + browse/classify/classifier suites green (80).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:36:07 -05:00

137 lines
7.1 KiB
JavaScript

/**
* ZDDC Classifier — lazy, multi-select directory picker (modal).
*
* Given one or more root directory handles, render an expandable checkbox tree
* and let the user TICK the directories whose files they want. Ticking a
* directory includes its whole subtree; a descendant under a ticked ancestor
* shows as checked+disabled (covered). Confirm resolves with the TOPMOST ticked
* handles only (a ticked child under a ticked parent is dropped — the parent's
* recursive walk covers it). Cancel/Esc/backdrop → [].
*
* Handle-agnostic: a "handle" is anything exposing async `values()` (yielding
* child handles {name, kind}) and `getDirectoryHandle(name)` — satisfied by both
* zddc-source.js HttpDirectoryHandle and native FileSystemDirectoryHandle.
*
* window.app.modules.dirPicker.pick(roots) → Promise<handle[]>
* roots: [ { label, handle } ]
*/
(function () {
'use strict';
if (!window.app) window.app = {};
if (!window.app.modules) window.app.modules = {};
function elt(tag, cls, text) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
}
// Same skip set as the archive walk: dotfiles, system (_), and risk folders.
function hiddenName(nm) { return nm.charAt(0) === '.' || nm.charAt(0) === '_' || nm === 'rsk'; }
function ancestorChecked(node) {
for (var p = node.parent; p; p = p.parent) { if (p.checked) return true; }
return false;
}
// Topmost ticked handles: a node whose own `checked` is set and which has no
// checked ancestor. Pure over { checked, handle, children } — also the test seam.
function collect(nodes, underChecked, out) {
(nodes || []).forEach(function (n) {
if (n.checked && !underChecked) out.push(n.handle);
collect(n.children, underChecked || !!n.checked, out);
});
return out;
}
function pick(roots) {
return new Promise(function (resolve) {
var done = false, rootNodes = [];
function finish(v) { if (done) return; done = true; document.removeEventListener('keydown', onKey); back.remove(); resolve(v); }
function onKey(e) { if (e.key === 'Escape') finish([]); }
var back = elt('div', 'copy-choice__backdrop');
var box = elt('div', 'copy-choice copy-choice--wide');
var h = elt('h3', null, 'Choose directories to scan');
var p = elt('p', null, 'Tick the directories whose files you want in the catalog — subfolders are included. Expand with ▸.');
var treeWrap = elt('div', 'dir-picker__tree');
var btns = elt('div', 'copy-choice__btns');
var go = elt('button', 'btn btn-primary', 'Scan'); go.disabled = true;
go.addEventListener('click', function () { finish(collect(rootNodes, false, [])); });
var cancel = elt('button', 'btn btn-secondary', 'Cancel');
cancel.addEventListener('click', function () { finish([]); });
btns.appendChild(go); btns.appendChild(cancel);
box.appendChild(h); box.appendChild(p); box.appendChild(treeWrap); box.appendChild(btns);
back.appendChild(box);
var pressedBackdrop = false;
back.addEventListener('mousedown', function (e) { pressedBackdrop = (e.target === back); });
back.addEventListener('click', function (e) { if (e.target === back && pressedBackdrop) finish([]); });
document.addEventListener('keydown', onKey);
document.body.appendChild(back);
function refreshGo() { go.disabled = collect(rootNodes, false, []).length === 0; }
// Recompute the displayed checkbox state of a subtree: a node under a
// checked ancestor is forced checked + disabled (inherited coverage).
function recompute(node, inherited) {
node.checkbox.disabled = inherited;
node.checkbox.checked = inherited || node.checked;
var below = inherited || node.checked;
node.children.forEach(function (c) { recompute(c, below); });
}
function makeNode(handle, label, parent, container) {
var node = { handle: handle, name: label, parent: parent, checked: false, expanded: false, loaded: false, children: [], childrenWrap: null, checkbox: null };
var rowEl = elt('div', 'dir-picker__row');
var twisty = elt('span', 'dir-picker__twisty', '▸');
var cb = elt('input'); cb.type = 'checkbox';
var nameEl = elt('span', 'dir-picker__name', label);
twisty.addEventListener('click', function () { toggle(node, twisty); });
nameEl.addEventListener('click', function () { toggle(node, twisty); });
cb.addEventListener('change', function () {
if (cb.disabled) return;
node.checked = cb.checked;
recompute(node, ancestorChecked(node));
refreshGo();
});
rowEl.appendChild(twisty); rowEl.appendChild(cb); rowEl.appendChild(nameEl);
var kids = elt('div', 'dir-picker__children'); kids.hidden = true;
node.checkbox = cb; node.childrenWrap = kids;
container.appendChild(rowEl); container.appendChild(kids);
return node;
}
async function toggle(node, twisty) {
node.expanded = !node.expanded;
node.childrenWrap.hidden = !node.expanded;
twisty.textContent = node.expanded ? '▾' : '▸';
if (node.expanded && !node.loaded) {
node.loaded = true;
twisty.textContent = '…';
try {
for await (var e of node.handle.values()) {
if (e.kind !== 'directory') continue;
var nm = String(e.name).replace(/\/$/, '');
if (hiddenName(nm)) continue;
var childHandle = e.getDirectoryHandle ? e : await node.handle.getDirectoryHandle(nm);
var child = makeNode(childHandle, nm, node, node.childrenWrap);
node.children.push(child);
}
} catch (err) {
node.childrenWrap.appendChild(elt('div', 'dir-picker__err', 'Could not read — ' + (err.message || err)));
}
twisty.textContent = node.children.length ? (node.expanded ? '▾' : '▸') : '·';
// A freshly-loaded subtree inherits an already-checked ancestor.
recompute(node, ancestorChecked(node));
}
}
(roots || []).forEach(function (r) { rootNodes.push(makeNode(r.handle, r.label, null, treeWrap)); });
if (!rootNodes.length) finish([]);
});
}
window.app.modules.dirPicker = {
pick: pick,
_collect: function (nodes) { return collect(nodes, false, []); }, // test seam
};
})();