Rework wikilinks feature
This commit is contained in:
@@ -1,110 +1,68 @@
|
||||
// wikicomplete.js — the `[[` wikilink autocomplete source for CodeMirror.
|
||||
// wikicomplete.js — the `[[` autocomplete source for CodeMirror.
|
||||
//
|
||||
// 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).
|
||||
// Wiki links are search queries, so the popup is the same typeahead the header
|
||||
// search box uses: the text typed after `[[` goes to `/_search` and each match
|
||||
// is offered by name. Picking one replaces the query segment with that page's
|
||||
// name — never a path — so what lands in the document is still a query. The
|
||||
// trailing `]]` and any `::display` tail are left untouched.
|
||||
//
|
||||
// Picking a folder inserts `<name>/` and re-opens the popup to drill one level
|
||||
// deeper; picking a file inserts `<name>` 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().
|
||||
// Exposes window.WikiComplete.source for main.js to register via CM's
|
||||
// autocompletion().
|
||||
window.WikiComplete = (function () {
|
||||
// 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';
|
||||
}
|
||||
// Matches search-suggest.js so both surfaces fire at the same point.
|
||||
var MIN_QUERY_LEN = 2;
|
||||
|
||||
function fetchTree(parent) {
|
||||
return fetch(treeURL(parent), {
|
||||
function fetchSuggestions(query) {
|
||||
return fetch('/_search?q=' + encodeURIComponent(query), {
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
// queryEnd returns where the current query segment ends: the first `]`, the
|
||||
// `::` that starts the display text, or end of line. Replacing only up to
|
||||
// there keeps a re-edit from eating an existing alias or the closing `]]`.
|
||||
function queryEnd(context, pos) {
|
||||
var line = context.state.doc.lineAt(pos);
|
||||
var to = pos;
|
||||
while (to < line.to) {
|
||||
var ch = context.state.sliceDoc(to, to + 1);
|
||||
if (ch === ']') break;
|
||||
if (ch === ':' && context.state.sliceDoc(to, to + 2) === '::') break;
|
||||
to++;
|
||||
}
|
||||
return to;
|
||||
}
|
||||
|
||||
// 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.
|
||||
// CM completion source. Activates on `[[` followed by at least
|
||||
// MIN_QUERY_LEN characters; a cursor past a `::` is in the display tail and
|
||||
// gets nothing.
|
||||
function source(context) {
|
||||
var match = context.matchBefore(/\[\[[^\]\n]*/);
|
||||
if (!match) return null;
|
||||
var content = context.state.sliceDoc(match.from + 2, context.pos);
|
||||
if (content[0] !== '/') return null;
|
||||
if (content.indexOf('::') !== -1) return null;
|
||||
var query = content.trim();
|
||||
if (query.length < MIN_QUERY_LEN) 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) {
|
||||
var ch = context.state.sliceDoc(to, to + 1);
|
||||
if (ch === '/' || ch === ']') break;
|
||||
to++;
|
||||
}
|
||||
var from = match.from + 2;
|
||||
var to = queryEnd(context, context.pos);
|
||||
|
||||
return new Promise(function (resolve) {
|
||||
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;
|
||||
}
|
||||
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 });
|
||||
fetchSuggestions(query).then(function (resp) {
|
||||
if (context.aborted) { resolve(null); return; }
|
||||
var options = (resp.results || []).map(function (r) {
|
||||
return { label: r.name, detail: '/' + r.path, apply: r.name };
|
||||
});
|
||||
// filter:false — the server already ranked these fuzzily, and
|
||||
// CM's own prefix filter would drop the near-misses (typos,
|
||||
// token reorderings) that make the fuzzy search worth having.
|
||||
// No validFor either: every keystroke re-queries from scratch.
|
||||
resolve({ from: from, to: to, options: options, filter: false });
|
||||
}).catch(function () {
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user