Rework wikilinks feature

This commit is contained in:
2026-09-17 17:46:36 +02:00
parent b14941b3e0
commit b57e5d454b
12 changed files with 334 additions and 544 deletions
+2 -1
View File
@@ -30,10 +30,11 @@
<button type="button" class="btn btn-tool" data-action="code" data-key="C" title="Inline code (C)">`</button>
<button type="button" class="btn btn-tool" data-action="codeblock" data-key="K" title="Code block (K)">```</button>
<span class="dropdown">
<button type="button" class="btn btn-tool dropdown-toggle" title="Link (L/P)">L▾</button>
<button type="button" class="btn btn-tool dropdown-toggle" title="Link (L/P/G)">L▾</button>
<div class="dropdown-menu">
<button type="button" class="btn btn-tool btn-block" data-action="link" data-key="L" title="Link (L)">Link</button>
<button type="button" class="btn btn-tool btn-block" data-action="wikilink" data-key="P" title="Wiki link (P)">Wiki link</button>
<button type="button" class="btn btn-tool btn-block" data-action="embed" data-key="G" title="Embed file (G)">Embed file</button>
</div>
</span>
<span class="dropdown">
+102 -36
View File
@@ -223,6 +223,7 @@
quote: function () { linePrefix('> '); },
link: function () { wrap('[', '](url)', 'link text'); },
wikilink: insertWikilink,
embed: insertEmbed,
ul: function () { linePrefix('- '); },
ol: function () { linePrefix('1. '); },
task: function () { linePrefix('- [ ] '); },
@@ -240,43 +241,34 @@
movie: function () { M.run(movieCtx); },
};
// 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.)
// Wiki link button (ALT+SHIFT+P): a query field backed by /_search typeahead
// plus an optional display-text field, inserting [[query]] or
// [[query::display]] at the cursor. Picking a suggestion only fills in that
// page's name — the link resolves through search at click time, so a path
// never enters the document. Inline `[[` typing uses the same suggestions
// via wikicomplete.js.
function insertWikilink() {
var sel = view.state.selection.main;
var selectedText = view.state.sliceDoc(sel.from, sel.to);
var selectedText = view.state.sliceDoc(sel.from, sel.to).trim();
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 queryWrap = document.createElement('div');
var queryInput = document.createElement('input');
queryInput.type = 'text';
queryInput.className = 'input';
queryInput.placeholder = 'Search query';
// A selection is the query, not the label: [[selection]] renders with
// the same visible text anyway, and it seeds the typeahead.
queryInput.value = selectedText;
queryWrap.appendChild(queryInput);
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(queryWrap);
container.appendChild(displayInput);
var handle = openModal({
@@ -284,30 +276,104 @@
body: container,
confirm: {
label: 'INSERT',
initiallyDisabled: true,
initiallyDisabled: !selectedText,
onConfirm: function () {
var target = targetInput.value.trim();
if (!isValidWikiTarget(target)) return;
var query = queryInput.value.trim();
if (!query) return;
var display = displayInput.value.trim();
handle.close();
insertAtCursor(display ? '[[' + target + '::' + display + ']]' : '[[' + target + ']]');
insertAtCursor(display ? '[[' + query + '::' + display + ']]' : '[[' + query + ']]');
}
}
});
function updateConfirm() {
handle.setConfirmDisabled(!isValidWikiTarget(targetInput.value.trim()));
handle.setConfirmDisabled(!queryInput.value.trim());
}
targetInput.addEventListener('input', updateConfirm);
queryInput.addEventListener('input', updateConfirm);
window.attachSuggestions(targetInput, {
window.attachSuggestions(queryInput, {
showFooter: false,
container: targetWrap,
container: queryWrap,
onPick: function (r) {
targetInput.value = '/' + r.path;
queryInput.value = r.name;
updateConfirm();
displayInput.focus();
displayInput.select();
}
});
}
// embedDest turns the absolute wiki path the tree picker hands back into the
// destination written to the document: relative to the page being edited
// when the file lives under it, absolute otherwise. Pages render at a
// slash-terminated URL, so a relative destination resolves against the page
// in the browser.
function embedDest(absPath) {
var base = decodeURIComponent(window.location.pathname);
if (base.slice(-1) !== '/') base += '/';
return absPath.indexOf(base) === 0 ? absPath.slice(base.length) : absPath;
}
// embedToken formats a Markdown image. A destination carrying spaces or
// brackets goes in <angle brackets> — CommonMark's way of keeping it in one
// piece — and the title slot holds the alignment keyword the renderer reads.
// Right is the renderer's default, so it is left off.
function embedToken(dest, caption, align) {
if (/[\s()<>]/.test(dest)) dest = '<' + dest + '>';
var title = (align && align !== 'right') ? ' "' + align + '"' : '';
return '![' + caption.replace(/([\[\]\\])/g, '\\$1') + '](' + dest + title + ')';
}
// Embed button (ALT+SHIFT+G): pick a file from the tree, then give it a
// caption and an alignment. Images render as a floated figure; anything
// else degrades to a link.
function insertEmbed() {
var sel = view.state.selection.main;
var selectedText = view.state.sliceDoc(sel.from, sel.to).trim();
var here = decodeURIComponent(window.location.pathname).replace(/\/+$/, '') || '/';
openTreePicker({
title: 'Embed — pick a file',
mode: 'file',
initialPath: here,
confirmLabel: 'NEXT',
onSelect: function (absPath) {
var container = document.createElement('div');
var captionInput = document.createElement('input');
captionInput.type = 'text';
captionInput.className = 'input';
captionInput.placeholder = 'Caption (optional)';
captionInput.value = selectedText;
var alignSelect = document.createElement('select');
alignSelect.className = 'input';
[['right', 'Float right'], ['left', 'Float left'], ['center', 'Centered']]
.forEach(function (opt) {
var o = document.createElement('option');
o.value = opt[0];
o.textContent = opt[1];
alignSelect.appendChild(o);
});
container.appendChild(captionInput);
container.appendChild(alignSelect);
var handle = openModal({
title: 'Embed — caption?',
body: container,
confirm: {
label: 'INSERT',
onConfirm: function () {
handle.close();
insertAtCursor(embedToken(
embedDest(absPath),
captionInput.value.trim(),
alignSelect.value
));
}
}
});
}
});
}
+43 -85
View File
@@ -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);
});
+1 -16
View File
@@ -129,23 +129,9 @@ function movePage() {
input.placeholder = 'Page name';
input.value = currentName;
var linksCheckbox = document.createElement('input');
linksCheckbox.type = 'checkbox';
linksCheckbox.id = 'move-update-links';
var linksLabel = document.createElement('label');
linksLabel.htmlFor = linksCheckbox.id;
linksLabel.className = 'row';
linksLabel.appendChild(linksCheckbox);
linksLabel.appendChild(document.createTextNode('Update links'));
var body = document.createDocumentFragment();
body.appendChild(input);
body.appendChild(linksLabel);
openModal({
title: 'Move — new name?',
body: body,
body: input,
confirm: {
label: 'MOVE',
onConfirm: function () {
@@ -154,7 +140,6 @@ function movePage() {
var dest = (newParent === '/' ? '' : newParent) + '/' + name;
var action = window.location.pathname + '?move=' +
encodeURIComponent(dest);
if (linksCheckbox.checked) action += '&links=1';
var target = encodePickedPath(dest) + '/';
closeModal();
submitMove(action, target);
-2
View File
@@ -75,8 +75,6 @@ a { color: var(--text); text-decoration: none; }
a:hover { color: var(--link-hover); }
.content a { color: var(--link); }
.content a:hover { color: var(--link-hover); }
.content a.broken { color: var(--link-hover); text-decoration: line-through; }
.content a.broken:hover { color: var(--link-hover); }
hr { border: none; border-top: var(--border-dashed); margin: var(--space-4) 0; }