2021-02-03 14:15:00 +00:00
|
|
|
<!DOCTYPE html>
|
|
|
|
<!--- This is a fork of https://raw.githubusercontent.com/cadejscroggins/tilde/master/index.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: true,
|
|
|
|
|
|
|
|
// 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: [
|
2021-02-03 14:47:34 +00:00
|
|
|
{
|
|
|
|
category: 'Social',
|
|
|
|
hues: ['217', '197'],
|
|
|
|
key: 'ww',
|
|
|
|
name: 'Whatsapp Web',
|
|
|
|
search: '/search?q={}',
|
|
|
|
url: 'https://web.whatsapp.com/',
|
|
|
|
},
|
|
|
|
{
|
|
|
|
category: 'Social',
|
|
|
|
hues: ['21', '177'],
|
|
|
|
key: 'tg',
|
|
|
|
name: 'Telegram',
|
|
|
|
search: '/search?q={}',
|
|
|
|
url: 'https://web.telegram.org/',
|
|
|
|
},
|
|
|
|
{
|
|
|
|
category: 'Social',
|
|
|
|
hues: ['217', '17'],
|
|
|
|
key: 'ss',
|
|
|
|
name: 'Pleroma',
|
|
|
|
search: '/search?q={}',
|
|
|
|
url: 'https://social.skiqqy.xyz/',
|
|
|
|
},
|
|
|
|
{
|
|
|
|
category: 'Social',
|
|
|
|
hues: ['92', '77'],
|
|
|
|
key: 'irc',
|
|
|
|
name: 'irc',
|
|
|
|
search: '/search?q={}',
|
|
|
|
url: 'https://irc.skiqqy.xyz/',
|
|
|
|
},
|
2021-02-03 14:15:00 +00:00
|
|
|
{
|
|
|
|
key: '*',
|
|
|
|
name: 'DuckDuckGo',
|
|
|
|
search: '/?q={}',
|
|
|
|
url: 'https://duckduckgo.com',
|
|
|
|
},
|
|
|
|
{
|
|
|
|
key: 'ecosia',
|
|
|
|
name: 'Ecosia',
|
|
|
|
search: '/search?q={}',
|
|
|
|
url: 'https://www.ecosia.org',
|
|
|
|
},
|
|
|
|
{
|
|
|
|
key: 'bing',
|
|
|
|
name: 'Bing',
|
|
|
|
search: '/search?q={}',
|
|
|
|
url: 'https://www.bing.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/client/T09M5UWSV',
|
|
|
|
},
|
|
|
|
{
|
|
|
|
category: 'Work',
|
|
|
|
hues: ['4', '24'],
|
|
|
|
key: 'n',
|
|
|
|
name: 'Notion',
|
|
|
|
url: 'https://www.notion.so',
|
|
|
|
},
|
|
|
|
{
|
|
|
|
category: 'Work',
|
|
|
|
hues: ['357', '337'],
|
|
|
|
key: 'a',
|
|
|
|
name: 'Asana',
|
|
|
|
url: 'https://app.asana.com/0/inbox/490539250066176',
|
|
|
|
},
|
|
|
|
{
|
|
|
|
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: '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: ['213', '193'],
|
|
|
|
key: 'v',
|
|
|
|
name: 'Vimeo',
|
|
|
|
search: '/search?q={}',
|
|
|
|
url: 'https://vimeo.com/watch',
|
|
|
|
},
|
|
|
|
{
|
|
|
|
category: 'Watch',
|
|
|
|
hues: ['5', '355'],
|
|
|
|
key: 'y',
|
|
|
|
name: 'YouTube',
|
|
|
|
search: '/results?search_query={}',
|
|
|
|
url: 'https://youtube.com',
|
|
|
|
},
|
|
|
|
{
|
|
|
|
category: 'Watch',
|
|
|
|
hues: ['357', '357'],
|
|
|
|
key: 'x',
|
|
|
|
name: 'Netflix',
|
|
|
|
search: '/search?q={}',
|
|
|
|
url: 'https://www.netflix.com/browse',
|
|
|
|
},
|
|
|
|
{
|
|
|
|
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'],
|
|
|
|
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/hot', 'r/new', 'r/top', 'r/r/startpages'],
|
|
|
|
s: ['s/client/T7K3RFA1M', 's/client/T018S4TL7CP'],
|
|
|
|
y: ['y/feed/subscriptions', '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: #efefef;
|
|
|
|
--help-background: #000;
|
|
|
|
--help-command-key-foreground: #000;
|
|
|
|
--help-foreground: #efefef;
|
|
|
|
--search-background: #000;
|
|
|
|
--search-foreground: #efefef;
|
|
|
|
--search-highlight-background: hsla(0, 0%, 100%, 0.1);
|
|
|
|
--command-color-alpha: 0.9;
|
|
|
|
--command-color-gradient: 45deg;
|
|
|
|
--command-color-lightness: 50%;
|
|
|
|
--command-color-saturation: 50%;
|
|
|
|
|
|
|
|
--font-family: -apple-system, BlinkMacSystemFont, Helvetica Neue, Helvetica,
|
|
|
|
Ubuntu, Roboto, Noto, Segoe UI, Arial, sans-serif;
|
|
|
|
|
|
|
|
--clock-font-family: Menlo, Consolas, Monaco, Liberation Mono,
|
|
|
|
Lucida Console, monospace;
|
|
|
|
|
|
|
|
--base-font-size: 16px;
|
|
|
|
--clock-font-size: 2rem;
|
|
|
|
--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: 4rem;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@media (min-width: 650px) {
|
|
|
|
:root {
|
|
|
|
--clock-font-size: 6rem;
|
|
|
|
--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: 3rem 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);
|
|
|
|
}
|
|
|
|
|
|
|
|
.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>
|