Add Tree Selector

This commit is contained in:
2026-04-23 14:02:41 +02:00
parent c6a292754f
commit 8b13938290
8 changed files with 567 additions and 23 deletions
+17
View File
@@ -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', '', ''); },
+53
View File
@@ -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
View File
@@ -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();
} });
} }
}); });
} }
+2
View File
@@ -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)">&gt;</button> <button type="button" class="btn btn-tool" data-action="quote" data-key="Q" title="Blockquote (Q)">&gt;</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
View File
@@ -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;
}
} }
+284
View File
@@ -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;
})();
+5
View File
@@ -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) {
+85
View File
@@ -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
}