From 204e89dbce332e75c03ddf87ca0728c9ab3ae183 Mon Sep 17 00:00:00 2001 From: luxick Date: Tue, 9 Jun 2026 18:36:40 +0200 Subject: [PATCH] Improve wiki linking --- assets/editor/main.js | 75 +++++++++++++++-- assets/editor/wikicomplete.js | 154 +++++++++++++++++++--------------- wikilinks.go | 9 +- 3 files changed, 160 insertions(+), 78 deletions(-) diff --git a/assets/editor/main.js b/assets/editor/main.js index 6c97bb9..433893a 100644 --- a/assets/editor/main.js +++ b/assets/editor/main.js @@ -172,17 +172,76 @@ movie: function () { M.run(movieCtx); }, }; - // Wiki link button: drop an empty [[]] at the cursor and open the `[[` - // completion popup so the same autocomplete flow handles target selection. + // isValidWikiTarget mirrors the Go validator in wikilinks.go — absolute + // path, no empty/dot segments. Used to gate the modal's INSERT button. + function isValidWikiTarget(p) { + if (!p || p[0] !== '/') return false; + var trimmed = p.replace(/^\/+|\/+$/g, ''); + if (trimmed === '') return true; + var segs = trimmed.split('/'); + for (var i = 0; i < segs.length; i++) { + if (segs[i] === '' || segs[i] === '.' || segs[i] === '..') return false; + } + return true; + } + + // Wiki link button (ALT+SHIFT+P): open a modal with a target field backed by + // full /_search typeahead plus an optional display-text field, then insert + // [[target]] or [[target::display]] at the cursor. (Inline `[[` typing uses + // the folder-scoped completion in wikicomplete.js instead.) function insertWikilink() { var sel = view.state.selection.main; - view.dispatch({ - changes: { from: sel.from, to: sel.to, insert: '[[]]' }, - selection: { anchor: sel.from + 2 }, - scrollIntoView: true, + var selectedText = view.state.sliceDoc(sel.from, sel.to); + + var container = document.createElement('div'); + + var targetWrap = document.createElement('div'); + var targetInput = document.createElement('input'); + targetInput.type = 'text'; + targetInput.className = 'input'; + targetInput.placeholder = 'Page path or search…'; + targetWrap.appendChild(targetInput); + + var displayInput = document.createElement('input'); + displayInput.type = 'text'; + displayInput.className = 'input'; + displayInput.placeholder = 'Display text (optional)'; + if (selectedText) displayInput.value = selectedText; + + container.appendChild(targetWrap); + container.appendChild(displayInput); + + var handle = openModal({ + title: 'Insert link', + body: container, + confirm: { + label: 'INSERT', + initiallyDisabled: true, + onConfirm: function () { + var target = targetInput.value.trim(); + if (!isValidWikiTarget(target)) return; + var display = displayInput.value.trim(); + handle.close(); + insertAtCursor(display ? '[[' + target + '::' + display + ']]' : '[[' + target + ']]'); + } + } + }); + + function updateConfirm() { + handle.setConfirmDisabled(!isValidWikiTarget(targetInput.value.trim())); + } + targetInput.addEventListener('input', updateConfirm); + + window.attachSuggestions(targetInput, { + showFooter: false, + container: targetWrap, + onPick: function (r) { + targetInput.value = '/' + r.path; + updateConfirm(); + displayInput.focus(); + displayInput.select(); + } }); - view.focus(); - CM.startCompletion(view); } // --- Keyboard shortcut registration --- diff --git a/assets/editor/wikicomplete.js b/assets/editor/wikicomplete.js index 283586a..7398bbb 100644 --- a/assets/editor/wikicomplete.js +++ b/assets/editor/wikicomplete.js @@ -1,93 +1,115 @@ // wikicomplete.js — the `[[` wikilink autocomplete source for CodeMirror. // -// Triggers when the cursor sits in a `[[…` token (freshly typed or re-edited -// inside an existing `[[…]]`), queries the existing /_search JSON endpoint -// (debounced), and offers matching page paths. Selecting a result inserts the -// absolute path and auto-closes `]]`, leaving the cursor before the close so a -// `::display` alias can be typed (decision 7). Exposes window.WikiComplete.source +// A level-by-level folder/file browser scoped to the path typed so far. It +// fires only once the `[[` token's content begins with `/` (targets are +// absolute; free-text search lives in the toolbar modal instead). The content +// is split into a parent path (up to and including the last `/`) and a partial +// segment (the text after it); the parent's children are fetched from the +// existing `?tree=1` endpoint and filtered to names containing the partial +// (case-insensitive substring). +// +// Picking a folder inserts `/` and re-opens the popup to drill one level +// deeper; picking a file inserts `` and stops. Only the current segment +// is replaced, so the trailing `]]` stays put and the cursor parks before it, +// leaving room to type a `::display` alias. Exposes window.WikiComplete.source // for main.js to register via CM's autocompletion(). window.WikiComplete = (function () { - var DEBOUNCE_MS = 100; - var MIN_QUERY_LEN = 2; - - // Mirrors the Go validator (wikilinks.go isValidWikiTarget) and the old - // client check: absolute path, no empty/'.'/'..' segments. Server results - // are wiki paths so this is mostly a guard against odd index entries. - function isValidWikiTarget(p) { - if (!p || p[0] !== '/') return false; - var trimmed = p.replace(/^\/+|\/+$/g, ''); - if (trimmed === '') return true; - var segs = trimmed.split('/'); - for (var i = 0; i < segs.length; i++) { - if (segs[i] === '' || segs[i] === '.' || segs[i] === '..') return false; - } - return true; + // treeURL builds the `?tree=1` request URL for an absolute parent path, + // percent-encoding each segment. A leading/trailing slash is tolerated; + // root resolves to `/?tree=1`. + function treeURL(parent) { + var trimmed = parent.replace(/^\/+/, '').replace(/\/+$/, ''); + if (trimmed === '') return '/?tree=1'; + var enc = trimmed.split('/').map(encodeURIComponent).join('/'); + return '/' + enc + '/?tree=1'; } - // Build the apply() for a chosen result. Inserts the path over the typed - // query and ensures a single trailing `]]`, cursor parked before it. If the - // user (or closeBrackets) already produced `]]`, we don't double it up. - function makeApply(path) { - return function (view, completion, from, to) { - var closed = view.state.sliceDoc(to, to + 2) === ']]'; - var insert = closed ? path : path + ']]'; - view.dispatch({ - changes: { from: from, to: to, insert: insert }, - selection: { anchor: from + path.length }, - scrollIntoView: true, - }); - }; - } - - function fetchSuggest(query) { - return fetch('/_search?q=' + encodeURIComponent(query), { + function fetchTree(parent) { + return fetch(treeURL(parent), { credentials: 'same-origin', headers: { 'Accept': 'application/json' }, }).then(function (r) { + // A 404 means the parent folder doesn't exist (typo, or a path under + // a file) — treat it as "no completions", not an error. + if (r.status === 404) return null; if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }); } - // CM completion source. `[[` plus any run of non-`]`, non-newline chars - // before the cursor is the trigger; the query is the text after `[[`. The - // replaced range extends past the cursor to the end of the inner token (up - // to `]]`/EOL) so re-editing inside an existing `[[…]]` replaces the whole - // target instead of duplicating the trailing text. + // makeApply builds the apply() for a chosen entry. It replaces only the + // current segment ([from, to]); the trailing `]]` is untouched, so the + // cursor ends up parked before it. Folders append `/` and re-open the popup + // to drill into the next level; files terminate. + function makeApply(name, kind) { + return function (view, completion, from, to) { + var isFolder = kind === 'folder'; + var insert = isFolder ? name + '/' : name; + view.dispatch({ + changes: { from: from, to: to, insert: insert }, + selection: { anchor: from + insert.length }, + scrollIntoView: true, + }); + if (isFolder) { + // Re-open after the transaction so the completion plugin sees + // the updated document (the next level's parent path). + setTimeout(function () { CM.startCompletion(view); }, 0); + } + }; + } + + // CM completion source. Activates when `[[` is followed by content that + // begins with `/`. The content is split at its last `/` into a parent path + // and a partial segment; the parent's children are fetched, filtered to + // names containing the partial (case-insensitive substring), and offered + // with name-only labels. function source(context) { var match = context.matchBefore(/\[\[[^\]\n]*/); if (!match) return null; - var query = context.state.sliceDoc(match.from + 2, context.pos); - if (query.length < MIN_QUERY_LEN && !context.explicit) return null; + var content = context.state.sliceDoc(match.from + 2, context.pos); + if (content[0] !== '/') return null; + var lastSlash = content.lastIndexOf('/'); + var parent = content.slice(0, lastSlash + 1); + var partial = content.slice(lastSlash + 1); + + // Replace only the current segment: from the start of the partial up to + // the next `/` or `]` (or end of line). This narrows re-edits inside an + // existing `[[…]]` so drilling doesn't duplicate trailing text. + var from = match.from + 2 + lastSlash + 1; var line = context.state.doc.lineAt(context.pos); var to = context.pos; - while (to < line.to && context.state.sliceDoc(to, to + 1) !== ']') to++; + while (to < line.to) { + var ch = context.state.sliceDoc(to, to + 1); + if (ch === '/' || ch === ']') break; + to++; + } return new Promise(function (resolve) { - var timer = setTimeout(function () { - if (context.aborted) { resolve(null); return; } - fetchSuggest(query).then(function (resp) { - if (context.aborted) { resolve(null); return; } - var options = (resp.results || []).reduce(function (acc, r) { - var path = '/' + r.path; - if (isValidWikiTarget(path)) { - acc.push({ label: path, detail: r.name, apply: makeApply(path) }); - } + if (context.aborted) { resolve(null); return; } + fetchTree(parent).then(function (resp) { + if (context.aborted || !resp) { resolve(null); return; } + var needle = partial.toLowerCase(); + var options = (resp.entries || []).reduce(function (acc, e) { + if (needle && e.name.toLowerCase().indexOf(needle) === -1) { return acc; - }, []); - resolve({ from: match.from + 2, to: to, options: options }); - }).catch(function () { - resolve(null); - }); - }, DEBOUNCE_MS); - // CM doesn't cancel pending promises, but it sets context.aborted; - // clear the timer too if the doc moved on before it fired. - if (context.addEventListener) { - context.addEventListener('abort', function () { clearTimeout(timer); }); - } + } + acc.push({ + label: e.name, + type: e.kind === 'folder' ? 'folder' : 'file', + apply: makeApply(e.name, e.kind), + }); + return acc; + }, []); + // No validFor: the source re-runs on each keystroke, so every + // edit (more chars, backspace, or a `/` that drills into the + // next folder) re-fetches and re-filters from scratch. + resolve({ from: from, to: to, options: options }); + }).catch(function () { + resolve(null); + }); }); } - return { source: source, isValidWikiTarget: isValidWikiTarget }; + return { source: source }; })(); diff --git a/wikilinks.go b/wikilinks.go index a88b2c2..790d073 100644 --- a/wikilinks.go +++ b/wikilinks.go @@ -115,13 +115,14 @@ func wikiTargetHref(target string) string { return b.String() } -// wikiTargetExists reports whether the on-disk folder backing the target -// exists under root. +// wikiTargetExists reports whether the on-disk path backing the target exists +// under root. Any existing path — file or folder — counts as resolved; only a +// missing path is treated as broken. func wikiTargetExists(root, target string) bool { target = normalizeWikiTarget(target) fsPath := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(target, "/"))) - info, err := os.Stat(fsPath) - return err == nil && info.IsDir() + _, err := os.Stat(fsPath) + return err == nil } // wikiDefaultDisplay returns the last segment of a target, or "/" for the root.