From 8b139382900ef1717c9f811a07c523defda53776 Mon Sep 17 00:00:00 2001 From: luxick Date: Thu, 23 Apr 2026 14:02:41 +0200 Subject: [PATCH] Add Tree Selector --- assets/editor.js | 17 +++ assets/modal.js | 53 ++++++++ assets/page-actions.js | 71 +++++++---- assets/page.html | 2 + assets/style.css | 73 ++++++++++- assets/tree-picker.js | 284 +++++++++++++++++++++++++++++++++++++++++ main.go | 5 + tree.go | 85 ++++++++++++ 8 files changed, 567 insertions(+), 23 deletions(-) create mode 100644 assets/tree-picker.js create mode 100644 tree.go diff --git a/assets/editor.js b/assets/editor.js index 0e87f22..17de39c 100644 --- a/assets/editor.js +++ b/assets/editor.js @@ -70,6 +70,23 @@ codeblock: function () { wrap('```\n', '\n```', 'code'); }, quote: function () { linePrefix('> '); }, 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('- '); }, ol: function () { linePrefix('1. '); }, hr: function () { wrap('\n\n---\n\n', '', ''); }, diff --git a/assets/modal.js b/assets/modal.js index 22658c3..34beeb2 100644 --- a/assets/modal.js +++ b/assets/modal.js @@ -43,6 +43,7 @@ backdrop.addEventListener('mousedown', function (e) { if (e.target === backdrop) close(); }); + wireDrag(header); cancelBtn.addEventListener('click', close); confirmBtn.addEventListener('click', function () { 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) { if (backdrop && backdrop.parentNode) close(); if (!backdrop) build(); @@ -59,6 +107,11 @@ currentOpts = opts; prevFocus = document.activeElement; + modal.classList.remove('is-dragged'); + modal.style.position = ''; + modal.style.left = ''; + modal.style.top = ''; + titleEl.textContent = opts.title || ''; bodyEl.textContent = ''; diff --git a/assets/page-actions.js b/assets/page-actions.js index 47b987b..8de2034 100644 --- a/assets/page-actions.js +++ b/assets/page-actions.js @@ -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'); input.type = 'text'; input.className = 'modal-input'; - input.placeholder = 'New page name'; - + input.placeholder = 'Page name'; + if (initial) input.value = initial; openModal({ - title: 'New page', + title: title, body: input, confirm: { - label: 'CREATE', + label: confirmLabel, onConfirm: function () { var name = input.value.trim(); if (!name) return; - window.location.href = window.location.pathname + - encodeURIComponent(name) + '/?edit'; + onName(name); } } }); } -function movePage() { - var input = document.createElement('input'); - input.type = 'text'; - input.className = 'modal-input'; - input.value = decodeURIComponent(window.location.pathname); +function newPage() { + var current = decodeURIComponent(window.location.pathname).replace(/\/+$/, '') || '/'; + openTreePicker({ + title: 'New page — where?', + 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({ - title: 'Move page', - body: input, - confirm: { - label: 'MOVE', - onConfirm: function () { - var target = input.value.trim(); - if (!target || !target.startsWith('/')) return; +function movePage() { + var current = decodeURIComponent(window.location.pathname).replace(/\/+$/, ''); + if (!current) return; + var segs = current.split('/').filter(Boolean); + var currentName = segs[segs.length - 1] || ''; + var parent = '/' + segs.slice(0, -1).join('/'); + if (parent === '/') parent = '/'; + + 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'); form.method = 'POST'; form.action = window.location.pathname + '?move=' + - encodeURIComponent(target); + encodeURIComponent(dest); document.body.appendChild(form); form.submit(); - } + }); } }); } diff --git a/assets/page.html b/assets/page.html index 52835ad..67ecba8 100644 --- a/assets/page.html +++ b/assets/page.html @@ -10,6 +10,7 @@ + @@ -53,6 +54,7 @@ + diff --git a/assets/style.css b/assets/style.css index 8a89534..c03ea43 100644 --- a/assets/style.css +++ b/assets/style.css @@ -465,7 +465,7 @@ hr { inset: 0; background: rgba(0, 0, 0, 0.6); display: flex; - align-items: center; + align-items: flex-start; justify-content: center; z-index: 1000; padding: 1rem; @@ -478,6 +478,12 @@ hr { max-width: 500px; display: flex; flex-direction: column; + margin-top: 6rem; + position: relative; +} + +.modal.is-dragged { + margin: 0; } .modal-header { @@ -487,6 +493,8 @@ hr { text-transform: uppercase; letter-spacing: 0.05em; color: var(--text); + cursor: move; + user-select: none; } .modal-body { @@ -516,6 +524,66 @@ hr { 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-cal { position: fixed; @@ -661,4 +729,7 @@ hr { max-width: none; margin-top: 1rem; } + .modal-header { + cursor: default; + } } diff --git a/assets/tree-picker.js b/assets/tree-picker.js new file mode 100644 index 0000000..b63f66b --- /dev/null +++ b/assets/tree-picker.js @@ -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; +})(); diff --git a/main.go b/main.go index dcb5e83..c9beca1 100644 --- a/main.go +++ b/main.go @@ -96,6 +96,11 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + if r.Method == http.MethodGet && r.URL.Query().Has("tree") { + h.handleTree(w, r, urlPath, fsPath) + return + } + info, err := os.Stat(fsPath) if err != nil { if os.IsNotExist(err) { diff --git a/tree.go b/tree.go new file mode 100644 index 0000000..6142dcb --- /dev/null +++ b/tree.go @@ -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 +}