Add Tree Selector
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
(function () {
|
||||
function joinPath(parent, name) {
|
||||
if (parent === '/' || parent === '') return '/' + name;
|
||||
return parent.replace(/\/+$/, '') + '/' + name;
|
||||
}
|
||||
|
||||
function encodePath(p) {
|
||||
if (p === '/' || p === '') return '/';
|
||||
return '/' + p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/') + '/';
|
||||
}
|
||||
|
||||
function fetchFolder(path) {
|
||||
return fetch(encodePath(path) + '?tree=1', { credentials: 'same-origin' })
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
function openTreePicker(opts) {
|
||||
opts = opts || {};
|
||||
var mode = opts.mode || 'folder';
|
||||
var initialPath = opts.initialPath || '/';
|
||||
var allowRoot = opts.allowRoot !== false;
|
||||
var hideFiles = !!opts.hideFiles;
|
||||
var preselect = opts.preselect || null;
|
||||
|
||||
var container = document.createElement('div');
|
||||
|
||||
var treeEl = document.createElement('div');
|
||||
treeEl.className = 'tree-picker';
|
||||
|
||||
var selectedPathEl = document.createElement('div');
|
||||
selectedPathEl.className = 'tree-selected-path muted';
|
||||
selectedPathEl.textContent = '\u00a0';
|
||||
|
||||
container.appendChild(treeEl);
|
||||
container.appendChild(selectedPathEl);
|
||||
|
||||
var selected = null; // { path, kind, rowEl }
|
||||
|
||||
var handle = openModal({
|
||||
title: opts.title || 'Pick',
|
||||
body: container,
|
||||
confirm: {
|
||||
label: opts.confirmLabel || 'SELECT',
|
||||
initiallyDisabled: true,
|
||||
onConfirm: function () {
|
||||
if (!selected) return;
|
||||
handle.close();
|
||||
if (opts.onSelect) opts.onSelect(selected.path, selected.kind);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function setSelected(path, kind, rowEl) {
|
||||
if (selected && selected.rowEl) selected.rowEl.classList.remove('is-selected');
|
||||
selected = { path: path, kind: kind, rowEl: rowEl };
|
||||
rowEl.classList.add('is-selected');
|
||||
selectedPathEl.textContent = path;
|
||||
handle.setConfirmDisabled(false);
|
||||
}
|
||||
|
||||
function isSelectable(kind) {
|
||||
if (mode === 'any') return true;
|
||||
if (mode === 'folder') return kind === 'folder';
|
||||
if (mode === 'file') return kind === 'file';
|
||||
return false;
|
||||
}
|
||||
|
||||
// buildRow returns { rowEl, expand(): Promise }. `expand` is a no-op
|
||||
// for files and idempotent for folders (resolves with the already-
|
||||
// loaded children on repeat calls).
|
||||
function buildRow(parentPath, name, kind) {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'tree-row';
|
||||
if (!isSelectable(kind)) row.classList.add('is-disabled');
|
||||
|
||||
var chevron = document.createElement('span');
|
||||
chevron.className = 'tree-chevron';
|
||||
if (kind === 'folder') {
|
||||
chevron.textContent = '\u25b8'; // ▸
|
||||
} else {
|
||||
chevron.classList.add('is-leaf');
|
||||
}
|
||||
row.appendChild(chevron);
|
||||
|
||||
var marker = document.createElement('span');
|
||||
marker.className = 'tree-marker';
|
||||
marker.textContent = kind === 'folder' ? '' : '\u00b7';
|
||||
row.appendChild(marker);
|
||||
|
||||
var label = document.createElement('span');
|
||||
label.className = 'tree-name';
|
||||
label.textContent = name;
|
||||
row.appendChild(label);
|
||||
|
||||
var fullPath = joinPath(parentPath, name);
|
||||
var childrenEl = null;
|
||||
var loadPromise = null;
|
||||
var isOpen = false;
|
||||
|
||||
function expand() {
|
||||
if (kind !== 'folder') return Promise.resolve(null);
|
||||
if (isOpen) return loadPromise || Promise.resolve(childrenEl);
|
||||
if (!childrenEl) {
|
||||
childrenEl = document.createElement('div');
|
||||
childrenEl.className = 'tree-children';
|
||||
}
|
||||
row.parentNode.insertBefore(childrenEl, row.nextSibling);
|
||||
chevron.textContent = '\u25be'; // ▾
|
||||
isOpen = true;
|
||||
if (!loadPromise) {
|
||||
loadPromise = loadInto(fullPath, childrenEl);
|
||||
}
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
function collapse() {
|
||||
if (kind !== 'folder' || !isOpen) return;
|
||||
if (childrenEl && childrenEl.parentNode) {
|
||||
childrenEl.parentNode.removeChild(childrenEl);
|
||||
}
|
||||
chevron.textContent = '\u25b8';
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
chevron.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
if (isOpen) collapse(); else expand();
|
||||
});
|
||||
|
||||
row.addEventListener('click', function () {
|
||||
if (isSelectable(kind)) {
|
||||
setSelected(fullPath, kind, row);
|
||||
} else if (kind === 'folder') {
|
||||
if (isOpen) collapse(); else expand();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
rowEl: row,
|
||||
name: name,
|
||||
kind: kind,
|
||||
childrenEl: function () { return childrenEl; },
|
||||
expand: expand
|
||||
};
|
||||
}
|
||||
|
||||
// loadInto fetches folderPath and populates `target` with rows. Returns
|
||||
// an array of the row objects on success, or [] on failure.
|
||||
function loadInto(folderPath, target) {
|
||||
target.textContent = '';
|
||||
var loading = document.createElement('div');
|
||||
loading.className = 'tree-row is-disabled';
|
||||
loading.textContent = '\u2026';
|
||||
target.appendChild(loading);
|
||||
|
||||
return fetchFolder(folderPath).then(function (resp) {
|
||||
target.textContent = '';
|
||||
var rows = [];
|
||||
(resp.entries || []).forEach(function (e) {
|
||||
if (hideFiles && e.kind !== 'folder') return;
|
||||
var r = buildRow(resp.path, e.name, e.kind);
|
||||
target.appendChild(r.rowEl);
|
||||
rows.push(r);
|
||||
});
|
||||
if (rows.length === 0) {
|
||||
var empty = document.createElement('div');
|
||||
empty.className = 'tree-row is-disabled';
|
||||
empty.textContent = '(empty)';
|
||||
target.appendChild(empty);
|
||||
}
|
||||
return rows;
|
||||
}).catch(function () {
|
||||
target.textContent = '';
|
||||
var err = document.createElement('div');
|
||||
err.className = 'tree-row';
|
||||
err.textContent = '(failed — tap to retry)';
|
||||
err.addEventListener('click', function () {
|
||||
loadInto(folderPath, target);
|
||||
});
|
||||
target.appendChild(err);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
// Root selection row — visible when allowRoot and mode accepts folders.
|
||||
if (allowRoot && isSelectable('folder')) {
|
||||
var rootRow = document.createElement('div');
|
||||
rootRow.className = 'tree-row';
|
||||
var rootChev = document.createElement('span');
|
||||
rootChev.className = 'tree-chevron is-leaf';
|
||||
rootRow.appendChild(rootChev);
|
||||
var rootMarker = document.createElement('span');
|
||||
rootMarker.className = 'tree-marker';
|
||||
rootRow.appendChild(rootMarker);
|
||||
var rootLabel = document.createElement('span');
|
||||
rootLabel.className = 'tree-name';
|
||||
rootLabel.textContent = '/';
|
||||
rootRow.appendChild(rootLabel);
|
||||
rootRow.addEventListener('click', function () {
|
||||
setSelected('/', 'folder', rootRow);
|
||||
});
|
||||
treeEl.appendChild(rootRow);
|
||||
}
|
||||
|
||||
var rootChildren = document.createElement('div');
|
||||
treeEl.appendChild(rootChildren);
|
||||
|
||||
// Expand the ancestor chain of initialPath so the user lands in
|
||||
// context. For root, just load root children.
|
||||
var segs = (initialPath || '/').split('/').filter(Boolean);
|
||||
var preselectSegs = preselect
|
||||
? preselect.split('/').filter(Boolean)
|
||||
: null;
|
||||
|
||||
loadInto('/', rootChildren).then(function (rows) {
|
||||
return expandChain(rows, segs, '/');
|
||||
}).then(function () {
|
||||
if (preselectSegs === null) return;
|
||||
if (preselectSegs.length === 0) {
|
||||
// Preselect root itself (if allowed).
|
||||
if (allowRoot && isSelectable('folder')) {
|
||||
var root = treeEl.querySelector('.tree-row');
|
||||
if (root) setSelected('/', 'folder', root);
|
||||
}
|
||||
return;
|
||||
}
|
||||
selectByPath(preselectSegs);
|
||||
});
|
||||
|
||||
// expandChain walks `segments`, looking up each by name in the current
|
||||
// row list, expanding it, and recursing into its children.
|
||||
function expandChain(rows, segments, basePath) {
|
||||
if (segments.length === 0) return Promise.resolve();
|
||||
var seg = segments[0];
|
||||
var match = null;
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
if (rows[i].kind === 'folder' && rows[i].name === seg) {
|
||||
match = rows[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!match) return Promise.resolve();
|
||||
return match.expand().then(function (childRows) {
|
||||
return expandChain(childRows || [], segments.slice(1), joinPath(basePath, seg));
|
||||
});
|
||||
}
|
||||
|
||||
// selectByPath walks the visible tree rows to locate the row matching
|
||||
// `segments` and marks it selected. Assumes its ancestors are already
|
||||
// expanded (expandChain ran first).
|
||||
function selectByPath(segments) {
|
||||
var container = rootChildren;
|
||||
var path = '/';
|
||||
for (var i = 0; i < segments.length; i++) {
|
||||
var seg = segments[i];
|
||||
var kids = container.children;
|
||||
var found = null;
|
||||
for (var j = 0; j < kids.length; j++) {
|
||||
var row = kids[j];
|
||||
if (!row.classList || !row.classList.contains('tree-row')) continue;
|
||||
var nm = row.querySelector('.tree-name');
|
||||
if (nm && nm.textContent === seg) { found = row; break; }
|
||||
}
|
||||
if (!found) return;
|
||||
path = joinPath(path, seg);
|
||||
if (i === segments.length - 1) {
|
||||
if (isSelectable('folder')) setSelected(path, 'folder', found);
|
||||
try { found.scrollIntoView({ block: 'nearest' }); } catch (e) {}
|
||||
return;
|
||||
}
|
||||
var next = found.nextSibling;
|
||||
if (!next || !next.classList || !next.classList.contains('tree-children')) return;
|
||||
container = next;
|
||||
}
|
||||
}
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
window.openTreePicker = openTreePicker;
|
||||
})();
|
||||
Reference in New Issue
Block a user