116 lines
5.3 KiB
JavaScript
116 lines
5.3 KiB
JavaScript
// wikicomplete.js — the `[[` wikilink 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).
|
|
//
|
|
// 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().
|
|
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';
|
|
}
|
|
|
|
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();
|
|
});
|
|
}
|
|
|
|
// 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 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) {
|
|
var ch = context.state.sliceDoc(to, to + 1);
|
|
if (ch === '/' || ch === ']') break;
|
|
to++;
|
|
}
|
|
|
|
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 });
|
|
}).catch(function () {
|
|
resolve(null);
|
|
});
|
|
});
|
|
}
|
|
|
|
return { source: source };
|
|
})();
|