'use strict';
/**
* this is the telescope-style fuzzy finder modal. it owns the overlay, the
* result list + preview pane, and the current selection. the pure helpers
* (clamp_index, result_url, is_editable) hang off the class as static methods
*/
export class Telescope {
/**
* this function wraps an index into [0, len), so moving past either end
* of the result list loops around
*
* @param {number} i
* @param {number} len
* @return {number}
*/
static clamp_index(i, len) {
if (len <= 0) {
return 0;
}
if (i < 0) {
return len - 1;
}
if (i >= len) {
return 0;
}
return i;
}
/**
* this function builds the thread url for a search result
*
* @param {object} result
* @return {string}
*/
static result_url(result) {
return '/thread/' + encodeURIComponent(result.thread_id);
}
/**
* this function tells whether an element is something youre typing into,
* so the "/" shortcut doesnt hijack a real input
*
* @param {Element} el
* @return {boolean}
*/
static is_editable(el) {
if (!el) {
return false;
}
const tag = el.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable === true;
}
constructor() {
this.results = [];
this.selected = 0;
this.open = false;
this.timer = null;
this.controller = null;
this.build();
}
/**
* this function builds the modal dom once and wires up its input + click
* handlers, then parks it on the body hidden
*
* @return {void}
*/
build() {
const overlay = document.createElement('div');
overlay.className = 'tele-overlay';
overlay.innerHTML =
'
';
this.overlay = overlay;
this.input = overlay.querySelector('.tele-input');
this.list = overlay.querySelector('.tele-list');
this.preview = overlay.querySelector('.tele-preview');
overlay.addEventListener('mousedown', (e) => {
if (e.target === overlay) {
this.close();
}
});
this.input.addEventListener('input', () => {
this.query(this.input.value);
});
this.input.addEventListener('keydown', (e) => {
this.on_key(e);
});
document.body.appendChild(overlay);
}
/**
* this function opens the modal, clearing whatever was there last and
* focusing the input
*
* @return {void}
*/
show() {
this.open = true;
this.overlay.classList.add('tele-visible');
this.input.value = '';
this.results = [];
this.selected = 0;
this.render();
this.input.focus();
}
/**
* this function closes the modal and aborts any in-flight search
*
* @return {void}
*/
close() {
this.open = false;
this.overlay.classList.remove('tele-visible');
if (this.controller) {
this.controller.abort();
this.controller = null;
}
}
/**
* this function handles keys while the modal is focused: esc closes,
* enter opens the selection, and arrows / ctrl-j / ctrl-k move the cursor
*
* @param {KeyboardEvent} e
* @return {void}
*/
on_key(e) {
if (e.key === 'Escape') {
e.preventDefault();
this.close();
} else if (e.key === 'Enter') {
e.preventDefault();
this.choose();
} else if (e.key === 'ArrowDown' || (e.ctrlKey && e.key === 'j')) {
e.preventDefault();
this.move(1);
} else if (e.key === 'ArrowUp' || (e.ctrlKey && e.key === 'k')) {
e.preventDefault();
this.move(-1);
}
}
/**
* this function moves the selection by delta (wrapping at the ends)
*
* @param {number} delta
* @return {void}
*/
move(delta) {
this.selected = Telescope.clamp_index(this.selected + delta, this.results.length);
this.render();
}
/**
* this function navigates to the currently selected result
*
* @return {void}
*/
choose() {
const r = this.results[this.selected];
if (r) {
window.location.href = Telescope.result_url(r);
}
}
/**
* this function debounces typing so we only hit the server once the user
* pauses for a moment
*
* @param {string} q
* @return {void}
*/
query(q) {
if (this.timer) {
clearTimeout(this.timer);
}
this.timer = setTimeout(() => this.fetch(q), 120);
}
/**
* this function fetches results from the /search json endpoint,
* cancelling any earlier request thats still in flight so stale results
* cant land late
*
* @param {string} q
* @return {void}
*/
fetch(q) {
if (q.trim() === '') {
this.results = [];
this.selected = 0;
this.render();
return;
}
if (this.controller) {
this.controller.abort();
}
this.controller = new AbortController();
window.fetch('/search?json=1&q=' + encodeURIComponent(q), { signal: this.controller.signal })
.then((r) => r.json())
.then((data) => {
this.results = data || [];
this.selected = 0;
this.render();
})
.catch(() => {});
}
/**
* this function repaints the result list and the preview pane for the
* current selection. titles go in as text, but the snippet is set as html
* since the server already escaped it and only left tags in
*
* @return {void}
*/
render() {
this.list.innerHTML = '';
if (!this.results.length) {
this.preview.innerHTML = '';
const empty = document.createElement('li');
empty.className = 'tele-empty';
empty.textContent = this.input.value.trim() === '' ? 'Type to search' : 'No results';
this.list.appendChild(empty);
return;
}
this.results.forEach((r, i) => {
const li = document.createElement('li');
li.className = 'tele-item' + (i === this.selected ? ' tele-active' : '');
const title = document.createElement('span');
title.className = 'tele-item-title';
title.textContent = r.title;
li.appendChild(title);
if (r.board_slug) {
const board = document.createElement('span');
board.className = 'tele-item-board';
board.textContent = r.board_slug;
li.appendChild(board);
}
li.addEventListener('mouseenter', () => {
this.selected = i;
this.render();
});
li.addEventListener('click', () => this.choose());
this.list.appendChild(li);
});
const sel = this.results[this.selected];
this.preview.innerHTML =
'';
this.preview.querySelector('.tele-prev-title').textContent = sel.title;
this.preview.querySelector('.tele-prev-snippet').innerHTML = sel.snippet || '';
}
}
/**
* this function wires up the global "/" and ctrl-k shortcuts (ignored while
* youre typing in a field) plus any [data-telescope] links
*
* @return {void}
*/
function telescope_init() {
const tele = new Telescope();
document.addEventListener('keydown', (e) => {
if (tele.open) {
return;
}
if (e.key === '/' && !Telescope.is_editable(e.target)) {
e.preventDefault();
tele.show();
} else if (e.ctrlKey && e.key === 'k') {
e.preventDefault();
tele.show();
}
});
const links = document.querySelectorAll('[data-telescope]');
links.forEach((a) => {
a.addEventListener('click', (e) => {
e.preventDefault();
tele.show();
});
});
}
if (typeof document !== 'undefined' && document.querySelectorAll) {
telescope_init();
}