munin/static/autocomplete.js
2026-10-19 10:13:56 +02:00

198 lines
6.5 KiB
JavaScript

// Schlägt beim Tippen von "[[" vorhandene Bezeichner vor.
//
// Bewusst kein an der Schreibmarke ausgerichtetes Einblendfenster — das
// bräuchte einen Spiegel-Trick, um die Pixel-Position im Textfeld
// nachzubauen (eine unsichtbare Kopie des Feldes, die dieselbe Schrift und
// denselben Umbruch trägt, nur um die Koordinate eines Zeichens abzulesen).
// Die Vorschläge stehen stattdessen als Liste direkt unter dem Feld, im
// selben Geist wie die Sammelleiste unter der Eintragsübersicht.
(function () {
"use strict";
var ACTIVE = "active";
var timer = null;
document.addEventListener("input", function (event) {
if (!isEditorField(event.target)) {
return;
}
clearTimeout(timer);
timer = setTimeout(function () {
updateSuggestions(event.target);
}, 150);
});
document.addEventListener("keydown", function (event) {
if (!isEditorField(event.target)) {
return;
}
var list = listFor(event.target);
if (!list || list.hidden) {
return;
}
switch (event.key) {
case "Escape":
list.hidden = true;
break;
case "ArrowDown":
event.preventDefault();
moveSelection(list, 1);
break;
case "ArrowUp":
event.preventDefault();
moveSelection(list, -1);
break;
case "Enter":
case "Tab": {
var selected = list.querySelector("li." + ACTIVE) || list.firstElementChild;
if (selected) {
event.preventDefault();
accept(event.target, list, selected);
}
break;
}
}
});
// mousedown statt click, und mit preventDefault: Sonst verlässt die
// Textarea den Fokus, BEVOR der Klick verarbeitet wird, und die Liste
// wäre durch den Blur-Handler unten schon verschwunden.
document.addEventListener("mousedown", function (event) {
var entry = event.target.closest("[data-autocomplete] li");
if (!entry) {
return;
}
event.preventDefault();
var list = entry.closest("[data-autocomplete]");
var textarea = textareaFor(list);
if (textarea) {
accept(textarea, list, entry);
}
});
// Blur bubbelt nicht — nur über die Capture-Phase zu fangen.
document.addEventListener(
"blur",
function (event) {
if (!isEditorField(event.target)) {
return;
}
var list = listFor(event.target);
if (list) {
list.hidden = true;
}
},
true,
);
function isEditorField(el) {
return !!(el && el.tagName === "TEXTAREA" && el.hasAttribute("data-suggest-url"));
}
function listFor(textarea) {
return textarea.parentElement.querySelector("[data-autocomplete]");
}
function textareaFor(list) {
return list.parentElement.querySelector("textarea[data-suggest-url]");
}
// Sucht rückwärts von der Schreibmarke aus das letzte "[[", das noch
// nicht mit "]]" geschlossen wurde. Kein Treffer heißt: die Schreibmarke
// steht gerade nicht in einem offenen Verweis.
function openBracket(val, position) {
var start = val.lastIndexOf("[[", position - 1);
if (start === -1) {
return null;
}
var between = val.slice(start + 2, position);
if (between.indexOf("]]") !== -1 || between.indexOf("\n") !== -1 || between.length > 60) {
return null;
}
return { start: start, search: between };
}
function updateSuggestions(textarea) {
var list = listFor(textarea);
if (!list) {
return;
}
var bracket = openBracket(textarea.value, textarea.selectionStart);
if (!bracket) {
list.hidden = true;
return;
}
var url = textarea.getAttribute("data-suggest-url") + "?q=" + encodeURIComponent(bracket.search);
fetch(url, { headers: { Accept: "application/json" } })
.then(function (answer) {
return answer.ok ? answer.json() : [];
})
.then(function (hits) {
// Zwischenzeitlich könnte weitergetippt oder das Feld
// verlassen worden sein — dann passt die Antwort nicht mehr
// zur aktuellen Schreibmarke.
var now = openBracket(textarea.value, textarea.selectionStart);
if (!now || now.start !== bracket.start) {
return;
}
showSuggestions(list, hits, bracket.start);
})
.catch(function () {
list.hidden = true;
});
}
function showSuggestions(list, hits, bracketStart) {
list.innerHTML = "";
if (!hits.length) {
list.hidden = true;
return;
}
hits.forEach(function (t, i) {
var li = document.createElement("li");
li.className = i === 0 ? ACTIVE : "";
li.dataset.slug = t.slug;
li.dataset.bracketStart = String(bracketStart);
var slug = document.createElement("code");
slug.textContent = t.slug;
var titleText = document.createElement("span");
titleText.textContent = t.title;
li.appendChild(slug);
li.appendChild(titleText);
list.appendChild(li);
});
list.hidden = false;
}
function moveSelection(list, direction) {
var entries = Array.prototype.slice.call(list.children);
if (!entries.length) {
return;
}
var index = entries.findIndex(function (e) {
return e.classList.contains(ACTIVE);
});
entries.forEach(function (e) {
e.classList.remove(ACTIVE);
});
var next = (index + direction + entries.length) % entries.length;
entries[next].classList.add(ACTIVE);
}
// Ersetzt den Text zwischen der offenen Klammer und der Schreibmarke
// durch "bezeichner]]" und setzt die Schreibmarke direkt dahinter.
function accept(textarea, list, entry) {
var start = parseInt(entry.dataset.bracketStart, 10);
var cursor = textarea.selectionStart;
textarea.setRangeText(entry.dataset.slug + "]]", start + 2, cursor, "end");
list.hidden = true;
textarea.focus();
}
})();