Add Tree Selector
This commit is contained in:
@@ -70,6 +70,23 @@
|
|||||||
codeblock: function () { wrap('```\n', '\n```', 'code'); },
|
codeblock: function () { wrap('```\n', '\n```', 'code'); },
|
||||||
quote: function () { linePrefix('> '); },
|
quote: function () { linePrefix('> '); },
|
||||||
link: function () { wrap('[', '](url)', 'link text'); },
|
link: function () { wrap('[', '](url)', 'link text'); },
|
||||||
|
wikilink: function () {
|
||||||
|
var sel = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd);
|
||||||
|
openTreePicker({
|
||||||
|
title: 'Insert link',
|
||||||
|
mode: 'any',
|
||||||
|
initialPath: '/',
|
||||||
|
confirmLabel: 'INSERT',
|
||||||
|
onSelect: function (path, kind) {
|
||||||
|
if (kind === 'folder') {
|
||||||
|
insertAtCursor(sel ? '[[' + path + '|' + sel + ']]' : '[[' + path + ']]');
|
||||||
|
} else {
|
||||||
|
var name = path.split('/').pop();
|
||||||
|
insertAtCursor('[' + (sel || name) + '](' + path + ')');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
ul: function () { linePrefix('- '); },
|
ul: function () { linePrefix('- '); },
|
||||||
ol: function () { linePrefix('1. '); },
|
ol: function () { linePrefix('1. '); },
|
||||||
hr: function () { wrap('\n\n---\n\n', '', ''); },
|
hr: function () { wrap('\n\n---\n\n', '', ''); },
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
backdrop.addEventListener('mousedown', function (e) {
|
backdrop.addEventListener('mousedown', function (e) {
|
||||||
if (e.target === backdrop) close();
|
if (e.target === backdrop) close();
|
||||||
});
|
});
|
||||||
|
wireDrag(header);
|
||||||
cancelBtn.addEventListener('click', close);
|
cancelBtn.addEventListener('click', close);
|
||||||
confirmBtn.addEventListener('click', function () {
|
confirmBtn.addEventListener('click', function () {
|
||||||
if (confirmBtn.disabled) return;
|
if (confirmBtn.disabled) return;
|
||||||
@@ -52,6 +53,53 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wireDrag makes the modal draggable by `handle`. Dragging switches the
|
||||||
|
// modal to fixed positioning so flexbox alignment doesn't fight us.
|
||||||
|
function wireDrag(handle) {
|
||||||
|
var dragging = false;
|
||||||
|
var startX, startY, originX, originY;
|
||||||
|
|
||||||
|
function onDown(e) {
|
||||||
|
if (e.button !== undefined && e.button !== 0) return;
|
||||||
|
var pt = e.touches ? e.touches[0] : e;
|
||||||
|
var rect = modal.getBoundingClientRect();
|
||||||
|
modal.classList.add('is-dragged');
|
||||||
|
modal.style.position = 'fixed';
|
||||||
|
modal.style.left = rect.left + 'px';
|
||||||
|
modal.style.top = rect.top + 'px';
|
||||||
|
dragging = true;
|
||||||
|
startX = pt.clientX;
|
||||||
|
startY = pt.clientY;
|
||||||
|
originX = rect.left;
|
||||||
|
originY = rect.top;
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMove(e) {
|
||||||
|
if (!dragging) return;
|
||||||
|
var pt = e.touches ? e.touches[0] : e;
|
||||||
|
var dx = pt.clientX - startX;
|
||||||
|
var dy = pt.clientY - startY;
|
||||||
|
var w = modal.offsetWidth;
|
||||||
|
var h = modal.offsetHeight;
|
||||||
|
var maxX = window.innerWidth - w;
|
||||||
|
var maxY = window.innerHeight - h;
|
||||||
|
var x = Math.max(0, Math.min(maxX, originX + dx));
|
||||||
|
var y = Math.max(0, Math.min(maxY, originY + dy));
|
||||||
|
modal.style.left = x + 'px';
|
||||||
|
modal.style.top = y + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUp() { dragging = false; }
|
||||||
|
|
||||||
|
handle.addEventListener('mousedown', onDown);
|
||||||
|
handle.addEventListener('touchstart', onDown, { passive: false });
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('touchmove', onMove, { passive: false });
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
document.addEventListener('touchend', onUp);
|
||||||
|
}
|
||||||
|
|
||||||
function open(opts) {
|
function open(opts) {
|
||||||
if (backdrop && backdrop.parentNode) close();
|
if (backdrop && backdrop.parentNode) close();
|
||||||
if (!backdrop) build();
|
if (!backdrop) build();
|
||||||
@@ -59,6 +107,11 @@
|
|||||||
currentOpts = opts;
|
currentOpts = opts;
|
||||||
prevFocus = document.activeElement;
|
prevFocus = document.activeElement;
|
||||||
|
|
||||||
|
modal.classList.remove('is-dragged');
|
||||||
|
modal.style.position = '';
|
||||||
|
modal.style.left = '';
|
||||||
|
modal.style.top = '';
|
||||||
|
|
||||||
titleEl.textContent = opts.title || '';
|
titleEl.textContent = opts.title || '';
|
||||||
|
|
||||||
bodyEl.textContent = '';
|
bodyEl.textContent = '';
|
||||||
|
|||||||
+49
-22
@@ -1,45 +1,72 @@
|
|||||||
function newPage() {
|
function encodePickedPath(p) {
|
||||||
|
if (p === '/' || p === '') return '/';
|
||||||
|
return '/' + p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function promptPageName(title, initial, confirmLabel, onName) {
|
||||||
var input = document.createElement('input');
|
var input = document.createElement('input');
|
||||||
input.type = 'text';
|
input.type = 'text';
|
||||||
input.className = 'modal-input';
|
input.className = 'modal-input';
|
||||||
input.placeholder = 'New page name';
|
input.placeholder = 'Page name';
|
||||||
|
if (initial) input.value = initial;
|
||||||
openModal({
|
openModal({
|
||||||
title: 'New page',
|
title: title,
|
||||||
body: input,
|
body: input,
|
||||||
confirm: {
|
confirm: {
|
||||||
label: 'CREATE',
|
label: confirmLabel,
|
||||||
onConfirm: function () {
|
onConfirm: function () {
|
||||||
var name = input.value.trim();
|
var name = input.value.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
window.location.href = window.location.pathname +
|
onName(name);
|
||||||
encodeURIComponent(name) + '/?edit';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function movePage() {
|
function newPage() {
|
||||||
var input = document.createElement('input');
|
var current = decodeURIComponent(window.location.pathname).replace(/\/+$/, '') || '/';
|
||||||
input.type = 'text';
|
openTreePicker({
|
||||||
input.className = 'modal-input';
|
title: 'New page — where?',
|
||||||
input.value = decodeURIComponent(window.location.pathname);
|
mode: 'folder',
|
||||||
|
initialPath: current,
|
||||||
|
preselect: current,
|
||||||
|
hideFiles: true,
|
||||||
|
confirmLabel: 'NEXT',
|
||||||
|
onSelect: function (parentPath) {
|
||||||
|
promptPageName('New page — name?', '', 'CREATE', function (name) {
|
||||||
|
var base = parentPath === '/' ? '/' : encodePickedPath(parentPath) + '/';
|
||||||
|
window.location.href = base + encodeURIComponent(name) + '/?edit';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
openModal({
|
function movePage() {
|
||||||
title: 'Move page',
|
var current = decodeURIComponent(window.location.pathname).replace(/\/+$/, '');
|
||||||
body: input,
|
if (!current) return;
|
||||||
confirm: {
|
var segs = current.split('/').filter(Boolean);
|
||||||
label: 'MOVE',
|
var currentName = segs[segs.length - 1] || '';
|
||||||
onConfirm: function () {
|
var parent = '/' + segs.slice(0, -1).join('/');
|
||||||
var target = input.value.trim();
|
if (parent === '/') parent = '/';
|
||||||
if (!target || !target.startsWith('/')) return;
|
|
||||||
|
openTreePicker({
|
||||||
|
title: 'Move — new parent?',
|
||||||
|
mode: 'folder',
|
||||||
|
initialPath: parent,
|
||||||
|
preselect: parent,
|
||||||
|
hideFiles: true,
|
||||||
|
confirmLabel: 'NEXT',
|
||||||
|
allowRoot: false,
|
||||||
|
onSelect: function (newParent) {
|
||||||
|
promptPageName('Move — new name?', currentName, 'MOVE', function (name) {
|
||||||
|
var dest = (newParent === '/' ? '' : newParent) + '/' + name;
|
||||||
var form = document.createElement('form');
|
var form = document.createElement('form');
|
||||||
form.method = 'POST';
|
form.method = 'POST';
|
||||||
form.action = window.location.pathname + '?move=' +
|
form.action = window.location.pathname + '?move=' +
|
||||||
encodeURIComponent(target);
|
encodeURIComponent(dest);
|
||||||
document.body.appendChild(form);
|
document.body.appendChild(form);
|
||||||
form.submit();
|
form.submit();
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<link rel="stylesheet" href="/_/style.css" />
|
<link rel="stylesheet" href="/_/style.css" />
|
||||||
<script src="/_/modal.js"></script>
|
<script src="/_/modal.js"></script>
|
||||||
<script src="/_/global-shortcuts.js"></script>
|
<script src="/_/global-shortcuts.js"></script>
|
||||||
|
<script src="/_/tree-picker.js"></script>
|
||||||
<script src="/_/page-actions.js"></script>
|
<script src="/_/page-actions.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -53,6 +54,7 @@
|
|||||||
<button type="button" class="btn btn-tool" data-action="codeblock" data-key="K" title="Code block (K)">```</button>
|
<button type="button" class="btn btn-tool" data-action="codeblock" data-key="K" title="Code block (K)">```</button>
|
||||||
<span class="toolbar-sep"></span>
|
<span class="toolbar-sep"></span>
|
||||||
<button type="button" class="btn btn-tool" data-action="link" data-key="L" title="Link (L)">[]</button>
|
<button type="button" class="btn btn-tool" data-action="link" data-key="L" title="Link (L)">[]</button>
|
||||||
|
<button type="button" class="btn btn-tool" data-action="wikilink" data-key="P" title="Insert wiki link (P)">[[]]</button>
|
||||||
<button type="button" class="btn btn-tool" data-action="quote" data-key="Q" title="Blockquote (Q)">></button>
|
<button type="button" class="btn btn-tool" data-action="quote" data-key="Q" title="Blockquote (Q)">></button>
|
||||||
<button type="button" class="btn btn-tool" data-action="ul" data-key="U" title="Unordered list (U)">-</button>
|
<button type="button" class="btn btn-tool" data-action="ul" data-key="U" title="Unordered list (U)">-</button>
|
||||||
<button type="button" class="btn btn-tool" data-action="ol" data-key="O" title="Ordered list (O)">1.</button>
|
<button type="button" class="btn btn-tool" data-action="ol" data-key="O" title="Ordered list (O)">1.</button>
|
||||||
|
|||||||
+72
-1
@@ -465,7 +465,7 @@ hr {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.6);
|
background: rgba(0, 0, 0, 0.6);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
@@ -478,6 +478,12 @@ hr {
|
|||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
margin-top: 6rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.is-dragged {
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
@@ -487,6 +493,8 @@ hr {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body {
|
.modal-body {
|
||||||
@@ -516,6 +524,66 @@ hr {
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Tree picker === */
|
||||||
|
.tree-picker {
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--secondary);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.tree-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 2rem;
|
||||||
|
}
|
||||||
|
.tree-row:hover {
|
||||||
|
background: var(--bg-panel-hover);
|
||||||
|
}
|
||||||
|
.tree-row.is-selected {
|
||||||
|
background: var(--bg-panel-hover);
|
||||||
|
border-left: 3px solid var(--primary);
|
||||||
|
padding-left: calc(0.5rem - 3px);
|
||||||
|
}
|
||||||
|
.tree-row.is-disabled {
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.tree-row.is-disabled:hover {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
.tree-chevron {
|
||||||
|
width: 1.25rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.tree-chevron.is-leaf {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
.tree-marker {
|
||||||
|
width: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.tree-name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tree-children {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
.tree-selected-path {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
/* === Diary Calendar === */
|
/* === Diary Calendar === */
|
||||||
.diary-cal {
|
.diary-cal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -661,4 +729,7 @@ hr {
|
|||||||
max-width: none;
|
max-width: none;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
.modal-header {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
})();
|
||||||
@@ -96,6 +96,11 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodGet && r.URL.Query().Has("tree") {
|
||||||
|
h.handleTree(w, r, urlPath, fsPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
info, err := os.Stat(fsPath)
|
info, err := os.Stat(fsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type treeEntry struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type treeResponse struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Entries []treeEntry `json:"entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTree responds with a JSON listing of the immediate children of the
|
||||||
|
// folder at fsPath. Hidden entries and `index.md` are filtered. Files are not
|
||||||
|
// descended — the client lazy-loads children on expand.
|
||||||
|
func (h *handler) handleTree(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
|
||||||
|
info, err := os.Stat(fsPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "stat failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
http.Error(w, "not a folder", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := listTreeEntries(fsPath)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "read failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := treeResponse{Path: canonicalTreePath(urlPath), Entries: entries}
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// canonicalTreePath returns the URL path in the form used by the picker:
|
||||||
|
// "/" for root, otherwise stripped of any trailing slash.
|
||||||
|
func canonicalTreePath(urlPath string) string {
|
||||||
|
if urlPath == "" || urlPath == "/" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
return "/" + strings.Trim(urlPath, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// listTreeEntries returns the immediate children of fsPath, filtering hidden
|
||||||
|
// entries and index.md. Folders are listed before files; both groups are
|
||||||
|
// sorted alphabetically.
|
||||||
|
func listTreeEntries(fsPath string) ([]treeEntry, error) {
|
||||||
|
raw, err := os.ReadDir(fsPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var folders, files []treeEntry
|
||||||
|
for _, e := range raw {
|
||||||
|
name := e.Name()
|
||||||
|
if strings.HasPrefix(name, ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if e.IsDir() {
|
||||||
|
folders = append(folders, treeEntry{Name: name, Kind: "folder"})
|
||||||
|
} else {
|
||||||
|
if name == "index.md" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
files = append(files, treeEntry{Name: name, Kind: "file"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(folders, func(i, j int) bool { return folders[i].Name < folders[j].Name })
|
||||||
|
sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name })
|
||||||
|
return append(folders, files...), nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user