Improve wiki linking
This commit is contained in:
+67
-8
@@ -172,17 +172,76 @@
|
|||||||
movie: function () { M.run(movieCtx); },
|
movie: function () { M.run(movieCtx); },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wiki link button: drop an empty [[]] at the cursor and open the `[[`
|
// isValidWikiTarget mirrors the Go validator in wikilinks.go — absolute
|
||||||
// completion popup so the same autocomplete flow handles target selection.
|
// 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() {
|
function insertWikilink() {
|
||||||
var sel = view.state.selection.main;
|
var sel = view.state.selection.main;
|
||||||
view.dispatch({
|
var selectedText = view.state.sliceDoc(sel.from, sel.to);
|
||||||
changes: { from: sel.from, to: sel.to, insert: '[[]]' },
|
|
||||||
selection: { anchor: sel.from + 2 },
|
var container = document.createElement('div');
|
||||||
scrollIntoView: true,
|
|
||||||
|
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 ---
|
// --- Keyboard shortcut registration ---
|
||||||
|
|||||||
@@ -1,93 +1,115 @@
|
|||||||
// wikicomplete.js — the `[[` wikilink autocomplete source for CodeMirror.
|
// wikicomplete.js — the `[[` wikilink autocomplete source for CodeMirror.
|
||||||
//
|
//
|
||||||
// Triggers when the cursor sits in a `[[…` token (freshly typed or re-edited
|
// A level-by-level folder/file browser scoped to the path typed so far. It
|
||||||
// inside an existing `[[…]]`), queries the existing /_search JSON endpoint
|
// fires only once the `[[` token's content begins with `/` (targets are
|
||||||
// (debounced), and offers matching page paths. Selecting a result inserts the
|
// absolute; free-text search lives in the toolbar modal instead). The content
|
||||||
// absolute path and auto-closes `]]`, leaving the cursor before the close so a
|
// is split into a parent path (up to and including the last `/`) and a partial
|
||||||
// `::display` alias can be typed (decision 7). Exposes window.WikiComplete.source
|
// 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().
|
// for main.js to register via CM's autocompletion().
|
||||||
window.WikiComplete = (function () {
|
window.WikiComplete = (function () {
|
||||||
var DEBOUNCE_MS = 100;
|
// treeURL builds the `?tree=1` request URL for an absolute parent path,
|
||||||
var MIN_QUERY_LEN = 2;
|
// percent-encoding each segment. A leading/trailing slash is tolerated;
|
||||||
|
// root resolves to `/?tree=1`.
|
||||||
// Mirrors the Go validator (wikilinks.go isValidWikiTarget) and the old
|
function treeURL(parent) {
|
||||||
// client check: absolute path, no empty/'.'/'..' segments. Server results
|
var trimmed = parent.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||||
// are wiki paths so this is mostly a guard against odd index entries.
|
if (trimmed === '') return '/?tree=1';
|
||||||
function isValidWikiTarget(p) {
|
var enc = trimmed.split('/').map(encodeURIComponent).join('/');
|
||||||
if (!p || p[0] !== '/') return false;
|
return '/' + enc + '/?tree=1';
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the apply() for a chosen result. Inserts the path over the typed
|
function fetchTree(parent) {
|
||||||
// query and ensures a single trailing `]]`, cursor parked before it. If the
|
return fetch(treeURL(parent), {
|
||||||
// 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), {
|
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: { 'Accept': 'application/json' },
|
headers: { 'Accept': 'application/json' },
|
||||||
}).then(function (r) {
|
}).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);
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
return r.json();
|
return r.json();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// CM completion source. `[[` plus any run of non-`]`, non-newline chars
|
// makeApply builds the apply() for a chosen entry. It replaces only the
|
||||||
// before the cursor is the trigger; the query is the text after `[[`. The
|
// current segment ([from, to]); the trailing `]]` is untouched, so the
|
||||||
// replaced range extends past the cursor to the end of the inner token (up
|
// cursor ends up parked before it. Folders append `/` and re-open the popup
|
||||||
// to `]]`/EOL) so re-editing inside an existing `[[…]]` replaces the whole
|
// to drill into the next level; files terminate.
|
||||||
// target instead of duplicating the trailing text.
|
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) {
|
function source(context) {
|
||||||
var match = context.matchBefore(/\[\[[^\]\n]*/);
|
var match = context.matchBefore(/\[\[[^\]\n]*/);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
var query = context.state.sliceDoc(match.from + 2, context.pos);
|
var content = context.state.sliceDoc(match.from + 2, context.pos);
|
||||||
if (query.length < MIN_QUERY_LEN && !context.explicit) return null;
|
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 line = context.state.doc.lineAt(context.pos);
|
||||||
var to = 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) {
|
return new Promise(function (resolve) {
|
||||||
var timer = setTimeout(function () {
|
|
||||||
if (context.aborted) { resolve(null); return; }
|
if (context.aborted) { resolve(null); return; }
|
||||||
fetchSuggest(query).then(function (resp) {
|
fetchTree(parent).then(function (resp) {
|
||||||
if (context.aborted) { resolve(null); return; }
|
if (context.aborted || !resp) { resolve(null); return; }
|
||||||
var options = (resp.results || []).reduce(function (acc, r) {
|
var needle = partial.toLowerCase();
|
||||||
var path = '/' + r.path;
|
var options = (resp.entries || []).reduce(function (acc, e) {
|
||||||
if (isValidWikiTarget(path)) {
|
if (needle && e.name.toLowerCase().indexOf(needle) === -1) {
|
||||||
acc.push({ label: path, detail: r.name, apply: makeApply(path) });
|
return acc;
|
||||||
}
|
}
|
||||||
|
acc.push({
|
||||||
|
label: e.name,
|
||||||
|
type: e.kind === 'folder' ? 'folder' : 'file',
|
||||||
|
apply: makeApply(e.name, e.kind),
|
||||||
|
});
|
||||||
return acc;
|
return acc;
|
||||||
}, []);
|
}, []);
|
||||||
resolve({ from: match.from + 2, to: to, options: options });
|
// 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 () {
|
}).catch(function () {
|
||||||
resolve(null);
|
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); });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { source: source, isValidWikiTarget: isValidWikiTarget };
|
return { source: source };
|
||||||
})();
|
})();
|
||||||
|
|||||||
+5
-4
@@ -115,13 +115,14 @@ func wikiTargetHref(target string) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// wikiTargetExists reports whether the on-disk folder backing the target
|
// wikiTargetExists reports whether the on-disk path backing the target exists
|
||||||
// exists under root.
|
// 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 {
|
func wikiTargetExists(root, target string) bool {
|
||||||
target = normalizeWikiTarget(target)
|
target = normalizeWikiTarget(target)
|
||||||
fsPath := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(target, "/")))
|
fsPath := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(target, "/")))
|
||||||
info, err := os.Stat(fsPath)
|
_, err := os.Stat(fsPath)
|
||||||
return err == nil && info.IsDir()
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// wikiDefaultDisplay returns the last segment of a target, or "/" for the root.
|
// wikiDefaultDisplay returns the last segment of a target, or "/" for the root.
|
||||||
|
|||||||
Reference in New Issue
Block a user