198 lines
6.5 KiB
JavaScript
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();
|
|
}
|
|
})();
|