From 1b5907f04e8a1e3c7d3675e565390c5a5e325673 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96zen=C3=A7=20Bilgili?= Date: Fri, 31 Jul 2020 11:25:34 +0300 Subject: [PATCH] Refactor and split code --- index.html | 12 +- js/body.js | 59 +++ js/clock.js | 35 ++ js/config.js | 219 +++++++++ js/form.js | 164 +++++++ js/help.js | 151 ++++++ js/index.js | 64 +++ js/influencers.js | 168 +++++++ js/queryParser.js | 90 ++++ js/script.js | 1120 --------------------------------------------- js/suggester.js | 165 +++++++ 11 files changed, 1126 insertions(+), 1121 deletions(-) create mode 100644 js/body.js create mode 100644 js/clock.js create mode 100644 js/config.js create mode 100644 js/form.js create mode 100644 js/help.js create mode 100644 js/index.js create mode 100644 js/influencers.js create mode 100644 js/queryParser.js delete mode 100644 js/script.js create mode 100644 js/suggester.js diff --git a/index.html b/index.html index b46c5f4..81e51f7 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,7 @@ + Home @@ -19,8 +20,17 @@ + - + + + + + + + + + \ No newline at end of file diff --git a/js/body.js b/js/body.js new file mode 100644 index 0000000..186df17 --- /dev/null +++ b/js/body.js @@ -0,0 +1,59 @@ +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 ctrl ? 'c-enter' : '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 189: + return 'dash'; + case 91: + case 93: + case 224: + return 'super'; + } + }, + + pad: v => ('0' + v.toString()).slice(-2), + }; \ No newline at end of file diff --git a/js/clock.js b/js/clock.js new file mode 100644 index 0000000..df9c075 --- /dev/null +++ b/js/clock.js @@ -0,0 +1,35 @@ +class Clock { + constructor(options) { + this._el = $.el('#clock'); + this._delimiter = options.delimiter; + this._twentyFourHourClock = options.twentyFourHourClock; + this._setTime = this._setTime.bind(this); + this._el.addEventListener('click', options.toggleHelp); + this._start(); + } + + _setTime() { + const date = new Date(); + let hours = $.pad(date.getHours()); + let amPm = ''; + + if (!this._twentyFourHourClock) { + hours = date.getHours(); + if (hours > 12) hours -= 12; + else if (hours === 0) hours = 12; + + amPm = + ` ` + + `${date.getHours() >= 12 ? 'PM' : 'AM'}`; + } + + const minutes = $.pad(date.getMinutes()); + this._el.innerHTML = `${hours}${this._delimiter}${minutes}${amPm}`; + this._el.setAttribute('datetime', date.toTimeString()); + } + + _start() { + this._setTime(); + setInterval(this._setTime, 1000); + } + } \ No newline at end of file diff --git a/js/config.js b/js/config.js new file mode 100644 index 0000000..fd9a251 --- /dev/null +++ b/js/config.js @@ -0,0 +1,219 @@ +let CONFIG = { + /** + * The category, name, key, url, search path, color, icon, and quicklaunch properties for your commands. + * Icons must be added to "icons" folder and their values/names must be updated. + * If none of the specified keys are matched, the '*' key is used. + * Commands without a category don't show up in the help menu. + * Update line 11 and 13 if you prefer using Google. + */ + commands: [ + { + name: 'Duckduckgo', + key: '*', + url: 'https://duckduckgo.com', + search: '/?q={}' + }, + { + category: 'General', + name: 'Mail', + key: 'm', + url: 'https://gmail.com', + search: '/#search/text={}', + color: 'linear-gradient(135deg, #dd5145, #dd5145)', + icon: 'mail', + quickLaunch: true, + }, + { + category: 'General', + name: 'Drive', + key: 'd', + url: 'https://drive.google.com', + search: '/drive/search?q={}', + color: 'linear-gradient(135deg, #FFD04B, #1EA362, #4688F3)', + icon: 'drive', + quickLaunch: false, + }, + { + category: 'General', + name: 'LinkedIn', + key: 'l', + url: 'https://linkedin.com', + search: '/search/results/all/?keywords={}', + color: 'linear-gradient(135deg, #006CA4, #0077B5)', + icon: 'linkedin', + quickLaunch: true, + }, + { + category: 'Tech', + name: 'GitHub', + key: 'g', + url: 'https://github.com', + search: '/search?q={}', + color: 'linear-gradient(135deg, #2b2b2b, #3b3b3b)', + icon: 'github', + quickLaunch: true, + }, + { + category: 'Tech', + name: 'StackOverflow', + key: 's', + url: 'https://stackoverflow.com', + search: '/search?q={}', + color: 'linear-gradient(135deg, #53341C, #F48024)', + icon: 'stackoverflow', + quickLaunch: true, + }, + { + category: 'Tech', + name: 'HackerNews', + key: 'h', + url: 'https://news.ycombinator.com/', + search: '/search?stories[query]={}', + color: 'linear-gradient(135deg, #FF6600, #DC5901)', + icon: 'hackernews', + quickLaunch: true, + }, + { + category: 'Fun', + name: 'YouTube', + key: 'y', + url: 'https://youtube.com', + search: '/results?search_query={}', + color: 'linear-gradient(135deg, #cd201f, #cd4c1f)', + icon: 'youtube', + quickLaunch: false, + }, + { + category: 'Fun', + name: 'Netflix', + key: 'n', + url: 'https://www.netflix.com', + color: 'linear-gradient(135deg, #E50914, #CB020C)', + icon: 'netflix', + quickLaunch: false, + }, + { + category: 'Fun', + name: 'Twitch', + key: 'tw', + url: 'https://www.twitch.tv', + search: '/directory/game/{}', + color: 'linear-gradient(135deg, #6441a5, #4b367c)', + icon: 'twitch', + quickLaunch: false, + }, + { + category: 'Other', + name: 'Reddit', + key: 'r', + url: 'https://reddit.com', + search: '/search?q={}', + color: 'linear-gradient(135deg, #FF8456, #FF4500)', + icon: 'reddit', + quickLaunch: false, + }, + { + category: 'Other', + name: 'Twitter', + key: 't', + url: 'https://twitter.com', + search: '/search?q={}&src=typed_query', + color: 'linear-gradient(135deg, #1DA1F2, #19608F)', + icon: 'twitter', + quickLaunch: true, + }, + { + category: 'Other', + name: 'IMDb', + key: 'i', + url: 'https://imdb.com', + search: '/find?ref_=nv_sr_fn&q={}', + color: 'linear-gradient(135deg, #7A5F00, #E8B708)', + icon: 'imdb', + quickLaunch: false, + }, + ], + + /** + * Get suggestions as you type. + */ + suggestions: true, + suggestionsLimit: 4, + + /** + * The order and limit for each suggestion influencer. An "influencer" is + * just a suggestion source. The following influencers are available: + * + * - "Commands" suggestions come from CONFIG.commands + * - "Default" suggestions come from CONFIG.defaultSuggestions + * - "DuckDuckGo" suggestions come from the duck duck go search api + * - "History" suggestions come from your previously entered queries + */ + influencers: [ + { name: 'Commands', limit: 2}, + { name: 'Default', limit: 4 }, + { name: 'History', limit: 1 }, + { name: 'DuckDuckGo', limit: 4 }, + ], + + /** + * Default search suggestions for the specified queries. + */ + defaultSuggestions: { + g: ['g/issues', 'g/pulls', 'gist.github.com'], + r: ['r/r/unixporn', 'r/r/startpages', 'r/r/webdev', 'r/r/technology'], + }, + + /** + * 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, + + /** + * Dynamic overlay background colors when command domains are matched. + */ + colors: true, + + /** + * Invert color theme + */ + invertedColors: false, + + /** + * Show keys instead of icons + */ + showKeys: 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: '/', + + /** + * The delimiter between the hours and minutes on the clock. + */ + clockDelimiter: ' ', + + /** + * Show a twenty-four-hour clock instead of a twelve-hour clock with AM/PM. + */ + twentyFourHourClock: true, + + /** + * File extension for icon images + */ + iconExtension: 'png' + }; \ No newline at end of file diff --git a/js/form.js b/js/form.js new file mode 100644 index 0000000..c0c3aac --- /dev/null +++ b/js/form.js @@ -0,0 +1,164 @@ +class Form { + constructor(options) { + this._colors = options.colors; + this._formEl = $.el('#search-form'); + this._inputEl = $.el('#search-input'); + this._inputElVal = ''; + this._instantRedirect = options.instantRedirect; + this._newTab = options.newTab; + this._parseQuery = options.parseQuery; + this._suggester = options.suggester; + this._toggleHelp = options.toggleHelp; + this._quickLaunch = options.quickLaunch; + this._categoryLaunch = options.categoryLaunch; + 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._invertColors = options.invertedColors; + this.hide = this.hide.bind(this); + this.show = this.show.bind(this); + this._registerEvents(); + this._loadQueryParam(); + this.invert(); + this.isCtrlEnter = false; + } + + hide() { + $.bodyClassRemove('form'); + this._inputEl.value = ''; + this._inputElVal = ''; + this._suggester.suggest(''); + this._setBackgroundFromQuery(''); + } + + show() { + $.bodyClassAdd('form'); + this._inputEl.focus(); + } + + invert() { + if (this._invertColors) { + const bgcolor = getComputedStyle(document.documentElement).getPropertyValue('--background'); + const fgcolor = getComputedStyle(document.documentElement).getPropertyValue('--foreground'); + document.documentElement.style.setProperty('--background', fgcolor); + document.documentElement.style.setProperty('--foreground', bgcolor); + } + } + + _invertConfig() { + let isInverted = !CONFIG.invertedColors; + localStorage.removeItem('invertColorCookie'); + localStorage.setItem('invertColorCookie', JSON.stringify(isInverted)); + location.reload(); + } + + _showKeysConfig() { + let isShowKeys = !CONFIG.showKeys; + localStorage.removeItem('showKeysCookie'); + localStorage.setItem('showKeysCookie', JSON.stringify(isShowKeys)); + location.reload(); + } + + _clearPreview() { + this._previewValue(this._inputElVal); + this._inputEl.focus(); + } + + _isCategoryLaunch(num){ + if(/^\d/.test(num[0]) && num[1] === '!'){ + return true + } else { + return false; + } + } + + _handleInput() { + const newQuery = this._inputEl.value; + const isHelp = newQuery === '?'; + const isLaunch = newQuery === 'q!'; + const isInvert = newQuery === 'invert!'; + const isShowKeys = newQuery === 'keys!'; + const isCategoryLaunch = this._isCategoryLaunch(newQuery); + const { isKey } = this._parseQuery(newQuery); + this._inputElVal = newQuery; + this._suggester.suggest(newQuery); + this._setBackgroundFromQuery(newQuery); + if (!newQuery || isHelp) this.hide(); + if (isHelp) this._toggleHelp(); + if (isLaunch) this._quickLaunch(); + if (isInvert) this._invertConfig(); + if (isShowKeys) this._showKeysConfig(); + if (isCategoryLaunch) this._categoryLaunch(); + 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; + case 'c-enter': + this.isCtrlEnter = true; + } + + this.show(); + } + + _loadQueryParam() { + const q = new URLSearchParams(window.location.search).get('q'); + if (q) this._submitWithValue(q); + } + + _previewValue(value) { + this._inputEl.value = value; + this._setBackgroundFromQuery(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); + } + } + + _setBackgroundFromQuery(query) { + if (!this._colors) return; + this._formEl.style.background = this._parseQuery(query).color; + } + + _submitForm(e) { + if (e) e.preventDefault(); + let query = this._inputEl.value; + if (this._suggester) this._suggester.success(query); + this.hide(); + if (this.isCtrlEnter) query += '.com'; + this._redirect(this._parseQuery(query).redirect); + } + + _submitWithValue(value) { + this._inputEl.value = value; + this._submitForm(); + } + } \ No newline at end of file diff --git a/js/help.js b/js/help.js new file mode 100644 index 0000000..5c3bc42 --- /dev/null +++ b/js/help.js @@ -0,0 +1,151 @@ +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.launch = this.launch.bind(this); + this.launchCategory = this.launchCategory.bind(this); + this._inputEl = $.el('#search-input'); + this._inputElVal = ''; + this._suggester = options.suggester; + this._invertColors = options.invertedColors; + this._buildAndAppendLists(); + this._registerEvents(); + this._invertValue; + } + + toggle(show) { + this._toggled = typeof show !== 'undefined' ? show : !this._toggled; + this._toggled ? $.bodyClassAdd('help') : $.bodyClassRemove('help'); + } + + hide() { + $.bodyClassRemove('form'); + this._inputEl.value = ''; + this._inputElVal = ''; + this._suggester.suggest(''); + } + + launch() { + this.hide(); + this.toggle(true); + $.bodyClassAdd('help'); + + CONFIG.commands.forEach(command => { + if(command.quickLaunch) window.open(command.url); + }); + } + + launchCategory(){ + + const categorySet = new Set(); + + CONFIG.commands.forEach(command => { + if(command.category) categorySet.add(command.category); + }); + + const targetCategoryIndex = $.el('#search-input').value.replace('!', ''); + const targetCategory = Array.from(categorySet)[targetCategoryIndex - 1]; + + CONFIG.commands.forEach(command => { + if(targetCategory && command.category === targetCategory) window.open(command.url); + }); + } + + _buildAndAppendLists() { + const lists = document.createElement('ul'); + lists.classList.add('categories'); + + this._getCategories().forEach(category => { + lists.insertAdjacentHTML( + 'beforeend', + `
  • +

    ${category}

    + +
  • ` + ); + }); + + this._el.appendChild(lists); + } + + _buildListCommands(currentCategory) { + let invertValue = this._invertColors ? 1: 0; + + const bgcolor = invertValue ? getComputedStyle(document.documentElement).getPropertyValue('--foreground') + : getComputedStyle(document.documentElement).getPropertyValue('--background'); + + const fgcolor = invertValue ? getComputedStyle(document.documentElement).getPropertyValue('--background') + : getComputedStyle(document.documentElement).getPropertyValue('--foreground'); + + + const commandListWithIcons = this._commands + .map(({ category, name, key, url, icon }, i) => { + const iconEl = CONFIG.iconExtension !== 'svg' + ? `` + : `` + + if (category === currentCategory) { + return ` + +
  • + + ${iconEl} + ${name} + +
  • + `; + } + }) + .join(''); + + const commandListWithKeys = this._commands + .map(({ category, name, key, url }, i) => { + if (category === currentCategory) { + return ` +
  • + + + ${key} + ${name} + +
  • + `; + } + }) + .join(''); + + return CONFIG.showKeys ? commandListWithKeys : commandListWithIcons; + } + + _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); + } + } \ No newline at end of file diff --git a/js/index.js b/js/index.js new file mode 100644 index 0000000..2998494 --- /dev/null +++ b/js/index.js @@ -0,0 +1,64 @@ +// Get invertedColors preference from cookies +CONFIG.invertedColors = localStorage.getItem('invertColorCookie') ? + JSON.parse(localStorage.getItem('invertColorCookie')) : + CONFIG.invertedColors; + +// Get showKeys preference from cookies +CONFIG.showKeys = localStorage.getItem('showKeysCookie') ? + JSON.parse(localStorage.getItem('showKeysCookie')) : + CONFIG.showKeys; + + + +const queryParser = new QueryParser({ + commands: CONFIG.commands, + pathDelimiter: CONFIG.pathDelimiter, + searchDelimiter: CONFIG.searchDelimiter, +}); + +const influencers = CONFIG.influencers.map(influencerConfig => { + return new { + Default: DefaultInfluencer, + Commands: CommandsInfluencer, + DuckDuckGo: DuckDuckGoInfluencer, + History: HistoryInfluencer, + } [influencerConfig.name]({ + defaultSuggestions: CONFIG.defaultSuggestions, + limit: influencerConfig.limit, + parseQuery: queryParser.parse, + commands: CONFIG.commands + }); +}); + +const suggester = new Suggester({ + enabled: CONFIG.suggestions, + influencers, + limit: CONFIG.suggestionsLimit, +}); + +const help = new Help({ + commands: CONFIG.commands, + newTab: CONFIG.newTab, + suggester, + invertedColors: CONFIG.invertedColors, + showKeys: CONFIG.showKeys +}); + +const form = new Form({ + colors: CONFIG.colors, + instantRedirect: CONFIG.instantRedirect, + newTab: CONFIG.newTab, + parseQuery: queryParser.parse, + suggester, + toggleHelp: help.toggle, + quickLaunch: help.launch, + categoryLaunch: help.launchCategory, + invertedColors: CONFIG.invertedColors, + showKeys: CONFIG.showKeys +}); + +new Clock({ + delimiter: CONFIG.clockDelimiter, + toggleHelp: help.toggle, + twentyFourHourClock: CONFIG.twentyFourHourClock, +}); \ No newline at end of file diff --git a/js/influencers.js b/js/influencers.js new file mode 100644 index 0000000..d8200a6 --- /dev/null +++ b/js/influencers.js @@ -0,0 +1,168 @@ +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 DefaultInfluencer extends Influencer { + constructor({ defaultSuggestions }) { + super(...arguments); + this._defaultSuggestions = defaultSuggestions; + } + + getSuggestions(query) { + return new Promise(resolve => { + const suggestions = this._defaultSuggestions[query]; + resolve(suggestions ? suggestions.slice(0, this._limit) : []); + }); + } + } + + +class CommandsInfluencer extends Influencer { + constructor({ commands, queryParser }) { + super(...arguments); + this._commands = commands; + } + + getSuggestions(rawQuery) { + const { query } = this._parseQuery(rawQuery); + if (!query) return Promise.resolve([]); + + return new Promise(resolve => { + const suggestions = []; + const commands = this._commands; + + commands.forEach(command => { + if(this._getDomain(command.url).startsWith(rawQuery)){ + suggestions.push(command.url); + } + }); + + resolve(suggestions); + }); + } + + _getHostName(url) { + let match = url.match(/:\/\/(www[0-9]?\.)?(.[^/:]+)/i); + if (match != null && match.length > 2 && typeof match[2] === 'string' && match[2].length > 0) { + return match[2]; + } + else { + return null; + } + } + + _getDomain(url){ + let hostName = this._getHostName(url); + let domain = hostName; + + if (hostName != null) { + let parts = hostName.split('.').reverse(); + if (parts != null && parts.length > 1) { + domain = parts[1] + '.' + parts[0]; + if (hostName.toLowerCase().indexOf('.co.uk') != -1 && parts.length > 2) { + domain = parts[2] + '.' + domain; + } + } + } + + return domain; + } +} + +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 HistoryInfluencer extends Influencer { + constructor() { + super(...arguments); + this._storeName = 'history'; + } + + addItem(query) { + if (query.length < 2) return; + let exists; + + const history = this._getHistory().map(([item, count]) => { + const match = $.ieq(item, query); + if (match) exists = true; + return [item, match ? count + 1 : count]; + }); + + if (!exists) history.push([query, 1]); + + const sorted = history + .sort((current, next) => current[1] - next[1]) + .reverse(); + + this._setHistory(sorted); + } + + getSuggestions(query) { + return new Promise(resolve => { + const suggestions = this._getHistory() + .map(([item]) => item) + .filter(item => query && !$.ieq(item, query) && $.iin(item, query)) + .slice(0, this._limit); + + resolve(suggestions); + }); + } + + _fetch() { + return JSON.parse(localStorage.getItem(this._storeName)) || []; + } + + _getHistory() { + this._history = this._history || this._fetch(); + return this._history; + } + + _save(history) { + localStorage.setItem(this._storeName, JSON.stringify(history)); + } + + _setHistory(history) { + this._history = history; + this._save(history); + } +} \ No newline at end of file diff --git a/js/queryParser.js b/js/queryParser.js new file mode 100644 index 0000000..5be7cc8 --- /dev/null +++ b/js/queryParser.js @@ -0,0 +1,90 @@ +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}`; + } + } \ No newline at end of file diff --git a/js/script.js b/js/script.js deleted file mode 100644 index e69ee9b..0000000 --- a/js/script.js +++ /dev/null @@ -1,1120 +0,0 @@ -let CONFIG = { - /** - * The category, name, key, url, search path, color, icon, and quicklaunch properties for your commands. - * Icons must be added to "icons" folder and their values/names must be updated. - * If none of the specified keys are matched, the '*' key is used. - * Commands without a category don't show up in the help menu. - * Update line 11 and 13 if you prefer using Google. - */ - commands: [ - { - name: 'Duckduckgo', - key: '*', - url: 'https://duckduckgo.com', - search: '/?q={}' - }, - { - category: 'General', - name: 'Mail', - key: 'm', - url: 'https://gmail.com', - search: '/#search/text={}', - color: 'linear-gradient(135deg, #dd5145, #dd5145)', - icon: 'mail', - quickLaunch: true, - }, - { - category: 'General', - name: 'Drive', - key: 'd', - url: 'https://drive.google.com', - search: '/drive/search?q={}', - color: 'linear-gradient(135deg, #FFD04B, #1EA362, #4688F3)', - icon: 'drive', - quickLaunch: false, - }, - { - category: 'General', - name: 'LinkedIn', - key: 'l', - url: 'https://linkedin.com', - search: '/search/results/all/?keywords={}', - color: 'linear-gradient(135deg, #006CA4, #0077B5)', - icon: 'linkedin', - quickLaunch: true, - }, - { - category: 'Tech', - name: 'GitHub', - key: 'g', - url: 'https://github.com', - search: '/search?q={}', - color: 'linear-gradient(135deg, #2b2b2b, #3b3b3b)', - icon: 'github', - quickLaunch: true, - }, - { - category: 'Tech', - name: 'StackOverflow', - key: 's', - url: 'https://stackoverflow.com', - search: '/search?q={}', - color: 'linear-gradient(135deg, #53341C, #F48024)', - icon: 'stackoverflow', - quickLaunch: true, - }, - { - category: 'Tech', - name: 'HackerNews', - key: 'h', - url: 'https://news.ycombinator.com/', - search: '/search?stories[query]={}', - color: 'linear-gradient(135deg, #FF6600, #DC5901)', - icon: 'hackernews', - quickLaunch: true, - }, - { - category: 'Fun', - name: 'YouTube', - key: 'y', - url: 'https://youtube.com', - search: '/results?search_query={}', - color: 'linear-gradient(135deg, #cd201f, #cd4c1f)', - icon: 'youtube', - quickLaunch: false, - }, - { - category: 'Fun', - name: 'Netflix', - key: 'n', - url: 'https://www.netflix.com', - color: 'linear-gradient(135deg, #E50914, #CB020C)', - icon: 'netflix', - quickLaunch: false, - }, - { - category: 'Fun', - name: 'Twitch', - key: 'tw', - url: 'https://www.twitch.tv', - search: '/directory/game/{}', - color: 'linear-gradient(135deg, #6441a5, #4b367c)', - icon: 'twitch', - quickLaunch: false, - }, - { - category: 'Other', - name: 'Reddit', - key: 'r', - url: 'https://reddit.com', - search: '/search?q={}', - color: 'linear-gradient(135deg, #FF8456, #FF4500)', - icon: 'reddit', - quickLaunch: false, - }, - { - category: 'Other', - name: 'Twitter', - key: 't', - url: 'https://twitter.com', - search: '/search?q={}&src=typed_query', - color: 'linear-gradient(135deg, #1DA1F2, #19608F)', - icon: 'twitter', - quickLaunch: true, - }, - { - category: 'Other', - name: 'IMDb', - key: 'i', - url: 'https://imdb.com', - search: '/find?ref_=nv_sr_fn&q={}', - color: 'linear-gradient(135deg, #7A5F00, #E8B708)', - icon: 'imdb', - quickLaunch: false, - }, - ], - - /** - * Get suggestions as you type. - */ - suggestions: true, - suggestionsLimit: 4, - - /** - * The order and limit for each suggestion influencer. An "influencer" is - * just a suggestion source. The following influencers are available: - * - * - "Commands" suggestions come from CONFIG.commands - * - "Default" suggestions come from CONFIG.defaultSuggestions - * - "DuckDuckGo" suggestions come from the duck duck go search api - * - "History" suggestions come from your previously entered queries - */ - influencers: [ - { name: 'Commands', limit: 2}, - { name: 'Default', limit: 4 }, - { name: 'History', limit: 1 }, - { name: 'DuckDuckGo', limit: 4 }, - ], - - /** - * Default search suggestions for the specified queries. - */ - defaultSuggestions: { - g: ['g/issues', 'g/pulls', 'gist.github.com'], - r: ['r/r/unixporn', 'r/r/startpages', 'r/r/webdev', 'r/r/technology'], - }, - - /** - * 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, - - /** - * Dynamic overlay background colors when command domains are matched. - */ - colors: true, - - /** - * Invert color theme - */ - invertedColors: false, - - /** - * Show keys instead of icons - */ - showKeys: 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: '/', - - /** - * The delimiter between the hours and minutes on the clock. - */ - clockDelimiter: ' ', - - /** - * Show a twenty-four-hour clock instead of a twelve-hour clock with AM/PM. - */ - twentyFourHourClock: true, - - /** - * File extension for icon images - */ - iconExtension: 'png' -}; - -// Get invertedColors preference from cookies -CONFIG.invertedColors = localStorage.getItem('invertColorCookie') - ? JSON.parse(localStorage.getItem('invertColorCookie')) - : CONFIG.invertedColors; - -// Get showKeys preference from cookies -CONFIG.showKeys = localStorage.getItem('showKeysCookie') - ? JSON.parse(localStorage.getItem('showKeysCookie')) - : CONFIG.showKeys; - -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 ctrl ? 'c-enter' : '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 189: - return 'dash'; - case 91: - case 93: - case 224: - return 'super'; - } - }, - - pad: v => ('0' + v.toString()).slice(-2), -}; - -class Clock { - constructor(options) { - this._el = $.el('#clock'); - this._delimiter = options.delimiter; - this._twentyFourHourClock = options.twentyFourHourClock; - this._setTime = this._setTime.bind(this); - this._el.addEventListener('click', options.toggleHelp); - this._start(); - } - - _setTime() { - const date = new Date(); - let hours = $.pad(date.getHours()); - let amPm = ''; - - if (!this._twentyFourHourClock) { - hours = date.getHours(); - if (hours > 12) hours -= 12; - else if (hours === 0) hours = 12; - - amPm = - ` ` + - `${date.getHours() >= 12 ? 'PM' : 'AM'}`; - } - - const minutes = $.pad(date.getMinutes()); - this._el.innerHTML = `${hours}${this._delimiter}${minutes}${amPm}`; - this._el.setAttribute('datetime', date.toTimeString()); - } - - _start() { - this._setTime(); - setInterval(this._setTime, 1000); - } -} - -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.launch = this.launch.bind(this); - this.launchCategory = this.launchCategory.bind(this); - this._inputEl = $.el('#search-input'); - this._inputElVal = ''; - this._suggester = options.suggester; - this._invertColors = options.invertedColors; - this._buildAndAppendLists(); - this._registerEvents(); - this._invertValue; - } - - toggle(show) { - this._toggled = typeof show !== 'undefined' ? show : !this._toggled; - this._toggled ? $.bodyClassAdd('help') : $.bodyClassRemove('help'); - } - - hide() { - $.bodyClassRemove('form'); - this._inputEl.value = ''; - this._inputElVal = ''; - this._suggester.suggest(''); - } - - launch() { - this.hide(); - this.toggle(true); - $.bodyClassAdd('help'); - - CONFIG.commands.forEach(command => { - if(command.quickLaunch) window.open(command.url); - }); - } - - launchCategory(){ - - const categorySet = new Set(); - - CONFIG.commands.forEach(command => { - if(command.category) categorySet.add(command.category); - }); - - const targetCategoryIndex = $.el('#search-input').value.replace('!', ''); - const targetCategory = Array.from(categorySet)[targetCategoryIndex - 1]; - - CONFIG.commands.forEach(command => { - if(targetCategory && command.category === targetCategory) window.open(command.url); - }); - } - - _buildAndAppendLists() { - const lists = document.createElement('ul'); - lists.classList.add('categories'); - - this._getCategories().forEach(category => { - lists.insertAdjacentHTML( - 'beforeend', - `
  • -

    ${category}

    - -
  • ` - ); - }); - - this._el.appendChild(lists); - } - - _buildListCommands(currentCategory) { - let invertValue = this._invertColors ? 1: 0; - - const bgcolor = invertValue ? getComputedStyle(document.documentElement).getPropertyValue('--foreground') - : getComputedStyle(document.documentElement).getPropertyValue('--background'); - - const fgcolor = invertValue ? getComputedStyle(document.documentElement).getPropertyValue('--background') - : getComputedStyle(document.documentElement).getPropertyValue('--foreground'); - - - const commandListWithIcons = this._commands - .map(({ category, name, key, url, icon }, i) => { - const iconEl = CONFIG.iconExtension !== 'svg' - ? `` - : `` - - if (category === currentCategory) { - return ` - -
  • - - ${iconEl} - ${name} - -
  • - `; - } - }) - .join(''); - - const commandListWithKeys = this._commands - .map(({ category, name, key, url }, i) => { - if (category === currentCategory) { - return ` -
  • - - - ${key} - ${name} - -
  • - `; - } - }) - .join(''); - - return CONFIG.showKeys ? commandListWithKeys : commandListWithIcons; - } - - _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 DefaultInfluencer extends Influencer { - constructor({ defaultSuggestions }) { - super(...arguments); - this._defaultSuggestions = defaultSuggestions; - } - - getSuggestions(query) { - return new Promise(resolve => { - const suggestions = this._defaultSuggestions[query]; - resolve(suggestions ? suggestions.slice(0, this._limit) : []); - }); - } -} - -class CommandsInfluencer extends Influencer { - constructor({ commands, queryParser }) { - super(...arguments); - this._commands = commands; - } - - getSuggestions(rawQuery) { - const { query } = this._parseQuery(rawQuery); - if (!query) return Promise.resolve([]); - - return new Promise(resolve => { - const suggestions = []; - const commands = this._commands; - - commands.forEach(command => { - if(this._getDomain(command.url).startsWith(rawQuery)){ - suggestions.push(command.url); - } - }); - - resolve(suggestions); - }); - } - - _getHostName(url) { - let match = url.match(/:\/\/(www[0-9]?\.)?(.[^/:]+)/i); - if (match != null && match.length > 2 && typeof match[2] === 'string' && match[2].length > 0) { - return match[2]; - } - else { - return null; - } - } - - _getDomain(url){ - let hostName = this._getHostName(url); - let domain = hostName; - - if (hostName != null) { - let parts = hostName.split('.').reverse(); - if (parts != null && parts.length > 1) { - domain = parts[1] + '.' + parts[0]; - if (hostName.toLowerCase().indexOf('.co.uk') != -1 && parts.length > 2) { - domain = parts[2] + '.' + domain; - } - } - } - - return domain; - } -} - -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 HistoryInfluencer extends Influencer { - constructor() { - super(...arguments); - this._storeName = 'history'; - } - - addItem(query) { - if (query.length < 2) return; - let exists; - - const history = this._getHistory().map(([item, count]) => { - const match = $.ieq(item, query); - if (match) exists = true; - return [item, match ? count + 1 : count]; - }); - - if (!exists) history.push([query, 1]); - - const sorted = history - .sort((current, next) => current[1] - next[1]) - .reverse(); - - this._setHistory(sorted); - } - - getSuggestions(query) { - return new Promise(resolve => { - const suggestions = this._getHistory() - .map(([item]) => item) - .filter(item => query && !$.ieq(item, query) && $.iin(item, query)) - .slice(0, this._limit); - - resolve(suggestions); - }); - } - - _fetch() { - return JSON.parse(localStorage.getItem(this._storeName)) || []; - } - - _getHistory() { - this._history = this._history || this._fetch(); - return this._history; - } - - _save(history) { - localStorage.setItem(this._storeName, JSON.stringify(history)); - } - - _setHistory(history) { - this._history = history; - this._save(history); - } -} - -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._colors = options.colors; - this._formEl = $.el('#search-form'); - this._inputEl = $.el('#search-input'); - this._inputElVal = ''; - this._instantRedirect = options.instantRedirect; - this._newTab = options.newTab; - this._parseQuery = options.parseQuery; - this._suggester = options.suggester; - this._toggleHelp = options.toggleHelp; - this._quickLaunch = options.quickLaunch; - this._categoryLaunch = options.categoryLaunch; - 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._invertColors = options.invertedColors; - this.hide = this.hide.bind(this); - this.show = this.show.bind(this); - this._registerEvents(); - this._loadQueryParam(); - this.invert(); - this.isCtrlEnter = false; - } - - hide() { - $.bodyClassRemove('form'); - this._inputEl.value = ''; - this._inputElVal = ''; - this._suggester.suggest(''); - this._setBackgroundFromQuery(''); - } - - show() { - $.bodyClassAdd('form'); - this._inputEl.focus(); - } - - invert() { - if (this._invertColors) { - const bgcolor = getComputedStyle(document.documentElement).getPropertyValue('--background'); - const fgcolor = getComputedStyle(document.documentElement).getPropertyValue('--foreground'); - document.documentElement.style.setProperty('--background', fgcolor); - document.documentElement.style.setProperty('--foreground', bgcolor); - } - } - - _invertConfig() { - let isInverted = !CONFIG.invertedColors; - localStorage.removeItem('invertColorCookie'); - localStorage.setItem('invertColorCookie', JSON.stringify(isInverted)); - location.reload(); - } - - _showKeysConfig() { - let isShowKeys = !CONFIG.showKeys; - localStorage.removeItem('showKeysCookie'); - localStorage.setItem('showKeysCookie', JSON.stringify(isShowKeys)); - location.reload(); - } - - _clearPreview() { - this._previewValue(this._inputElVal); - this._inputEl.focus(); - } - - _isCategoryLaunch(num){ - if(/^\d/.test(num[0]) && num[1] === '!'){ - return true - } else { - return false; - } - } - - _handleInput() { - const newQuery = this._inputEl.value; - const isHelp = newQuery === '?'; - const isLaunch = newQuery === 'q!'; - const isInvert = newQuery === 'invert!'; - const isShowKeys = newQuery === 'keys!'; - const isCategoryLaunch = this._isCategoryLaunch(newQuery); - const { isKey } = this._parseQuery(newQuery); - this._inputElVal = newQuery; - this._suggester.suggest(newQuery); - this._setBackgroundFromQuery(newQuery); - if (!newQuery || isHelp) this.hide(); - if (isHelp) this._toggleHelp(); - if (isLaunch) this._quickLaunch(); - if (isInvert) this._invertConfig(); - if (isShowKeys) this._showKeysConfig(); - if (isCategoryLaunch) this._categoryLaunch(); - 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; - case 'c-enter': - this.isCtrlEnter = true; - } - - this.show(); - } - - _loadQueryParam() { - const q = new URLSearchParams(window.location.search).get('q'); - if (q) this._submitWithValue(q); - } - - _previewValue(value) { - this._inputEl.value = value; - this._setBackgroundFromQuery(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); - } - } - - _setBackgroundFromQuery(query) { - if (!this._colors) return; - this._formEl.style.background = this._parseQuery(query).color; - } - - _submitForm(e) { - if (e) e.preventDefault(); - let query = this._inputEl.value; - if (this._suggester) this._suggester.success(query); - this.hide(); - if (this.isCtrlEnter) query += '.com'; - 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 { - Default: DefaultInfluencer, - Commands: CommandsInfluencer, - DuckDuckGo: DuckDuckGoInfluencer, - History: HistoryInfluencer, - }[influencerConfig.name]({ - defaultSuggestions: CONFIG.defaultSuggestions, - limit: influencerConfig.limit, - parseQuery: queryParser.parse, - commands: CONFIG.commands - }); -}); - -const suggester = new Suggester({ - enabled: CONFIG.suggestions, - influencers, - limit: CONFIG.suggestionsLimit, -}); - -const help = new Help({ - commands: CONFIG.commands, - newTab: CONFIG.newTab, - suggester, - invertedColors: CONFIG.invertedColors, - showKeys: CONFIG.showKeys -}); - -const form = new Form({ - colors: CONFIG.colors, - instantRedirect: CONFIG.instantRedirect, - newTab: CONFIG.newTab, - parseQuery: queryParser.parse, - suggester, - toggleHelp: help.toggle, - quickLaunch: help.launch, - categoryLaunch: help.launchCategory, - invertedColors: CONFIG.invertedColors, - showKeys: CONFIG.showKeys -}); - -new Clock({ - delimiter: CONFIG.clockDelimiter, - toggleHelp: help.toggle, - twentyFourHourClock: CONFIG.twentyFourHourClock, -}); \ No newline at end of file diff --git a/js/suggester.js b/js/suggester.js new file mode 100644 index 0000000..34c9ba6 --- /dev/null +++ b/js/suggester.js @@ -0,0 +1,165 @@ +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(); + } + } \ No newline at end of file