2023-12-24 21:21:25 +00:00
|
|
|
/**
|
2023-12-29 10:35:23 +00:00
|
|
|
* Find the word currently being typed in the given element
|
2023-12-24 21:21:25 +00:00
|
|
|
*
|
|
|
|
* @param {HTMLInputElement} element
|
2023-12-29 10:35:23 +00:00
|
|
|
* @returns {string}
|
2023-12-24 21:21:25 +00:00
|
|
|
*/
|
2023-12-29 10:35:23 +00:00
|
|
|
function getCurrentWord(element) {
|
2023-12-24 21:21:25 +00:00
|
|
|
let text = element.value;
|
|
|
|
let pos = element.selectionStart;
|
|
|
|
var start = text.lastIndexOf(' ', pos-1);
|
|
|
|
if(start === -1) {
|
|
|
|
start = 0;
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
start++; // skip the space
|
|
|
|
}
|
2023-12-29 10:35:23 +00:00
|
|
|
return text.substring(start, pos);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Whenever input changes, look at what word is currently
|
|
|
|
* being typed, and fetch completions for it.
|
|
|
|
*
|
|
|
|
* @param {HTMLInputElement} element
|
|
|
|
*/
|
|
|
|
function updateCompletions(element) {
|
2023-12-29 10:38:24 +00:00
|
|
|
// Reset selction, but no need to validate and re-render
|
|
|
|
// highlightCompletion(element, -1);
|
|
|
|
element.selected_completion = -1;
|
2023-12-29 10:35:23 +00:00
|
|
|
|
|
|
|
// get the word before the cursor
|
|
|
|
var word = getCurrentWord(element);
|
2016-06-18 10:58:41 +00:00
|
|
|
|
2023-12-24 21:21:25 +00:00
|
|
|
// search for completions
|
|
|
|
if(element.completer_timeout !== null) {
|
|
|
|
clearTimeout(element.completer_timeout);
|
|
|
|
element.completer_timeout = null;
|
|
|
|
}
|
2024-02-12 13:25:06 +00:00
|
|
|
if(word === '' || word === '-') {
|
2023-12-24 21:21:25 +00:00
|
|
|
element.completions = {};
|
|
|
|
renderCompletions(element);
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
element.completer_timeout = setTimeout(() => {
|
2024-02-11 16:40:44 +00:00
|
|
|
const wordWithoutMinus = word.replace(/^-/, '');
|
2024-06-05 12:58:59 +00:00
|
|
|
fetch(shm_make_link('api/internal/autocomplete', {s: wordWithoutMinus})).then(
|
2023-12-24 21:21:25 +00:00
|
|
|
(response) => response.json()
|
|
|
|
).then((json) => {
|
|
|
|
if(element.selected_completion !== -1) {
|
|
|
|
return; // user has started to navigate the completions, so don't update them
|
2023-03-26 06:42:25 +00:00
|
|
|
}
|
2023-12-24 21:21:25 +00:00
|
|
|
element.completions = json;
|
|
|
|
renderCompletions(element);
|
2023-03-26 06:42:25 +00:00
|
|
|
});
|
2023-12-24 21:21:25 +00:00
|
|
|
}, 250);
|
|
|
|
renderCompletions(element);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Highlight the nth completion
|
|
|
|
*
|
|
|
|
* @param {HTMLInputElement} element
|
|
|
|
* @param {number} n
|
|
|
|
*/
|
|
|
|
function highlightCompletion(element, n) {
|
|
|
|
if(!element.completions) return;
|
|
|
|
element.selected_completion = Math.min(
|
|
|
|
Math.max(n, -1),
|
|
|
|
Object.keys(element.completions).length-1
|
|
|
|
);
|
|
|
|
renderCompletions(element);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Render the completion block
|
|
|
|
*
|
|
|
|
* @param {HTMLInputElement} element
|
|
|
|
*/
|
|
|
|
function renderCompletions(element) {
|
|
|
|
let completions = element.completions;
|
|
|
|
let selected_completion = element.selected_completion;
|
|
|
|
|
2023-12-29 10:42:12 +00:00
|
|
|
// remove any existing completion block
|
|
|
|
hideCompletions();
|
|
|
|
|
|
|
|
// if there are no completions, don't render anything
|
2023-12-24 21:21:25 +00:00
|
|
|
if(Object.keys(completions).length === 0) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2023-12-29 10:42:12 +00:00
|
|
|
let completions_el = document.createElement('ul');
|
|
|
|
completions_el.className = 'autocomplete_completions';
|
|
|
|
completions_el.id = 'completions';
|
2023-12-24 21:21:25 +00:00
|
|
|
|
2023-12-29 11:00:56 +00:00
|
|
|
// add children for top completions, with the selected one highlighted
|
2023-12-29 12:04:05 +00:00
|
|
|
let word = getCurrentWord(element);
|
|
|
|
Object.keys(completions).filter(
|
2024-01-05 12:24:50 +00:00
|
|
|
(key) => {
|
|
|
|
let k = key.toLowerCase();
|
2024-02-11 16:40:44 +00:00
|
|
|
let w = word.replace(/^-/, '').toLowerCase();
|
2024-01-21 10:51:43 +00:00
|
|
|
return (k.startsWith(w) || k.split(':').some((k) => k.startsWith(w)))
|
2024-01-05 12:24:50 +00:00
|
|
|
}
|
2023-12-29 12:04:05 +00:00
|
|
|
).slice(0, 100).forEach((key, i) => {
|
2023-12-24 21:21:25 +00:00
|
|
|
let li = document.createElement('li');
|
2024-02-12 13:40:23 +00:00
|
|
|
li.innerText = completions[key].newtag ?
|
|
|
|
`${key} → ${completions[key].newtag} (${completions[key].count})` :
|
|
|
|
`${key} (${completions[key].count})` ;
|
2023-12-24 21:21:25 +00:00
|
|
|
if(i === selected_completion) {
|
|
|
|
li.className = 'selected';
|
2016-06-18 10:58:41 +00:00
|
|
|
}
|
2023-12-24 21:21:25 +00:00
|
|
|
// on hover, select the completion
|
2023-12-29 11:39:00 +00:00
|
|
|
// (use mousemove rather than mouseover, because
|
|
|
|
// if the mouse is stationary, then we want the
|
|
|
|
// keyboard to be able to override it)
|
|
|
|
li.addEventListener('mousemove', () => {
|
|
|
|
// avoid re-rendering if the completion is already selected
|
|
|
|
if(element.selected_completion !== i) {
|
|
|
|
highlightCompletion(element, i);
|
|
|
|
}
|
2023-12-24 21:21:25 +00:00
|
|
|
});
|
|
|
|
// on click, set the completion
|
|
|
|
// (mousedown is used instead of click because click is
|
|
|
|
// fired after blur, which causes the completion block to
|
|
|
|
// be removed before the click event is handled)
|
2023-12-26 03:40:30 +00:00
|
|
|
li.addEventListener('mousedown', (event) => {
|
2023-12-24 21:21:25 +00:00
|
|
|
setCompletion(element, key);
|
2023-12-26 03:40:30 +00:00
|
|
|
event.preventDefault();
|
2023-12-24 21:21:25 +00:00
|
|
|
});
|
2023-12-26 04:18:40 +00:00
|
|
|
li.addEventListener('touchstart', (event) => {
|
|
|
|
setCompletion(element, key);
|
|
|
|
event.preventDefault();
|
|
|
|
});
|
2023-12-24 21:21:25 +00:00
|
|
|
completions_el.appendChild(li);
|
|
|
|
});
|
|
|
|
|
|
|
|
// insert the completion block after the element
|
|
|
|
if(element.parentNode) {
|
|
|
|
element.parentNode.insertBefore(completions_el, element.nextSibling);
|
2023-12-26 03:18:31 +00:00
|
|
|
let br = element.getBoundingClientRect();
|
2023-12-29 11:37:44 +00:00
|
|
|
completions_el.style.minWidth = br.width + 'px';
|
2024-01-07 22:14:13 +00:00
|
|
|
completions_el.style.maxWidth = 'calc(100vw - 2rem - ' + br.left + 'px)';
|
2023-12-26 04:11:35 +00:00
|
|
|
completions_el.style.left = window.scrollX + br.left + 'px';
|
|
|
|
completions_el.style.top = window.scrollY + (br.top + br.height) + 'px';
|
2023-12-24 21:21:25 +00:00
|
|
|
}
|
|
|
|
}
|
2023-12-29 10:42:12 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* hide the completions block
|
|
|
|
*/
|
|
|
|
function hideCompletions() {
|
|
|
|
document.querySelectorAll('.autocomplete_completions').forEach((element) => {
|
|
|
|
element.remove();
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2023-12-24 21:21:25 +00:00
|
|
|
/**
|
|
|
|
* Set the current word to the given completion
|
|
|
|
*
|
|
|
|
* @param {HTMLInputElement} element
|
|
|
|
* @param {string} new_word
|
|
|
|
*/
|
|
|
|
function setCompletion(element, new_word) {
|
|
|
|
let text = element.value;
|
|
|
|
let pos = element.selectionStart;
|
|
|
|
|
2024-02-12 13:40:23 +00:00
|
|
|
// resolve alias before setting the word
|
|
|
|
if(element.completions[new_word].newtag) {
|
|
|
|
new_word = element.completions[new_word].newtag;
|
|
|
|
}
|
|
|
|
|
2023-12-24 21:21:25 +00:00
|
|
|
// get the word before the cursor
|
|
|
|
var start = text.lastIndexOf(' ', pos-1);
|
|
|
|
if(start === -1) {
|
|
|
|
start = 0;
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
start++; // skip the space
|
|
|
|
}
|
|
|
|
var end = text.indexOf(' ', pos);
|
|
|
|
if(end === -1) {
|
|
|
|
end = text.length;
|
|
|
|
}
|
|
|
|
|
|
|
|
// replace the word with the completion
|
2024-02-11 16:40:44 +00:00
|
|
|
if(text[start] === '-') {
|
|
|
|
new_word = '-' + new_word;
|
|
|
|
}
|
2023-12-24 21:21:25 +00:00
|
|
|
new_word += ' ';
|
|
|
|
element.value = text.substring(0, start) + new_word + text.substring(end);
|
|
|
|
element.selectionStart = start + new_word.length;
|
|
|
|
element.selectionEnd = start + new_word.length;
|
|
|
|
|
|
|
|
// reset metadata
|
|
|
|
element.completions = {};
|
|
|
|
element.selected_completion = -1;
|
|
|
|
if(element.completer_timeout) {
|
|
|
|
clearTimeout(element.completer_timeout);
|
|
|
|
element.completer_timeout = null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
|
|
// Find all elements with class 'autocomplete_tags'
|
|
|
|
document.querySelectorAll('.autocomplete_tags').forEach((element) => {
|
|
|
|
// set metadata
|
|
|
|
element.completions = {};
|
|
|
|
element.selected_completion = -1;
|
|
|
|
element.completer_timeout = null;
|
|
|
|
|
|
|
|
// disable built-in autocomplete
|
|
|
|
element.setAttribute('autocomplete', 'off');
|
2024-01-08 19:14:42 +00:00
|
|
|
|
|
|
|
// safari treats spellcheck as a form of autocomplete
|
|
|
|
element.setAttribute('spellcheck', 'off');
|
2023-12-24 21:21:25 +00:00
|
|
|
|
|
|
|
// when element is focused, add completion block
|
|
|
|
element.addEventListener('focus', () => {
|
|
|
|
updateCompletions(element);
|
|
|
|
});
|
|
|
|
|
|
|
|
// when element is blurred, remove completion block
|
|
|
|
element.addEventListener('blur', () => {
|
2023-12-29 10:42:12 +00:00
|
|
|
hideCompletions();
|
2023-12-24 21:21:25 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
// when cursor is moved, change current completion
|
|
|
|
document.addEventListener('selectionchange', () => {
|
|
|
|
// if element is focused
|
|
|
|
if(document.activeElement === element) {
|
|
|
|
updateCompletions(element);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
element.addEventListener('keydown', (event) => {
|
|
|
|
// up / down should select previous / next completion
|
|
|
|
if(event.code === "ArrowUp") {
|
|
|
|
event.preventDefault();
|
|
|
|
highlightCompletion(element, element.selected_completion-1);
|
|
|
|
}
|
2024-01-19 19:11:18 +00:00
|
|
|
else if(event.code === "ArrowDown") {
|
2023-12-24 21:21:25 +00:00
|
|
|
event.preventDefault();
|
|
|
|
highlightCompletion(element, element.selected_completion+1);
|
|
|
|
}
|
2024-02-04 00:24:34 +00:00
|
|
|
// if enter or right are pressed while a completion is selected, add the selected completion
|
2024-01-19 19:11:18 +00:00
|
|
|
else if((event.code === "Enter" || event.code == "ArrowRight") && element.selected_completion !== -1) {
|
2023-12-24 21:21:25 +00:00
|
|
|
event.preventDefault();
|
2024-02-12 13:40:23 +00:00
|
|
|
const key = Object.keys(element.completions)[element.selected_completion]
|
|
|
|
setCompletion(element, key);
|
2023-12-24 21:21:25 +00:00
|
|
|
}
|
|
|
|
// if escape is pressed, hide the completion block
|
2024-01-19 19:11:18 +00:00
|
|
|
else if(event.code === "Escape") {
|
2023-12-29 10:42:12 +00:00
|
|
|
event.preventDefault();
|
|
|
|
hideCompletions();
|
2023-12-24 21:21:25 +00:00
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// on change, update completions
|
|
|
|
element.addEventListener('input', () => {
|
|
|
|
updateCompletions(element);
|
|
|
|
});
|
2016-06-18 10:58:41 +00:00
|
|
|
});
|
2016-06-18 18:00:26 +00:00
|
|
|
});
|