// This script is derived from this GitHub project: https://github.com/cadejscroggins/tilde $(function () { const CONFIG = { /* * To invoke the HELP menu, press the "?" key. * NOTE: The HELP menu will NOT appear on mobile browsers. * When using commands you MUST select the result from the suggestions result list below the input field. * Google Search will ALWAYS be used when pressing the ENTER key or clicking search. * If none of the specified keys are matched, the '*' (Google) key is used. * The category, name, key, url, and search path for commands are required. * Commands without a category don't show in the help menu. */ commands: [{ name: 'Google', key: '*', url: 'https://encrypted.google.com', search: '/search?q={}', color: '#1e272e', }, { category: 'Work', name: 'Drive', key: 'd', url: 'https://drive.google.com', search: '/drive/search?q={}', color: 'linear-gradient(135deg, #4285f4, #4259f4)', }, { category: 'Work', name: 'GitHub', key: 'g', url: 'https://github.com', search: '/search?q={}', color: 'linear-gradient(135deg, #2b2b2b, #3b3b3b)', }, { category: 'Work', name: 'Keep', key: 'k', url: 'https://keep.google.com', search: '/#search/text={}', color: 'linear-gradient(135deg, #fca550, #fcd050)', }, { category: 'Lurk', name: 'Hunt', key: 'p', url: 'https://www.producthunt.com', search: '/search?q={}', color: 'linear-gradient(135deg, #da552f, #da802f)', }, { category: 'Lurk', name: 'Reddit', key: 'r', url: 'https://www.reddit.com', search: '/search?q={}', color: 'linear-gradient(135deg, #5f99cf, #5f7dcf)', }, { category: 'Lurk', name: 'Unsplash', key: 'u', url: 'https://unsplash.com/new', search: '/search/{}', color: '#000', }, { category: 'Listen', name: 'Hypem', key: 'h', url: 'https://hypem.com/popular', search: '/search/{}', color: 'linear-gradient(135deg, #83c441, #62c441)', }, { category: 'Listen', name: 'Line Radio', key: 'l', url: 'https://linerad.io', search: '/#{}', color: 'linear-gradient(135deg, #a29bfe, #bb9bfe)', }, { category: 'Listen', name: 'SoundCloud', key: 's', url: 'https://soundcloud.com/discover', search: '/search?q={}', color: 'linear-gradient(135deg, #ff8800, #ffc800)', }, { category: 'Watch', name: 'Netflix', key: 'n', url: 'https://www.netflix.com/browse', search: '/search?q={}', color: 'linear-gradient(135deg, #e50914, #e53509)', }, { category: 'Watch', name: 'Twitch', key: 't', url: 'https://www.twitch.tv/directory/following', color: 'linear-gradient(135deg, #6441a5, #7d41a5)', }, { category: 'Watch', name: 'YouTube', key: 'y', url: 'https://youtube.com/feed/subscriptions', search: '/results?search_query={}', color: 'linear-gradient(135deg, #cd201f, #cd4c1f)', }, { category: 'Learn', name: 'Academy', key: 'a', url: 'https://www.khanacademy.org', search: '/search?page_search_query={}', color: 'linear-gradient(135deg, #9cb443, #80b443)', }, { category: 'Learn', name: 'Coursera', key: 'c', url: 'https://www.coursera.org', search: '/courses?query={}', color: 'linear-gradient(135deg, #407ED7, #4058d7)', }, { category: 'Learn', name: 'Egghead', key: 'e', url: 'https://egghead.io', search: '/search?q={}', color: 'linear-gradient(135deg, #171C23, #171923)', }, { category: 'Download', name: '7digital', key: '7', url: 'https://us.7digital.com', search: '/search?q={}', color: 'linear-gradient(135deg, #07606e, #07466e)', }, { category: 'Download', name: 'Bay', key: 'b', url: 'https://thepiratebay.org', search: '/search/{}', color: 'linear-gradient(135deg, #d2b9a6, #d2c4a6)', }, { category: 'Download', name: 'YTS', key: 'Y', url: 'https://yts.am/browse-movies/all/1080p/all/7/latest', search: '/browse-movies/{}', color: 'linear-gradient(135deg, #2f2f2f, #373737)', }, ], // * Get suggestions "live search" as you type. suggestions: true, suggestionsLimit: 4, /* * The "influencer" is how the live search aggrogates results while typing. * These results come from DuckDuckGo which uses the Google API and will direct the user to Google when a result is clicked. */ influencers: [ { name: 'DuckDuckGo', limit: 4 }, ], /* * Instantly redirect when a key is matched. Put a space before any other * queries to prevent unwanted redirects. */ instantRedirect: false, /* * Open triggered queries in a new tab. */ newTab: false, /* * The delimiter between a command key and your search query. For example, * to search GitHub for potatoes, you'd type "g:potatoes". */ searchDelimiter: ':', /* * The delimiter between a command key and a path. For example, you'd type * "r/r/unixporn" to go to "https://reddit.com/r/unixporn". */ pathDelimiter: '/', }; const $ = { bodyClassAdd: c => $.el('body').classList.add(c), bodyClassRemove: c => $.el('body').classList.remove(c), el: s => document.querySelector(s), els: s => [].slice.call(document.querySelectorAll(s) || []), escapeRegex: s => s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'), flattenAndUnique: arr => [...new Set([].concat.apply([], arr))], ieq: (a, b) => a.toLowerCase() === b.toLowerCase(), iin: (a, b) => a.toLowerCase().indexOf(b.toLowerCase()) !== -1, isDown: e => ['c-n', 'down', 'tab'].includes($.key(e)), isRemove: e => ['backspace', 'delete'].includes($.key(e)), isUp: e => ['c-p', 'up', 's-tab'].includes($.key(e)), jsonp: url => { let script = document.createElement('script'); script.src = url; $.el('head').appendChild(script); }, key: e => { const ctrl = e.ctrlKey; const shift = e.shiftKey; switch (e.which) { case 8: return 'backspace'; case 9: return shift ? 's-tab' : 'tab'; case 13: return 'enter'; case 16: return 'shift'; case 17: return 'ctrl'; case 18: return 'alt'; case 27: return 'escape'; case 38: return 'up'; case 40: return 'down'; case 46: return 'delete'; case 78: return ctrl ? 'c-n' : 'n'; case 80: return ctrl ? 'c-p' : 'p'; case 91: case 93: case 224: return 'super'; } }, pad: v => ('0' + v.toString()).slice(-2), }; class Help { constructor(options) { this._el = $.el('#help'); this._commands = options.commands; this._newTab = options.newTab; this._toggled = false; this._handleKeydown = this._handleKeydown.bind(this); this.toggle = this.toggle.bind(this); this._buildAndAppendLists(); this._registerEvents(); } toggle(show) { this._toggled = typeof show !== 'undefined' ? show : !this._toggled; this._toggled ? $.bodyClassAdd('help') : $.bodyClassRemove('help'); } _buildAndAppendLists() { const lists = document.createElement('ul'); lists.classList.add('categories'); this._getCategories().forEach(category => { lists.insertAdjacentHTML( 'beforeend', `
  • ${category}

  • ` ); }); this._el.appendChild(lists); } _buildListCommands(currentCategory) { return this._commands .map(({ category, name, key, url }) => { if (category === currentCategory) { return `
  • ${key} ${name}
  • `; } }) .join(''); } _getCategories() { const categories = this._commands .map(v => v.category) .filter(category => category); return [...new Set(categories)]; } _handleKeydown(e) { if ($.key(e) === 'escape') this.toggle(false); } _registerEvents() { document.addEventListener('keydown', this._handleKeydown); } } class Influencer { constructor(options) { this._limit = options.limit; this._parseQuery = options.parseQuery; } addItem() {} getSuggestions() {} _addSearchPrefix(items, query) { const searchPrefix = this._getSearchPrefix(query); return items.map(s => (searchPrefix ? searchPrefix + s : s)); } _getSearchPrefix(query) { const { isSearch, key, split } = this._parseQuery(query); return isSearch ? `${key}${split} ` : false; } } class DuckDuckGoInfluencer extends Influencer { constructor({ queryParser }) { super(...arguments); } getSuggestions(rawQuery) { const { query } = this._parseQuery(rawQuery); if (!query) return Promise.resolve([]); return new Promise(resolve => { const endpoint = 'https://duckduckgo.com/ac/'; const callback = 'autocompleteCallback'; window[callback] = res => { const suggestions = res .map(i => i.phrase) .filter(s => !$.ieq(s, query)) .slice(0, this._limit); resolve(this._addSearchPrefix(suggestions, rawQuery)); }; $.jsonp(`${endpoint}?callback=${callback}&q=${query}`); }); } }; class Suggester { constructor(options) { this._el = $.el('#search-suggestions'); this._enabled = options.enabled; this._influencers = options.influencers; this._limit = options.limit; this._suggestionEls = []; this._handleKeydown = this._handleKeydown.bind(this); this._registerEvents(); } setOnClick(callback) { this._onClick = callback; } setOnHighlight(callback) { this._onHighlight = callback; } setOnUnhighlight(callback) { this._onUnhighlight = callback; } success(query) { this._clearSuggestions(); this._influencers.forEach(i => i.addItem(query)); } suggest(input) { if (!this._enabled) return; input = input.trim(); if (input === '') this._clearSuggestions(); Promise.all(this._getInfluencerPromises(input)).then(res => { const suggestions = $.flattenAndUnique(res); this._clearSuggestions(); if (suggestions.length) { this._appendSuggestions(suggestions, input); this._registerSuggestionHighlightEvents(); this._registerSuggestionClickEvents(); $.bodyClassAdd('suggestions'); } }); } _appendSuggestions(suggestions, input) { suggestions.some((suggestion, i) => { const match = new RegExp($.escapeRegex(input), 'ig'); const suggestionHtml = suggestion.replace(match, `${input}`); this._el.insertAdjacentHTML( 'beforeend', `
  • ` ); if (i + 1 >= this._limit) return true; }); this._suggestionEls = $.els('.js-search-suggestion'); } _clearSuggestionClickEvents() { this._suggestionEls.forEach(el => { el.removeEventListener('click', this._onClick); }); } _clearSuggestionHighlightEvents() { this._suggestionEls.forEach(el => { el.removeEventListener('mouseover', this._highlight); el.removeEventListener('mouseout', this._unHighlight); }); } _clearSuggestions() { $.bodyClassRemove('suggestions'); this._clearSuggestionHighlightEvents(); this._clearSuggestionClickEvents(); this._suggestionEls = []; this._el.innerHTML = ''; } _focusNext(e) { const exists = this._suggestionEls.some((el, i) => { if (el.classList.contains('highlight')) { this._highlight(this._suggestionEls[i + 1], e); return true; } }); if (!exists) this._highlight(this._suggestionEls[0], e); } _focusPrevious(e) { const exists = this._suggestionEls.some((el, i) => { if (el.classList.contains('highlight') && i) { this._highlight(this._suggestionEls[i - 1], e); return true; } }); if (!exists) this._unHighlight(e); } _getInfluencerPromises(input) { return this._influencers.map(influencer => influencer.getSuggestions(input) ); } _handleKeydown(e) { if ($.isDown(e)) this._focusNext(e); if ($.isUp(e)) this._focusPrevious(e); } _highlight(el, e) { this._unHighlight(); if (!el) return; this._onHighlight(el.getAttribute('data-suggestion')); el.classList.add('highlight'); e.preventDefault(); } _registerEvents() { document.addEventListener('keydown', this._handleKeydown); } _registerSuggestionClickEvents() { this._suggestionEls.forEach(el => { const value = el.getAttribute('data-suggestion'); el.addEventListener('click', this._onClick.bind(null, value)); }); } _registerSuggestionHighlightEvents() { const noHighlightUntilMouseMove = () => { window.removeEventListener('mousemove', noHighlightUntilMouseMove); this._suggestionEls.forEach(el => { el.addEventListener('mouseover', this._highlight.bind(this, el)); el.addEventListener('mouseout', this._unHighlight.bind(this)); }); }; window.addEventListener('mousemove', noHighlightUntilMouseMove); } _unHighlight(e) { const el = $.el('.highlight'); if (!el) return; this._onUnhighlight(); el.classList.remove('highlight'); if (e) e.preventDefault(); } }; class QueryParser { constructor(options) { this._commands = options.commands; this._searchDelimiter = options.searchDelimiter; this._pathDelimiter = options.pathDelimiter; this._protocolRegex = /^[a-zA-Z]+:\/\//i; this._urlRegex = /^((https?:\/\/)?[\w-]+(\.[\w-]+)+\.?(:\d+)?(\/\S*)?)$/i; this.parse = this.parse.bind(this); } parse(query) { const res = { query: query, split: null }; if (this._urlRegex.test(query)) { const hasProtocol = this._protocolRegex.test(query); res.redirect = hasProtocol ? query : 'http://' + query; } else { const trimmed = query.trim(); const splitSearch = trimmed.split(this._searchDelimiter); const splitPath = trimmed.split(this._pathDelimiter); this._commands.some(({ category, key, name, search, url }) => { if (query === key) { res.key = key; res.isKey = true; res.redirect = url; return true; } if (splitSearch[0] === key && search) { res.key = key; res.isSearch = true; res.split = this._searchDelimiter; res.query = QueryParser._shiftAndTrim(splitSearch, res.split); res.redirect = QueryParser._prepSearch(url, search, res.query); return true; } if (splitPath[0] === key) { res.key = key; res.isPath = true; res.split = this._pathDelimiter; res.path = QueryParser._shiftAndTrim(splitPath, res.split); res.redirect = QueryParser._prepPath(url, res.path); return true; } if (key === '*') { res.redirect = QueryParser._prepSearch(url, search, query); } }); } res.color = QueryParser._getColorFromUrl(this._commands, res.redirect); return res; } static _getColorFromUrl(commands, url) { const domain = new URL(url).hostname; return ( commands .filter(c => new URL(c.url).hostname.includes(domain)) .map(c => c.color)[0] || null ); } static _prepPath(url, path) { return QueryParser._stripUrlPath(url) + '/' + path; } static _prepSearch(url, searchPath, query) { if (!searchPath) return url; const baseUrl = QueryParser._stripUrlPath(url); const urlQuery = encodeURIComponent(query); searchPath = searchPath.replace('{}', urlQuery); return baseUrl + searchPath; } static _shiftAndTrim(arr, delimiter) { arr.shift(); return arr.join(delimiter).trim(); } static _stripUrlPath(url) { const parser = document.createElement('a'); parser.href = url; return `${parser.protocol}//${parser.hostname}`; } }; class Form { constructor(options) { this._formEl = $.el('#formstretch'); this._inputEl = $.el('#flexbox-input'); this._inputElVal = ''; this._instantRedirect = options.instantRedirect; this._newTab = options.newTab; this._parseQuery = options.parseQuery; this._suggester = options.suggester; this._toggleHelp = options.toggleHelp; this._clearPreview = this._clearPreview.bind(this); this._handleInput = this._handleInput.bind(this); this._handleKeydown = this._handleKeydown.bind(this); this._previewValue = this._previewValue.bind(this); this._submitForm = this._submitForm.bind(this); this._submitWithValue = this._submitWithValue.bind(this); this.hide = this.hide.bind(this); this.show = this.show.bind(this); this._registerEvents(); this._loadQueryParam(); } hide() { $.bodyClassRemove('form'); this._inputEl.value = ''; this._inputElVal = ''; this._suggester.suggest(''); } show() { $.bodyClassAdd('form'); this._inputEl.focus(); } _clearPreview() { this._previewValue(this._inputElVal); this._inputEl.focus(); } _handleInput() { const newQuery = this._inputEl.value; const isHelp = newQuery === '?'; const { isKey } = this._parseQuery(newQuery); this._inputElVal = newQuery; this._suggester.suggest(newQuery); if (!newQuery || isHelp) this.hide(); if (isHelp) this._toggleHelp(); if (this._instantRedirect && isKey) this._submitWithValue(newQuery); } _handleKeydown(e) { if ($.isUp(e) || $.isDown(e) || $.isRemove(e)) return; switch ($.key(e)) { case 'alt': case 'ctrl': case 'enter': case 'shift': case 'super': return; case 'escape': this.hide(); return; } this.show(); } _loadQueryParam() { const q = new URLSearchParams(window.location.search).get('q'); if (q) this._submitWithValue(q); } _previewValue(value) { this._inputEl.value = value; } _redirect(redirect) { if (this._newTab) window.open(redirect, '_blank'); else window.location.href = redirect; } _registerEvents() { // document.addEventListener('keydown', this._handleKeydown); this._inputEl.addEventListener('input', this._handleInput); this._formEl.addEventListener('submit', this._submitForm, false); if (this._suggester) { this._suggester.setOnClick(this._submitWithValue); this._suggester.setOnHighlight(this._previewValue); this._suggester.setOnUnhighlight(this._clearPreview); } } _submitForm(e) { if (e) e.preventDefault(); const query = this._inputEl.value; if (this._suggester) this._suggester.success(query); this.hide(); this._redirect(this._parseQuery(query).redirect); } _submitWithValue(value) { this._inputEl.value = value; this._submitForm(); } }; const queryParser = new QueryParser({ commands: CONFIG.commands, pathDelimiter: CONFIG.pathDelimiter, searchDelimiter: CONFIG.searchDelimiter, }); const influencers = CONFIG.influencers.map(influencerConfig => { return new { DuckDuckGo: DuckDuckGoInfluencer, } [influencerConfig.name]({ limit: influencerConfig.limit, parseQuery: queryParser.parse, }); }); const suggester = new Suggester({ enabled: CONFIG.suggestions, influencers, limit: CONFIG.suggestionsLimit, }); const help = new Help({ commands: CONFIG.commands, newTab: CONFIG.newTab, }); const form = new Form({ instantRedirect: CONFIG.instantRedirect, newTab: CONFIG.newTab, parseQuery: queryParser.parse, suggester, toggleHelp: help.toggle, }); });