Last active
May 17, 2022 01:59
-
-
Save orazmyradov/c67c07eaaddae82ef3a6ccd849a2e615 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<meta charset="utf-8" /> | |
<meta name="viewport" content="width=device-width, initial-scale=1" /> | |
<meta name="robots" content="noindex" /> | |
<title>~</title> | |
<script> | |
const CONFIG = { | |
// Action to take when the clock is clicked. Options include: | |
// - "Menu" to show the help menu | |
// - "Search" to show the search input (useful on mobile) | |
clockOnClickAction: 'Menu', | |
// The delimiter between the hours and minutes on the clock. | |
clockDelimiter: ' - ', | |
// Show AM/PM indication when CONFIG.clockTwentyFourHours is false. | |
clockShowAmPm: true, | |
// Show seconds on the clock. A monospaced font is recommended for this. | |
clockShowSeconds: false, | |
// Show a twenty-four-hour clock instead of a twelve-hour clock. | |
clockTwentyFourHour: true, | |
// The "category", "name", "key", "url", "search" path and "color"/"hues" | |
// for your commands. If none of the specified keys are matched, the * key | |
// is used. Commands without a category don't show up in the help menu. | |
// You can specify either "hues" or "color" to change a command's background | |
// color. "hues" is an array of HSL hues that will be converted into a | |
// linear gradient. There are CSS variables defined below, prefixed with | |
// "--command-color-", that determine the gradient angle, saturation, | |
// lightness and alpha for each generated color. "color", if defined, will | |
// be applied as-is to the command's "background" CSS property. | |
commands: [ | |
{ | |
key: '*', | |
name: 'Google', | |
search: '/search?q={}', | |
url: 'https://www.google.com', | |
}, | |
{ | |
category: 'Google', | |
hues: ['217', '197'], | |
key: 'm', | |
name: 'Mail', | |
search: '/mail/u/0/?q={}#search/{}', | |
url: 'https://mail.google.com/mail/u/0', | |
}, | |
{ | |
category: 'Google', | |
hues: ['136', '156'], | |
key: 'd', | |
name: 'Drive', | |
search: '/drive/u/0/search?q={}', | |
url: 'https://drive.google.com/drive/u/0/my-drive', | |
}, | |
{ | |
category: 'Google', | |
hues: ['45', '40'], | |
key: 'k', | |
name: 'Keep', | |
search: '/u/0/#search/text={}', | |
url: 'https://keep.google.com/u/0', | |
}, | |
{ | |
category: 'Google', | |
hues: ['5', '355'], | |
key: 'c', | |
name: 'Cal', | |
search: '/calendar/u/0/r/search?q={}', | |
url: 'https://calendar.google.com/calendar/u/0/r', | |
}, | |
{ | |
category: 'Work', | |
hues: ['190', '210'], | |
key: 's', | |
name: 'Slack', | |
url: 'https://app.slack.com', | |
}, | |
{ | |
category: 'Work', | |
hues: ['4', '24'], | |
key: 'n', | |
name: 'Notion', | |
url: 'https://www.notion.so', | |
}, | |
{ | |
category: 'Work', | |
hues: ['234', '264'], | |
key: 'A', | |
name: 'Plausible', | |
url: 'https://plausible.io', | |
}, | |
{ | |
category: 'Work', | |
hues: ['167', '187'], | |
key: '.', | |
name: 'AND', | |
url: 'https://app.and.co/timers', | |
}, | |
{ | |
category: 'Create', | |
hues: ['36', '26'], | |
key: 'a', | |
name: 'AWS', | |
url: 'https://console.aws.amazon.com', | |
}, | |
{ | |
category: 'Create', | |
hues: ['214', '234'], | |
key: 'g', | |
name: 'GitHub', | |
search: '/search?q={}', | |
url: 'https://github.com', | |
}, | |
{ | |
category: 'Create', | |
hues: ['266', '286'], | |
key: 'f', | |
name: 'Figma', | |
url: 'https://www.figma.com/files/recent', | |
}, | |
{ | |
category: 'Create', | |
key: '0', | |
name: 'Local', | |
search: ':{}', | |
url: 'http://localhost:3000', | |
}, | |
{ | |
category: 'Learn', | |
hues: ['166', '146'], | |
key: '+', | |
name: 'Khan', | |
search: '/search?page_search_query={}', | |
url: 'https://www.khanacademy.org', | |
}, | |
{ | |
category: 'Learn', | |
hues: ['5', '345'], | |
key: '=', | |
name: 'Wolfram', | |
search: '/input/?i={}', | |
url: 'https://www.wolframalpha.com', | |
}, | |
{ | |
category: 'Learn', | |
hues: ['217', '237'], | |
key: 'w', | |
name: 'Wikipedia', | |
search: '/wiki/{}', | |
url: 'https://en.wikipedia.org/wiki/Main_Page', | |
}, | |
{ | |
category: 'Learn', | |
hues: ['198', '218'], | |
key: ';', | |
name: 'MDN', | |
search: '/en-US/search?q={}', | |
url: 'https://developer.mozilla.org/en-US', | |
}, | |
{ | |
category: 'Lurk', | |
hues: ['254', '234'], | |
key: 'r', | |
name: 'Reddit', | |
search: '/search?q={}', | |
url: 'https://www.reddit.com', | |
}, | |
{ | |
category: 'Lurk', | |
hues: ['230', '280'], | |
key: 'i', | |
name: 'Instagram', | |
url: 'https://www.instagram.com', | |
}, | |
{ | |
category: 'Lurk', | |
hues: ['201', '221'], | |
key: 'l', | |
name: 'LinkedIn', | |
search: '/search/results/all/?keywords={}', | |
url: 'https://www.linkedin.com', | |
}, | |
{ | |
category: 'Lurk', | |
hues: ['203', '183'], | |
key: 't', | |
name: 'Twitter', | |
search: '/search?q={}', | |
url: 'https://twitter.com/home', | |
}, | |
{ | |
category: 'Inspire', | |
hues: ['349', '329'], | |
key: '1', | |
name: 'OPL', | |
search: '/?s={}', | |
url: 'https://onepagelove.com/inspiration', | |
}, | |
{ | |
category: 'Inspire', | |
hues: ['337', '317'], | |
key: 'b', | |
name: 'Dribbble', | |
search: '/search/{}', | |
url: 'https://dribbble.com/shots/popular', | |
}, | |
{ | |
category: 'Inspire', | |
key: 'u', | |
name: 'Unsplash', | |
search: '/search/{}', | |
url: 'https://unsplash.com/images', | |
}, | |
{ | |
category: 'Inspire', | |
hues: ['13', '33'], | |
key: 'p', | |
name: 'Hunt', | |
search: '/search?q={}', | |
url: 'https://www.producthunt.com', | |
}, | |
{ | |
category: 'Listen', | |
hues: ['141'], | |
key: 'S', | |
name: 'Spotify', | |
search: '/search/{}', | |
url: 'https://open.spotify.com', | |
}, | |
{ | |
category: 'Listen', | |
hues: ['32', '22'], | |
key: 'o', | |
name: 'SoundCloud', | |
search: '/search?q={}', | |
url: 'https://soundcloud.com/discover', | |
}, | |
{ | |
category: 'Listen', | |
hues: ['202', '222'], | |
key: 'P', | |
name: 'Pandora', | |
search: '/search/{}/all', | |
url: 'https://www.pandora.com', | |
}, | |
{ | |
category: 'Listen', | |
hues: ['90'], | |
key: 'h', | |
name: 'Hypem', | |
search: '/search/{}', | |
url: 'https://hypem.com/latest', | |
}, | |
{ | |
category: 'Watch', | |
hues: ['226', '236'], | |
key: 'e', | |
name: 'Disney', | |
url: 'https://www.disneyplus.com/home', | |
}, | |
{ | |
category: 'Watch', | |
hues: ['357', '357'], | |
key: 'x', | |
name: 'Netflix', | |
search: '/search?q={}', | |
url: 'https://www.netflix.com/browse', | |
}, | |
{ | |
category: 'Watch', | |
hues: ['5', '355'], | |
key: 'y', | |
name: 'YouTube', | |
search: '/results?search_query={}', | |
url: 'https://youtube.com/feed/subscriptions', | |
}, | |
{ | |
category: 'Watch', | |
hues: ['264', '244'], | |
key: 'T', | |
name: 'Twitch', | |
url: 'https://www.twitch.tv/directory/following', | |
}, | |
], | |
// Instantly redirect when a key is matched. Put a space before any other | |
// queries to prevent unwanted redirects. | |
queryInstantRedirect: false, | |
// Open triggered queries in a new tab. | |
queryNewTab: true, | |
// 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". | |
queryPathDelimiter: '/', | |
// The delimiter between a command key and your search query. For example, | |
// to search GitHub for tilde, you'd type "g'tilde". | |
querySearchDelimiter: "'", | |
// Scripts allow you to open or search multiple sites at once. For example, | |
// to search Google, DuckDuckGo, Ecosia and Bing for cats at the same time, | |
// you'd type "se'cats". | |
scripts: { | |
se: ['bing', 'ecosia', 'duckduckgo', '*'], | |
sn: ['f', 'n', 's'], | |
sp: ['g/notifications', 'a', 's/client/T7K3RFA1M', 'm/mail/u/1'], | |
}, | |
// Default search suggestions for the specified queries. | |
suggestionDefaults: { | |
'.': ['./inout/in/invoices/create'], | |
0: ["0'8000", "0'8080"], | |
c: ['c/calendar/u/1/r', 'c/calendar/u/2/r'], | |
d: ['d/drive/u/1/my-drive', 'd/drive/u/2/my-drive'], | |
e: ['e/brand/national-geographic', 'e/brand/marvel', 'e/brand/star-wars'], | |
g: ['g/notifications', 'g/trending', 'g/ossu', 'g/cadejscroggins/tilde'], | |
h: ['h/popular', 'h/popular/lastweek', 'h/tags'], | |
k: ['k/u/1', 'k/u/2'], | |
m: ['m/mail/u/1', 'm/mail/u/2'], | |
r: ['r/r/superstonk', 'r/r/fujix', 'r/r/unixporn', 'r/r/startpages'], | |
s: ['s/client/T09M5UWSV'], | |
y: ['y/feed/trending'], | |
}, | |
// The order, limit and minimum characters for each suggestion influencer. | |
// An "influencer" is just a suggestion source. "limit" is the max number of | |
// suggestions an influencer will produce. "minChars" determines how many | |
// characters need to be typed before the influencer kicks in. | |
// The following influencers are available: | |
// - "Default" suggestions come from CONFIG.suggestionDefaults (sync) | |
// - "History" suggestions come from your previously entered queries (sync) | |
// - "DuckDuckGo" suggestions come from the duck duck go search api (async) | |
// To disable suggestions, remove all influencers. | |
suggestionInfluencers: [ | |
{ name: 'Default', limit: 4, minChars: 1 }, | |
{ name: 'DuckDuckGo', limit: 4, minChars: 2 }, | |
], | |
// Max number of suggestions that will ever be shown. | |
suggestionLimit: 4, | |
}; | |
</script> | |
<style> | |
:root { | |
--base-background: #000; | |
--base-foreground: #fff; | |
--help-background: #000; | |
--help-foreground: #fff; | |
--help-command-key-foreground: #000; | |
--search-background: #000; | |
--search-foreground: #fff; | |
--search-highlight-background: hsla(0, 0%, 100%, 0.85); | |
--search-highlight-foreground: #000; | |
--command-color-alpha: 1; | |
--command-color-gradient: 45deg; | |
--command-color-lightness: 50%; | |
--command-color-saturation: 40%; | |
--font-family: -apple-system, BlinkMacSystemFont, Helvetica Neue, Helvetica, | |
Ubuntu, Roboto, Noto, Segoe UI, Arial, sans-serif; | |
--clock-font-family: var(--font-family); | |
--base-font-size: 16px; | |
--clock-font-size: 4rem; | |
--search-input-font-size: 2rem; | |
--font-weight-black: 900; | |
--font-weight-bold: 700; | |
--font-weight-normal: 400; | |
--base-border-radius: 2px; | |
--base-transition-speed: 0.2s; | |
--help-category-columns: 1; | |
--search-input-text-align: left; | |
--search-suggestion-flex-direction: column; | |
--search-suggestions-align-items: flex-start; | |
} | |
@media (min-width: 400px) { | |
:root { | |
--clock-font-size: 6rem; | |
} | |
} | |
@media (min-width: 650px) { | |
:root { | |
--clock-font-size: 8rem; | |
--help-category-columns: 2; | |
--search-input-text-align: center; | |
--search-suggestions-align-items: center; | |
} | |
} | |
@media (min-width: 900px) { | |
:root { | |
--search-input-font-size: 3rem; | |
--help-category-columns: 3; | |
--search-suggestion-flex-direction: row; | |
} | |
} | |
@media (min-width: 1200px) { | |
:root { | |
--help-category-columns: 4; | |
} | |
} | |
* { | |
box-sizing: border-box; | |
} | |
html { | |
font-family: var(--font-family); | |
font-size: var(--base-font-size); | |
font-weight: var(--font-weight-normal); | |
} | |
body { | |
position: absolute; | |
top: 0; | |
left: 0; | |
width: 100%; | |
height: 100%; | |
margin: 0; | |
padding: 0; | |
background: var(--base-background); | |
color: var(--base-foreground); | |
} | |
input, | |
button { | |
display: block; | |
width: 100%; | |
margin: 0; | |
padding: 0; | |
background: transparent; | |
color: inherit; | |
font-family: var(--font-family); | |
font-weight: var(--font-weight-normal); | |
font-size: 1rem; | |
} | |
input, | |
button, | |
input:focus, | |
button:focus { | |
border: 0; | |
outline: 0; | |
-webkit-appearance: none; | |
-moz-appearance: none; | |
} | |
ul, | |
li { | |
margin: 0; | |
padding: 0; | |
list-style: none; | |
} | |
a, | |
a:focus { | |
color: inherit; | |
outline: 0; | |
} | |
.center { | |
display: flex; | |
width: 100%; | |
height: 100%; | |
} | |
.center > * { | |
margin: auto; | |
} | |
.overlay { | |
position: fixed; | |
top: 0; | |
left: 0; | |
overflow: auto; | |
width: 100%; | |
height: 100%; | |
} | |
.clock { | |
display: block; | |
font-family: var(--clock-font-family); | |
font-size: var(--clock-font-size); | |
cursor: pointer; | |
} | |
body.form .clock { | |
visibility: hidden; | |
} | |
.help { | |
background: var(--help-background); | |
visibility: hidden; | |
color: var(--help-foreground); | |
z-index: 1; | |
} | |
body.help .help { | |
visibility: visible; | |
} | |
body.form .help { | |
visibility: hidden; | |
} | |
.categories { | |
padding: 2rem 0; | |
columns: var(--help-category-columns); | |
} | |
.category { | |
padding: 1.5rem 2.25rem; | |
vertical-align: text-top; | |
break-inside: avoid-column; | |
page-break-inside: avoid; | |
} | |
.category-name { | |
margin-bottom: 1.5rem; | |
font-size: 1rem; | |
font-weight: var(--font-weight-black); | |
letter-spacing: 0.075em; | |
text-transform: uppercase; | |
} | |
.command a { | |
display: flex; | |
align-items: baseline; | |
position: relative; | |
width: 100%; | |
padding: 0.75rem 0; | |
text-decoration: none; | |
} | |
.command-key { | |
display: block; | |
flex-shrink: 0; | |
float: left; | |
width: 2.25rem; | |
height: 2.25rem; | |
margin-right: 1.5rem; | |
border-radius: var(--base-border-radius); | |
color: var(--help-command-key-foreground); | |
font-weight: var(--font-weight-bold); | |
line-height: 2.25rem; | |
text-align: center; | |
} | |
.command-name { | |
position: relative; | |
} | |
.command-name::after { | |
content: ' '; | |
display: none; | |
position: absolute; | |
right: 0; | |
bottom: -0.175rem; | |
left: 0; | |
height: 0.4rem; | |
border-radius: 1px; | |
transition: transform var(--base-transition-speed), | |
opacity var(--base-transition-speed); | |
transform: translateX(-2rem); | |
background: var(--base-foreground); | |
opacity: 0; | |
z-index: -1; | |
} | |
.command a:hover .command-name::after, | |
.command a:focus .command-name::after { | |
transform: translateX(0); | |
opacity: 1; | |
} | |
body.help .command-name::after { | |
display: block; | |
} | |
.search-form { | |
background: var(--search-background); | |
color: var(--search-foreground); | |
visibility: hidden; | |
} | |
body.form .search-form { | |
visibility: visible; | |
} | |
.search-form-content { | |
width: 100%; | |
} | |
.search-input { | |
width: 100%; | |
padding: 0 1rem; | |
font-size: var(--search-input-font-size); | |
font-weight: var(--font-weight-black); | |
text-align: var(--search-input-text-align); | |
text-transform: uppercase; | |
} | |
.search-suggestions { | |
display: none; | |
justify-content: center; | |
align-items: var(--search-suggestions-align-items); | |
flex-direction: var(--search-suggestion-flex-direction); | |
flex-wrap: wrap; | |
overflow: hidden; | |
} | |
body.suggestions .search-suggestions { | |
display: flex; | |
margin-top: 2rem; | |
} | |
.search-suggestion { | |
padding: 0.75rem 1rem; | |
border-radius: var(--base-border-radius); | |
font-weight: var(--font-weight-bold); | |
text-align: left; | |
white-space: nowrap; | |
cursor: pointer; | |
} | |
.search-suggestion.highlight { | |
background: var(--search-highlight-background); | |
color: var(--search-highlight-foreground); | |
} | |
.search-suggestion-match { | |
font-weight: var(--font-weight-normal); | |
} | |
</style> | |
<div class="center"><time class="clock" id="clock"></time></div> | |
<form | |
autocomplete="off" | |
class="center overlay search-form" | |
id="search-form" | |
spellcheck="false" | |
> | |
<div class="search-form-content"> | |
<input class="search-input" id="search-input" title="search" type="text" /> | |
<ul class="search-suggestions" id="search-suggestions"></ul> | |
</div> | |
</form> | |
<aside class="center help overlay" id="help"></aside> | |
<script> | |
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))], | |
isDown: (e) => ['ctrl-n', 'down', 'tab'].includes($.whichKey(e)), | |
isRemove: (e) => ['backspace', 'delete'].includes($.whichKey(e)), | |
isUp: (e) => ['ctrl-p', 'up', 's-tab'].includes($.whichKey(e)), | |
pad: (v) => ('0' + v.toString()).slice(-2), | |
whichKey: (e) => { | |
const ctrl = e.ctrlKey; | |
const meta = e.metaKey; | |
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 ? 'ctrl-n' : 'n'; | |
case 80: | |
return ctrl ? 'ctrl-p' : 'p'; | |
case 86: | |
if (ctrl) return 'ctrl-v'; | |
if (meta) return 'ctrl-v'; | |
break; | |
case 91: | |
case 93: | |
case 224: | |
return 'meta'; | |
} | |
if (ctrl) return 'ctrl-*'; | |
if (meta) return 'meta-*'; | |
}, | |
}; | |
class Clock { | |
constructor(options) { | |
this._el = $.el('#clock'); | |
this._amPm = options.amPm; | |
this._delimiter = options.delimiter; | |
this._showSeconds = options.showSeconds; | |
this._twentyFourHour = options.twentyFourHour; | |
this._setTime = this._setTime.bind(this); | |
this._el.addEventListener('click', options.onClick); | |
this._start(); | |
} | |
_setTime() { | |
const date = new Date(); | |
let hours = $.pad(date.getHours()); | |
let amPm = ''; | |
if (!this._twentyFourHour) { | |
hours = date.getHours(); | |
if (hours > 12) hours -= 12; | |
else if (hours === 0) hours = 12; | |
if (this._amPm) amPm = date.getHours() >= 12 ? ' PM' : ' AM'; | |
} | |
const minutes = this._delimiter + $.pad(date.getMinutes()); | |
const seconds = this._showSeconds | |
? this._delimiter + $.pad(date.getSeconds()) | |
: ''; | |
this._el.innerHTML = hours + minutes + seconds + 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.toggle = this.toggle.bind(this); | |
this._handleKeydown = this._handleKeydown.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', | |
`<li class="category"> | |
<h2 class="category-name">${category}</h2> | |
<ul>${this._buildListCommands(category)}</ul> | |
</li>` | |
); | |
}); | |
this._el.appendChild(lists); | |
} | |
_buildListCommands(currentCategory) { | |
return this._commands.reduce( | |
(acc, { category, color, name, key, url }, i) => { | |
if (category !== currentCategory) return acc; | |
const target = this._newTab ? '_blank' : '_self'; | |
return ` | |
${acc} | |
<style> | |
.command-key-${i}, | |
.command-name-${i}::after { | |
background: ${color}; | |
} | |
</style> | |
<li class="command"> | |
<a href="${url}" target="${target}" rel="noopener noreferrer"> | |
<span class="command-key command-key-${i}">${key}</span> | |
<span class="command-name command-name-${i}">${name}</span> | |
</a> | |
</li> | |
`; | |
}, | |
'' | |
); | |
} | |
_getCategories() { | |
const categories = this._commands | |
.map((v) => v.category) | |
.filter((category) => category); | |
return [...new Set(categories)]; | |
} | |
_handleKeydown(e) { | |
if ($.whichKey(e) === 'escape') this.toggle(false); | |
} | |
_registerEvents() { | |
document.addEventListener('keydown', this._handleKeydown); | |
} | |
} | |
class Influencer { | |
constructor(options) { | |
this._limit = options.limit; | |
this._minChars = options.minChars; | |
this._parseQuery = options.parseQuery; | |
} | |
addItem() { | |
return undefined; | |
} | |
getSuggestions() { | |
return Promise.resolve([]); | |
} | |
_addSearchPrefix(items, query) { | |
const { isSearch, key, split } = this._parseQuery(query); | |
const searchPrefix = isSearch ? `${key}${split} ` : false; | |
return items.map((s) => (searchPrefix ? searchPrefix + s : s)); | |
} | |
_isTooShort(query) { | |
return query.length < this._minChars; | |
} | |
} | |
class DefaultInfluencer extends Influencer { | |
constructor({ suggestionDefaults }) { | |
super(...arguments); | |
this._suggestionDefaults = suggestionDefaults; | |
} | |
getSuggestions(rawQuery) { | |
if (this._isTooShort(rawQuery)) return Promise.resolve([]); | |
return new Promise((resolve) => | |
resolve( | |
(this._suggestionDefaults[rawQuery] || []).slice(0, this._limit) | |
) | |
); | |
} | |
} | |
class DuckDuckGoInfluencer extends Influencer { | |
constructor({ queryParser }) { | |
super(...arguments); | |
} | |
getSuggestions(rawQuery) { | |
const { query } = this._parseQuery(rawQuery); | |
if (this._isTooShort(rawQuery) || !query) return Promise.resolve([]); | |
return new Promise((resolve) => { | |
const callback = 'autocompleteCallback'; | |
window[callback] = (res) => { | |
const suggestions = res | |
.map((i) => i.phrase) | |
.filter((s) => s.toLowerCase() !== query.toLowerCase()) | |
.slice(0, this._limit); | |
resolve(this._addSearchPrefix(suggestions, rawQuery)); | |
}; | |
const script = document.createElement('script'); | |
script.src = `https://duckduckgo.com/ac/?callback=${callback}&q=${query}`; | |
$.el('head').appendChild(script); | |
}); | |
} | |
} | |
class HistoryInfluencer extends Influencer { | |
constructor() { | |
super(...arguments); | |
this._storeName = 'history'; | |
} | |
static clearHistory() { | |
localStorage.clear(); | |
} | |
addItem(query) { | |
if (query.length < 2) return; | |
let exists; | |
const history = this._getHistory().map(([item, count]) => { | |
const match = item.toLowerCase() === query.toLowerCase(); | |
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(rawQuery) { | |
if (this._isTooShort(rawQuery)) return Promise.resolve([]); | |
return new Promise((resolve) => | |
resolve( | |
this._getHistory() | |
.map(([item]) => item) | |
.filter( | |
(item) => | |
rawQuery && | |
item.toLowerCase() !== rawQuery.toLowerCase() && | |
item.toLowerCase().indexOf(rawQuery.toLowerCase()) !== -1 | |
) | |
.slice(0, this._limit) | |
) | |
); | |
} | |
_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._influencers = options.influencers; | |
this._limit = options.limit; | |
this._currentInput = ''; | |
this._highlitedSuggestion = null; | |
this._suggestionEls = []; | |
this._handleKeydown = this._handleKeydown.bind(this); | |
this._setSuggestions = this._setSuggestions.bind(this); | |
this._registerEvents(); | |
} | |
setOnClick(callback) { | |
this._onClick = callback; | |
} | |
setOnHighlight(callback) { | |
this._onHighlight = callback; | |
} | |
setOnUnhighlight(callback) { | |
this._onUnhighlight = callback; | |
} | |
success(query) { | |
this._influencers.forEach((i) => i.addItem(query)); | |
this._clearSuggestions(); | |
} | |
suggest(input) { | |
this._currentInput = input.trim(); | |
this._highlitedSuggestion = null; | |
if (!this._currentInput) { | |
this._clearSuggestions(); | |
return; | |
} | |
Promise.all(this._getInfluencers()).then(this._setSuggestions); | |
} | |
_buildSuggestionsHtml(suggestions) { | |
return suggestions.slice(0, this._limit).reduce((acc, suggestion) => { | |
const match = new RegExp($.escapeRegex(this._currentInput), 'i'); | |
const matched = suggestion.match(match); | |
const suggestionHtml = matched | |
? suggestion.replace( | |
match, | |
`<span class="search-suggestion-match">${matched[0]}</span>` | |
) | |
: suggestion; | |
return ` | |
${acc} | |
<li> | |
<button | |
type="button" | |
class="js-search-suggestion search-suggestion" | |
data-suggestion="${suggestion}" | |
tabindex="-1" | |
> | |
${suggestionHtml} | |
</button> | |
</li> | |
`; | |
}, ''); | |
} | |
_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._el.innerHTML = ''; | |
this._highlitedSuggestion = null; | |
this._suggestionEls = []; | |
} | |
_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); | |
} | |
_getInfluencers() { | |
return this._influencers.map((influencer) => | |
influencer.getSuggestions(this._currentInput) | |
); | |
} | |
_handleKeydown(e) { | |
if ($.isDown(e)) this._focusNext(e); | |
if ($.isUp(e)) this._focusPrevious(e); | |
} | |
_highlight(el, e) { | |
this._unHighlight(); | |
if (!el) return; | |
this._highlitedSuggestion = el.getAttribute('data-suggestion'); | |
this._onHighlight(this._highlitedSuggestion); | |
el.classList.add('highlight'); | |
if (e) 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); | |
} | |
_rehighlight() { | |
if (!this._highlitedSuggestion) return; | |
this._highlight($.el(`[data-suggestion="${this._highlitedSuggestion}"]`)); | |
} | |
_setSuggestions(newSuggestions) { | |
const suggestions = $.flattenAndUnique(newSuggestions); | |
this._el.innerHTML = this._buildSuggestionsHtml(suggestions); | |
this._suggestionEls = $.els('.js-search-suggestion'); | |
this._registerSuggestionHighlightEvents(); | |
this._registerSuggestionClickEvents(); | |
if (this._suggestionEls.length) $.bodyClassAdd('suggestions'); | |
this._rehighlight(); | |
} | |
_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._scripts = options.scripts; | |
this._protocolRegex = /^[a-zA-Z]+:\/\//i; | |
this._urlRegex = /^((https?:\/\/)?[\w-]+(\.[\w-]+)+\.?(:\d+)?(\/\S*)?)$/i; | |
this.parse = this.parse.bind(this); | |
} | |
parse(query) { | |
const res = []; | |
res.query = query; | |
res.split = null; | |
if (this._urlRegex.test(query)) { | |
const hasProtocol = this._protocolRegex.test(query); | |
res.redirect = hasProtocol ? query : 'http://' + query; | |
res.color = QueryParser._getColorFromUrl(this._commands, res.redirect); | |
return res; | |
} | |
const trimmed = query.trim(); | |
const splitSearch = trimmed.split(this._searchDelimiter); | |
const splitPath = trimmed.split(this._pathDelimiter); | |
const isScript = Object.entries(this._scripts).some(([key, script]) => { | |
if (query === key) { | |
res.key = key; | |
res.isKey = true; | |
script.forEach((command) => res.push(this.parse(command))); | |
return true; | |
} | |
if (splitSearch[0] === key) { | |
res.key = key; | |
res.isSearch = true; | |
res.split = this._searchDelimiter; | |
res.query = QueryParser._shiftAndTrim(splitSearch, res.split); | |
script.forEach((command) => | |
res.push(this.parse(`${command}${res.split}${res.query}`)) | |
); | |
return true; | |
} | |
if (splitPath[0] === key) { | |
res.key = key; | |
res.split = this._pathDelimiter; | |
res.path = QueryParser._shiftAndTrim(splitPath, res.split); | |
script.forEach((command) => | |
res.push(this.parse(`${command}${this._pathDelimiter}${res.path}`)) | |
); | |
return true; | |
} | |
}); | |
if (isScript) return res; | |
this._commands.some(({ key, search, url }) => { | |
if (query === key) { | |
res.key = key; | |
res.isKey = true; | |
res.redirect = url; | |
return true; | |
} | |
if (splitSearch[0] === key) { | |
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.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; | |
const domainRegex = new RegExp(`${domain}$`); | |
return ( | |
commands | |
.filter((c) => domainRegex.test(new URL(c.url).hostname)) | |
.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(/{}/g, 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('#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._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(''); | |
this._setColorsFromQuery(''); | |
} | |
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); | |
this._setColorsFromQuery(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 ($.whichKey(e)) { | |
case 'alt': | |
case 'ctrl': | |
case 'ctrl-*': | |
case 'enter': | |
case 'meta': | |
case 'meta-*': | |
case 'shift': | |
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; | |
this._setColorsFromQuery(value); | |
} | |
_redirect(redirect, forceNewTab) { | |
if (this._newTab || forceNewTab) { | |
window.open(redirect, '_blank', 'noopener noreferrer'); | |
} 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); | |
} | |
} | |
_setColorsFromQuery(query) { | |
const color = this._parseQuery(query).color; | |
this._formEl.style.background = color || ''; | |
} | |
_submitForm(e) { | |
if (e) e.preventDefault(); | |
const query = this._inputEl.value; | |
if (this._suggester) this._suggester.success(query); | |
this.hide(); | |
const res = this._parseQuery(query); | |
if (res.length) { | |
res.forEach((r) => this._redirect(r.redirect, true)); | |
return; | |
} | |
this._redirect(res.redirect); | |
} | |
_submitWithValue(value) { | |
this._inputEl.value = value; | |
this._submitForm(); | |
} | |
} | |
class CommandParser { | |
static commandHuesToColor(command) { | |
const hsla = (hue, saturation = 'var(--command-color-saturation)') => | |
`hsla(${hue}, ${saturation}, var(--command-color-lightness), var(--command-color-alpha))`; | |
if (command.color) return command; | |
command.color = command.category ? hsla(0, '0%') : null; | |
if (!command.hues) return command; | |
if (command.hues.length === 1) { | |
command.color = hsla(command.hues[0]); | |
return command; | |
} | |
const c = command.hues.reduce((a, h) => `${a}, ${hsla(h)}`, ''); | |
command.color = `linear-gradient(var(--command-color-gradient) ${c})`; | |
return command; | |
} | |
} | |
const commands = CONFIG.commands.map(CommandParser.commandHuesToColor); | |
const queryParser = new QueryParser({ | |
commands, | |
pathDelimiter: CONFIG.queryPathDelimiter, | |
scripts: CONFIG.scripts, | |
searchDelimiter: CONFIG.querySearchDelimiter, | |
}); | |
const influencers = CONFIG.suggestionInfluencers.map((influencerConfig) => { | |
return new { | |
Default: DefaultInfluencer, | |
DuckDuckGo: DuckDuckGoInfluencer, | |
History: HistoryInfluencer, | |
}[influencerConfig.name]({ | |
limit: influencerConfig.limit, | |
minChars: influencerConfig.minChars, | |
parseQuery: queryParser.parse, | |
suggestionDefaults: CONFIG.suggestionDefaults, | |
}); | |
}); | |
const suggester = new Suggester({ | |
influencers, | |
limit: CONFIG.suggestionLimit, | |
}); | |
const help = new Help({ | |
commands, | |
newTab: CONFIG.queryNewTab, | |
}); | |
const form = new Form({ | |
instantRedirect: CONFIG.queryInstantRedirect, | |
newTab: CONFIG.queryNewTab, | |
parseQuery: queryParser.parse, | |
suggester, | |
toggleHelp: help.toggle, | |
}); | |
new Clock({ | |
amPm: CONFIG.clockShowAmPm, | |
delimiter: CONFIG.clockDelimiter, | |
onClick: CONFIG.clockOnClickAction === 'Search' ? form.show : help.toggle, | |
showSeconds: CONFIG.clockShowSeconds, | |
twentyFourHour: CONFIG.clockTwentyFourHour, | |
}); | |
</script> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment