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