// 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(); } })();