This repository has been archived on 2024-09-05. You can view files and clone it, but cannot push or open issues or pull requests.
shimmie2/ext/autocomplete/script.js

263 lines
7.4 KiB
JavaScript

/**
* Find the word currently being typed in the given element
*
* @param {HTMLInputElement} element
* @returns {string}
*/
function getCurrentWord(element) {
let text = element.value;
let pos = element.selectionStart;
var start = text.lastIndexOf(' ', pos-1);
if(start === -1) {
start = 0;
}
else {
start++; // skip the space
}
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) {
// Reset selction, but no need to validate and re-render
// highlightCompletion(element, -1);
element.selected_completion = -1;
// get the word before the cursor
var word = getCurrentWord(element);
// search for completions
if(element.completer_timeout !== null) {
clearTimeout(element.completer_timeout);
element.completer_timeout = null;
}
if(word === '' || word === '-') {
element.completions = {};
renderCompletions(element);
}
else {
element.completer_timeout = setTimeout(() => {
const wordWithoutMinus = word.replace(/^-/, '');
fetch(shm_make_link('api/internal/autocomplete', {s: wordWithoutMinus})).then(
(response) => response.json()
).then((json) => {
if(element.selected_completion !== -1) {
return; // user has started to navigate the completions, so don't update them
}
element.completions = json;
renderCompletions(element);
});
}, 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;
// remove any existing completion block
hideCompletions();
// if there are no completions, don't render anything
if(Object.keys(completions).length === 0) {
return;
}
let completions_el = document.createElement('ul');
completions_el.className = 'autocomplete_completions';
completions_el.id = 'completions';
// add children for top completions, with the selected one highlighted
let word = getCurrentWord(element);
Object.keys(completions).filter(
(key) => {
let k = key.toLowerCase();
let w = word.replace(/^-/, '').toLowerCase();
return (k.startsWith(w) || k.split(':').some((k) => k.startsWith(w)))
}
).slice(0, 100).forEach((key, i) => {
let li = document.createElement('li');
li.innerText = completions[key].newtag ?
`${key}${completions[key].newtag} (${completions[key].count})` :
`${key} (${completions[key].count})` ;
if(i === selected_completion) {
li.className = 'selected';
}
// on hover, select the completion
// (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);
}
});
// 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)
li.addEventListener('mousedown', (event) => {
setCompletion(element, key);
event.preventDefault();
});
li.addEventListener('touchstart', (event) => {
setCompletion(element, key);
event.preventDefault();
});
completions_el.appendChild(li);
});
// insert the completion block after the element
if(element.parentNode) {
element.parentNode.insertBefore(completions_el, element.nextSibling);
let br = element.getBoundingClientRect();
completions_el.style.minWidth = br.width + 'px';
completions_el.style.maxWidth = 'calc(100vw - 2rem - ' + br.left + 'px)';
completions_el.style.left = window.scrollX + br.left + 'px';
completions_el.style.top = window.scrollY + (br.top + br.height) + 'px';
}
}
/**
* hide the completions block
*/
function hideCompletions() {
document.querySelectorAll('.autocomplete_completions').forEach((element) => {
element.remove();
});
}
/**
* 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;
// resolve alias before setting the word
if(element.completions[new_word].newtag) {
new_word = element.completions[new_word].newtag;
}
// 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
if(text[start] === '-') {
new_word = '-' + new_word;
}
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');
// safari treats spellcheck as a form of autocomplete
element.setAttribute('spellcheck', 'off');
// when element is focused, add completion block
element.addEventListener('focus', () => {
updateCompletions(element);
});
// when element is blurred, remove completion block
element.addEventListener('blur', () => {
hideCompletions();
});
// 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);
}
else if(event.code === "ArrowDown") {
event.preventDefault();
highlightCompletion(element, element.selected_completion+1);
}
// if enter or right are pressed while a completion is selected, add the selected completion
else if((event.code === "Enter" || event.code == "ArrowRight") && element.selected_completion !== -1) {
event.preventDefault();
const key = Object.keys(element.completions)[element.selected_completion]
setCompletion(element, key);
}
// if escape is pressed, hide the completion block
else if(event.code === "Escape") {
event.preventDefault();
hideCompletions();
}
});
// on change, update completions
element.addEventListener('input', () => {
updateCompletions(element);
});
});
});