UNPKG

omnibox-js

Version:

Turn your div into a chrome omnibox

364 lines (325 loc) 12.9 kB
import { parseInput, PAGE_TURNER } from "./omnibox.js"; const DISPOSITION_CURRENT_TAB = 'currentTab'; // enter (default) const DISPOSITION_FOREGROUND_TAB = 'newForegroundTab'; // alt + enter const DISPOSITION_BACKROUND_TAB = 'newBackgroundTab'; // meta + enter export const OMNIBOX_HTML = ` <div class="omn-container"> <textarea class="omn-input" autocapitalize="off" autocomplete="off" autocorrect="off" maxlength="2048" role="combobox" rows="1" style="resize:none" spellcheck="false"></textarea> <div class="omn-clear"> <svg focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"></path></svg> </div> <div class="omn-search-icon"> <svg focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"></path></svg> </div> </div>`; // Wrapper function to add the event listener function addEventListenerOnce(eventName, handler) { // Remove any existing listeners for this event document.removeEventListener(eventName, handler); // Add the new listener document.addEventListener(eventName, handler); } export class Render { constructor({ el, element, icon, placeholder, onFooter }) { if (!el && !element) { throw new Error("`el` or `element` is required"); } if (!element) { element = document.querySelector(el); if (!element) { throw new Error(`not element found: ${el}`); } if (element.tagName !== "DIV") { throw new Error("The `el` can only be `div` tag"); } if (element.children.length > 0) { throw new Error("The `el` element should have no child elements"); } element.style.position = "relative"; element.innerHTML = OMNIBOX_HTML; } this.container = document.querySelector(".omn-container"); this.inputBox = element.querySelector("textarea"); this.searchKeyword = ""; if (placeholder) { this.inputBox.setAttribute("placeholder", placeholder); } this.icon = icon; this.onInputChanged = new OnInputChangedListener(); this.onInputEntered = new OnInputEnteredListener(); this.disposition = DISPOSITION_CURRENT_TAB; this.onFooter = onFooter; this.clearButton = element.querySelector(".omn-clear"); if (this.clearButton) { this.clearButton.onclick = () => { this.inputBox.value = ""; this.clearDropdown(); this.removeHint(); this.clearButton.style.display = "none"; }; } this.trigger = async (event) => { this.searchKeyword = event.target.value; await this.render(); }; this.inputBox.oninput = this.trigger; this.inputBox.onfocus = this.trigger; this.onKeyDown = async (event) => { switch (event.code) { case 'Enter': { event.preventDefault(); let selected = document.querySelector('.omn-selected'); if (selected) { if (event.metaKey) { this.disposition = DISPOSITION_BACKROUND_TAB; } else if (event.altKey) { this.disposition = DISPOSITION_FOREGROUND_TAB; } else { this.disposition = DISPOSITION_CURRENT_TAB; } let content = selected.getAttribute('data-content'); for (const listener of this.onInputEntered.listeners) { await listener(content, this.disposition); } } return; } case 'ArrowUp': { event.preventDefault(); this.selectUp(); return; } case 'ArrowDown': { event.preventDefault(); this.selectDown(); return; } case 'Escape': { event.preventDefault(); this.resetSearchKeyword(); return; } } if (event.ctrlKey) { switch (event.key) { case 'n': event.preventDefault(); this.pageDown(); break; case 'p': event.preventDefault(); this.pageUp(); break; case "j": event.preventDefault(); this.selectDown(); break; case "k": event.preventDefault(); this.selectUp(); break; } } }; this.inputBox.addEventListener("keydown", async (event) => { if (event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === "Enter") { // Prevent the default behavior of arrow up and arrow down keys event.preventDefault(); return; } else if ((event.ctrlKey && event.key === 'p') || (event.ctrlKey && event.key === 'n')) { event.preventDefault(); // Prevent the default action } }); this.clickOutSide = (event) => { let dropdown = document.querySelector('.omn-dropdown'); if (!event.composedPath().includes(element) || (dropdown && !event.composedPath().includes(element))) { // Click outside to clear dropdown this.resetSearchKeyword(); } }; document.addEventListener('click', this.clickOutSide); document.addEventListener('keydown', this.onKeyDown); } clearListeners() { document.removeEventListener('click', this.clickOutSide); document.removeEventListener('keydown', this.onKeyDown); } async render() { if (this.searchKeyword) { let suggestFn = this.suggest.bind(this); for (const listener of this.onInputChanged.listeners) { await listener(this.searchKeyword, suggestFn); } if (this.clearButton) { this.clearButton.style.display = "block"; } } else { this.removeHint(); this.clearDropdown(); if (this.clearButton) { this.clearButton.style.display = "none"; } } } resetSearchKeyword() { // Reset the input box value to the search keyword this.inputBox.value = this.searchKeyword; this.clearDropdown(); } clearDropdown() { this.container.classList.remove("omn-filled"); let dropdown = document.querySelector('.omn-dropdown'); if (dropdown) { dropdown.remove(); } } setHint(hintText) { this.removeHint(); let hintElement = document.createElement('div'); hintElement.classList.add('omn-hint'); hintElement.textContent = hintText; this.container.insertAdjacentHTML('afterbegin', ` <div class="omn-hint">${hintText}<div class="omn-hint-gapline"></div></div> `); } removeHint() { let hint = document.querySelector('.omn-hint'); if (hint) { hint.remove(); } } selectUp() { let selected = document.querySelector('.omn-selected'); if (selected) { let newSelected = null; if (selected.previousElementSibling) { newSelected = selected.previousElementSibling; } else { // Already selected the fist item, but a arrow-up key pressed, // select the last item. newSelected = document.querySelector('.omn-dropdown-item:last-child'); } if (newSelected) { selected.classList.remove('omn-selected'); newSelected.classList.add('omn-selected') this.inputBox.value = newSelected.getAttribute('data-value'); } } } selectDown() { let selected = document.querySelector('.omn-selected'); if (selected) { let newSelected = null; if (selected.nextElementSibling) { newSelected = selected.nextElementSibling; } else { // Already selected the last item, but a arrow-up key pressed, // select the fist item. newSelected = document.querySelector('.omn-dropdown-item:first-child'); } if (newSelected) { selected.classList.remove('omn-selected'); newSelected.classList.add('omn-selected') this.inputBox.value = newSelected.getAttribute('data-value'); } } } async pageDown() { if (this.searchKeyword) { let { query, page } = parseInput(this.searchKeyword); if (!query) { page += 1; } this.searchKeyword = `${query} ${PAGE_TURNER.repeat(page)}`; this.inputBox.value = this.searchKeyword; await this.render(); } } async pageUp() { if (this.searchKeyword) { let { query, page } = parseInput(this.searchKeyword); if (query) { page -= 1; } if (page > 0) { this.searchKeyword = `${query} ${PAGE_TURNER.repeat(Math.max(0, page - 1))}`; this.inputBox.value = this.searchKeyword; } await this.render(); } } /** * * @param {Array[{content, description}]} suggestions * @param {curr, total} pagination */ suggest(suggestions, pagination) { this.clearDropdown(); this.container.classList.add("omn-filled"); let dropdown = document.createElement('div'); dropdown.classList.add('omn-dropdown'); let gapline = document.createElement("div"); gapline.classList.add("omn-gapline"); dropdown.appendChild(gapline); let items = document.createElement("div"); for (let [index, { content, description, icon }] of suggestions.entries()) { let li = document.createElement("div"); li.classList.add("omn-dropdown-item"); li.style.position = "relative"; li.setAttribute("data-content", content); if (index === 0) { // Always select the first item by default. li.classList.add('omn-selected'); // Set the inputbox value as data-value, similar to chrome.omnibox API li.setAttribute("data-value", this.inputBox.value); } else { li.setAttribute("data-value", content); } let i = icon || this.icon; li.innerHTML = `<div class="omn-dropdown-indicator"></div> <a href="${content}"> ${i ? `<img src=\"${i}\"/>` : ""} ${parseOmniboxDescription(description)} </a>`; items.appendChild(li); } dropdown.appendChild(items); if (pagination && this.onFooter) { let footer = this.onFooter(this, pagination); if (footer) { dropdown.appendChild(footer); } } this.container.insertAdjacentElement('afterend', dropdown); } } class OnInputChangedListener { constructor() { this.listeners = []; } addListener(listener) { if (listener) { this.listeners.push(listener); } } } class OnInputEnteredListener { constructor() { this.listeners = []; } addListener(listener) { if (listener) { this.listeners.push(listener); } } } function parseOmniboxDescription(input) { return input.replaceAll("<match>", "<span class='omn-match'>") .replaceAll("</match>", "</span>") .replaceAll("<dim>", "<span class='omn-dim'>") .replaceAll("</dim>", "</span>"); }