tilde/index.html
2022-03-03 09:48:35 -08:00

1499 lines
39 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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 = {
/**
* Choose a predefined theme:
*
* - "ashes-dark"
* - "gruvbox-dark"
* - "nord-dark"
* - "ocean-light"
* - "tilde-dark"
* - "tokyo-dark"
*
* Alternatively, create your own in the <style> tag below!
*/
theme: 'tilde-dark',
/**
* Action to take when the clock is clicked. Options include:
*
* - "Help" to show the help menu
* - "Search" to show the search input (useful on mobile)
*/
clockOnClickAction: 'Help',
/**
* The delimiter between the hours, minutes and seconds on the clock.
*/
clockDelimiter: ' ',
/**
* Show seconds on the clock. A monospaced font is recommended for this.
*/
clockShowSeconds: false,
/**
* Show AM/PM indication when CONFIG.clockTwentyFourHours is false.
*/
clockShowAmPm: true,
/**
* Show a twenty-four-hour clock instead of a twelve-hour clock.
*/
clockTwentyFourHour: true,
/**
* Force an IANA timezone. Useful when attempting to prevent browser
* fingerprinting. For example, "America/Los_Angeles" would force Pacific
* Time, "Asia/Kolkata" would force Indian Standard Time, etc. Read:
*
* https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
*/
clockTimeZone: undefined,
/**
* Type this to toggle the help menu.
*/
helpKey: '?',
/**
* 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, Bing, DuckDuckGo, Ecosia and Yahoo for cats at the same
* time, you'd type "q'cats".
*/
scripts: {
q: ['bin', 'yah', 'eco', 'ddg', '*'],
},
/**
* 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 DuckDuckGo search API (async)
*
* To disable suggestions, remove all influencers.
*/
suggestionInfluencers: [
{ name: 'Default', limit: 4 },
{ name: 'History', limit: 4, minChars: 2 },
{ name: 'DuckDuckGo', limit: 4, minChars: 2 },
],
/**
* Max number of suggestions that will ever be shown.
*/
suggestionLimit: 4,
/**
* Default search suggestions for the specified queries.
*/
suggestionDefaults: {
0: ["0'8000", "0'8080"],
a: ['a/cognito/v2/idp/user-pools', 'a/dynamodbv2#item-explorer'],
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/trending', 'g/ossu', 'g/cadejscroggins/tilde', 'gist.github.com'],
j: ['j/popular', 'j/popular/lastweek', 'j/tags'],
k: ['k/u/1', 'k/u/2'],
m: ['m/mail/u/1', 'm/mail/u/2'],
o: ['o/discover/sets/new-for-you', 'o/discover/sets/weekly'],
p: ['p/beema.finance'],
r: ['r/r/startpages', 'r/r/unixporn', 'r/r/onebag', 'r/r/fujix'],
s: ['s/collection/tracks', 's/playlist/37i9dQZEVXcXr3r4FYT3J7'],
u: ['u/explore', 'u/backgrounds'],
y: ['y/feed/trending'],
},
/**
* The name, key, url, search path and color for your commands. If none of
* the specified keys are matched, the * key is used. Commands without a
* name don't show up in the help menu.
*
* "hues" is an array of HSL hues that will be converted into a linear
* gradient. CSS variables defined below, prefixed with --command-color-,
* determine the saturation and lightness for each generated color.
*
* "color", if defined, will be applied to the command as-is.
*/
commands: [
{
key: '*',
search: '/search?q={}',
url: 'https://www.google.com',
},
{
key: 'bin',
search: '/search?q={}',
url: 'https://www.bing.com',
},
{
key: 'ddg',
search: '/?q={}',
url: 'https://duckduckgo.com',
},
{
key: 'eco',
search: '/search?q={}',
url: 'https://www.ecosia.org',
},
{
key: 'yah',
search: '/search?p={}',
url: 'https://search.yahoo.com',
},
{
hues: ['0', '0'],
key: 'x',
name: 'Netflix',
search: '/search?q={}',
url: 'https://www.netflix.com/browse',
},
{
hues: ['0', '350'],
key: 'c',
name: 'Calendar',
search: '/calendar/u/0/r/search?q={}',
url: 'https://calendar.google.com/calendar/u/0/r',
},
{
hues: ['5', '345'],
key: 'y',
name: 'YouTube',
search: '/results?search_query={}',
url: 'https://youtube.com/feed/subscriptions',
},
{
hues: ['355', '335'],
key: '=',
name: 'Wolfram',
search: '/input/?i={}',
url: 'https://www.wolframalpha.com',
},
{
hues: ['349', '329'],
key: '1',
name: 'OPL',
search: '/?s={}',
url: 'https://onepagelove.com/inspiration',
},
{
hues: ['337', '317'],
key: 'b',
name: 'Dribbble',
search: '/search/{}',
url: 'https://dribbble.com/shots/popular',
},
{
hues: ['266', '286'],
key: 'f',
name: 'Figma',
url: 'https://www.figma.com/files/recent',
},
{
hues: ['230', '280'],
key: 'i',
name: 'Instagram',
url: 'https://www.instagram.com',
},
{
hues: ['264', '244'],
key: 'v',
name: 'Twitch',
url: 'https://www.twitch.tv/directory/following',
},
{
hues: ['254', '234'],
key: 'r',
name: 'Reddit',
search: '/search?q={}',
url: 'https://www.reddit.com',
},
{
hues: ['226', '236'],
key: 'e',
name: 'Disney',
url: 'https://www.disneyplus.com/home',
},
{
hues: ['217', '237'],
key: 'w',
name: 'Wikipedia',
search: '/wiki/{}',
url: 'https://en.wikipedia.org/wiki/Main_Page',
},
{
hues: ['214', '234'],
key: 'g',
name: 'GitHub',
search: '/search?q={}',
url: 'https://github.com',
},
{
hues: ['243', '204'],
key: 'p',
name: 'Plausible',
url: 'https://plausible.io',
},
{
hues: ['192', '232'],
key: 'z',
name: 'Telegram',
url: 'https://web.telegram.org/z/',
},
{
hues: ['201', '221'],
key: 'l',
name: 'LinkedIn',
search: '/search/results/all/?keywords={}',
url: 'https://www.linkedin.com',
},
{
hues: ['198', '218'],
key: ';',
name: 'MDN',
search: '/en-US/search?q={}',
url: 'https://developer.mozilla.org/en-US',
},
{
hues: ['217', '197'],
key: 'm',
name: 'Mail',
search: '/mail/u/0/?q={}#search/{}',
url: 'https://mail.google.com/mail/u/0',
},
{
hues: ['203', '183'],
key: 't',
name: 'Twitter',
search: '/search?q={}',
url: 'https://twitter.com/home',
},
{
hues: ['166', '146'],
key: '+',
name: 'Khan',
search: '/search?page_search_query={}',
url: 'https://www.khanacademy.org',
},
{
hues: ['136', '156'],
key: 'd',
name: 'Drive',
search: '/drive/u/0/search?q={}',
url: 'https://drive.google.com/drive/u/0/my-drive',
},
{
hues: ['124', '164'],
key: 's',
name: 'Spotify',
search: '/search/{}',
url: 'https://open.spotify.com',
},
{
hues: ['90'],
key: 'j',
name: 'Hypem',
search: '/search/{}',
url: 'https://hypem.com/latest',
},
{
hues: ['45', '40'],
key: 'k',
name: 'Keep',
search: '/u/0/#search/text={}',
url: 'https://keep.google.com/u/0',
},
{
hues: ['36', '26'],
key: 'a',
name: 'AWS',
url: 'https://us-west-2.console.aws.amazon.com/console/home',
},
{
hues: ['32', '22'],
key: 'o',
name: 'Sounds',
search: '/search?q={}',
url: 'https://soundcloud.com/discover',
},
{
hues: ['13', '33'],
key: 'h',
name: 'Hunt',
search: '/search?q={}',
url: 'https://www.producthunt.com',
},
{
hues: ['4', '24'],
key: 'n',
name: 'Notion',
url: 'https://www.notion.so',
},
{
key: 'u',
name: 'Unsplash',
search: '/search/{}',
url: 'https://unsplash.com/images',
},
{
key: '0',
name: 'Local',
url: 'http://localhost:3000',
},
].map((command) => {
const hsla = (hue, saturation = 'var(--command-color-saturation)') =>
`hsla(${hue}, ${saturation}, var(--command-color-lightness), 1)`;
if (command.color || !command.name) {
return command;
} else if (!Array.isArray(command.hues) || !command.hues.length) {
command.color = hsla(0, '0%');
} else if (command.hues.length === 1) {
command.color = hsla(command.hues[0]);
} else {
const c = command.hues.reduce((a, h) => `${a}, ${hsla(h)}`, '');
command.color = `linear-gradient(135deg ${c})`;
}
return command;
}),
};
</script>
<style>
/* themes */
.ashes-dark {
--base-background: #1c2023;
--base-foreground: #c7ccd1;
--command-color-complementary: var(--base-background);
--command-color-highlight-foreground: var(--base-foreground);
--command-color-lightness: 68%;
--command-color-saturation: 25%;
--help-command-background: #272e32;
}
.gruvbox-dark {
--base-background: #282828;
--base-foreground: #ebdbb2;
--command-color-complementary: #fbf1c7;
--command-color-highlight-foreground: var(--base-background);
--command-color-lightness: 45%;
--command-color-saturation: 34%;
--help-command-background: #3c3836;
}
.nord-dark {
--base-background: #2e3440;
--base-foreground: #d8dee9;
--command-color-complementary: #fff;
--command-color-highlight-foreground: var(--help-command-background);
--command-color-lightness: 56%;
--command-color-saturation: 42%;
--help-command-background: #3b4252;
}
.ocean-light {
--base-background: #eff1f5;
--base-foreground: #4f5b66;
--command-color-complementary: var(--base-background);
--command-color-highlight-foreground: var(--base-foreground);
--command-color-lightness: 56%;
--command-color-saturation: 32%;
--help-command-background: #e4e6ea;
}
.tilde-dark {
--base-background: #111;
--base-foreground: #ddd;
--command-color-complementary: #eee;
--command-color-highlight-foreground: var(--base-background);
--command-color-lightness: 50%;
--command-color-saturation: 40%;
--help-command-background: #222;
}
.tokyo-dark {
--base-background: #1a1b26;
--base-foreground: #a9b1d6;
--command-color-complementary: var(--base-background);
--command-color-highlight-foreground: var(--base-foreground);
--command-color-lightness: 72%;
--command-color-saturation: 89%;
--help-command-background: #24283b;
}
/* root variables */
:root {
--base-border-radius: 2px;
--base-font-family: -apple-system, BlinkMacSystemFont, Helvetica Neue,
Helvetica, Ubuntu, Roboto, Noto, Segoe UI, Arial, sans-serif;
--base-font-size: 17px;
--base-font-weight-black: 900;
--base-font-weight-bold: 700;
--base-font-weight-normal: 400;
--clock-font-size: 3rem;
--command-height: 4rem;
--command-padding: calc(var(--command-height) / 2 - 1ch / 2);
--help-grid-columns: 1;
--help-grid-gap: 1rem;
--search-input-font-size: 2rem;
--search-input-text-align: left;
--suggestion-height: 3rem;
--suggestion-padding: calc(var(--suggestion-height) / 2 - 1ch / 2);
--suggestions-flex-direction: column;
}
@media (min-width: 600px) {
:root {
--clock-font-size: 5rem;
--help-grid-columns: 2;
}
}
@media (min-width: 900px) {
:root {
--clock-font-size: 7rem;
--help-grid-columns: 3;
--search-input-font-size: 3rem;
--suggestions-flex-direction: row;
}
}
@media (min-width: 1400px) {
:root {
--help-grid-columns: 5;
}
}
/* reset */
* {
box-sizing: border-box;
}
html {
font-family: var(--base-font-family);
font-size: var(--base-font-size);
font-weight: var(--base-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(--base-font-family);
font-weight: var(--base-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;
}
/* reusable classes */
.center {
display: flex;
width: 100%;
height: 100%;
}
.center > * {
margin: auto;
}
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: auto;
}
/* main styles */
.clock {
display: block;
font-size: var(--clock-font-size);
cursor: pointer;
}
.help {
background: var(--base-background);
visibility: hidden;
z-index: 1;
}
body.help .help {
visibility: visible;
}
body.form .help {
visibility: hidden;
}
.command-list {
display: grid;
grid-gap: var(--help-grid-gap);
grid-template-columns: repeat(var(--help-grid-columns), 1fr);
padding: 6rem 0;
}
.command {
display: flex;
background: var(--help-command-background);
border-radius: var(--base-border-radius);
opacity: 0.9;
line-height: var(--command-height);
text-decoration: none;
}
.command:hover {
opacity: 1;
}
.command:hover .command-key {
background: transparent;
}
.command-key {
width: var(--command-height);
border-radius: var(--base-border-radius);
color: var(--command-color-complementary);
font-weight: var(--base-font-weight-bold);
text-align: center;
}
.command-name {
padding: 0 var(--command-padding);
}
.search-form {
background: var(--base-background);
visibility: hidden;
}
body.form .search-form {
visibility: visible;
}
body.color .search-form {
color: var(--command-color-complementary);
}
.search-form-content {
width: 100%;
}
.search-input {
width: 100%;
font-size: var(--search-input-font-size);
font-weight: var(--base-font-weight-black);
text-align: center;
}
.search-suggestions {
display: none;
justify-content: center;
align-items: center;
flex-direction: var(--suggestions-flex-direction);
flex-wrap: wrap;
margin-top: 2rem;
overflow: hidden;
}
body.suggestions .search-suggestions {
display: flex;
}
.search-suggestion {
border-radius: var(--base-border-radius);
cursor: pointer;
font-weight: var(--base-font-weight-bold);
height: var(--suggestion-height);
padding: 0 var(--suggestion-padding);
white-space: nowrap;
}
.search-suggestion.highlight {
background: var(--base-foreground);
color: var(--base-background);
}
body.color .search-suggestion.highlight {
background: var(--command-color-complementary);
color: var(--command-color-highlight-foreground);
}
.search-suggestion-match {
font-weight: var(--base-font-weight-normal);
}
</style>
<div class="center">
<time class="clock" id="clock" />
</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" />
</div>
</form>
<aside class="center help overlay" id="help" />
<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)),
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._timeZone = options.timeZone;
this._twentyFourHour = options.twentyFourHour;
this._setTime = this._setTime.bind(this);
this._el.addEventListener('click', options.onClick);
this._start();
}
_setTime() {
const date = new Date();
this._el.innerHTML = date
.toLocaleString('en-US', {
hour12: !this._twentyFourHour,
hour: 'numeric',
minute: 'numeric',
second: this._showSeconds ? 'numeric' : undefined,
timeZone: this._timeZone,
})
.replace(this._amPm ? '' : / (AM|PM)/, '')
.replace(/:/g, this._delimiter)
.replace(/^0/, '');
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._buildCommands();
this._registerEvents();
}
toggle(show) {
this._toggled = typeof show !== 'undefined' ? show : !this._toggled;
if (this._toggled) $.bodyClassAdd('help');
else $.bodyClassRemove('help');
}
_buildCommands() {
const list = document.createElement('ul');
list.classList.add('command-list');
list.insertAdjacentHTML(
'beforeend',
this._commands.reduce((acc, { color, name, key, url }, i) => {
if (!name) return acc;
const target = this._newTab ? '_blank' : '_self';
return `
${acc}
<style>
.command-key-${i},
.command-${i}:hover {
background: ${color};
color: var(--command-color-complementary);
}
</style>
<li>
<a class="command command-${i}" href="${url}" target="${target}" rel="noopener noreferrer">
<span class="command-key command-key-${i}">${key}</span>
<span class="command-name">${name}</span>
</a>
</li>
`;
}, '')
);
this._el.appendChild(list);
}
_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;
}
addItem() {
return undefined;
}
getSuggestions() {
return Promise.resolve([]);
}
_addSearchPrefix(items, { isSearch, key, split }) {
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({ raw }) {
return new Promise((resolve) =>
resolve((this._suggestionDefaults[raw] || []).slice(0, this._limit))
);
}
}
class DuckDuckGoInfluencer extends Influencer {
constructor() {
super(...arguments);
}
getSuggestions(parsedQuery) {
const { lower, query } = parsedQuery;
if (this._isTooShort(query)) return Promise.resolve([]);
return new Promise((resolve) => {
window.autocompleteCallback = (res) =>
resolve(
this._addSearchPrefix(
res
.map((i) => i.phrase)
.filter((s) => s.toLowerCase() !== lower)
.slice(0, this._limit),
parsedQuery
)
);
const script = document.createElement('script');
script.src = `https://duckduckgo.com/ac/?callback=autocompleteCallback&q=${query}`;
$.el('head').appendChild(script);
});
}
}
class HistoryInfluencer extends Influencer {
constructor() {
super(...arguments);
this._storeName = 'history';
}
addItem({ isPath, lower }) {
if (isPath || this._isTooShort(lower)) return;
let exists;
const history = this._getHistory().map(([item, count]) => {
const match = item === lower;
if (match) exists = true;
return [item, match ? count + 1 : count];
});
if (!exists) history.push([lower, 1]);
this._setHistory(history.sort((a, b) => b[1] - a[1]));
}
getSuggestions(parsedQuery) {
const { lower } = parsedQuery;
if (this._isTooShort(lower)) return Promise.resolve([]);
return new Promise((resolve) =>
resolve(
this._addSearchPrefix(
this._getHistory()
.filter(([item]) => item !== lower && item.includes(lower))
.slice(0, this._limit)
.map(([item]) => item),
parsedQuery
)
)
);
}
_getHistory() {
this._history =
this._history ||
JSON.parse(localStorage.getItem(this._storeName)) ||
[];
return this._history;
}
_setHistory(history) {
this._history = history;
localStorage.setItem(this._storeName, JSON.stringify(history));
}
}
class Suggester {
constructor(options) {
this._el = $.el('#search-suggestions');
this._influencers = options.influencers;
this._limit = options.limit;
this._parsedQuery = '';
this._highlightedSuggestion = 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(parsedQuery) {
this._influencers.forEach((i) => i.addItem(parsedQuery));
this._clearSuggestions();
}
suggest(parsedQuery) {
this._parsedQuery = parsedQuery;
this._highlightedSuggestion = null;
if (!parsedQuery.query) {
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._parsedQuery.query), '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._highlightedSuggestion = 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._parsedQuery)
);
}
_handleKeydown(e) {
if ($.isDown(e)) this._focusNext(e);
if ($.isUp(e)) this._focusPrevious(e);
}
_highlight(el, e) {
this._unHighlight();
if (!el) return;
this._highlightedSuggestion = el.getAttribute('data-suggestion');
this._onHighlight(this._highlightedSuggestion);
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._highlightedSuggestion) return;
this._highlight(
$.el(`[data-suggestion="${this._highlightedSuggestion}"]`)
);
}
_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.raw = query.trim();
res.query = res.raw;
res.lower = res.raw.toLowerCase();
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 splitSearch = res.query.split(this._searchDelimiter);
const splitPath = res.query.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);
res.lower = res.query.toLowerCase();
script.forEach((command) =>
res.push(this.parse(`${command}${res.split}${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);
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.lower = res.query.toLowerCase();
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;
const domainRegex = new RegExp(`${domain}$`);
return commands.find((c) => domainRegex.test(new URL(c.url).hostname))
?.color;
}
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._helpKey = options.helpKey;
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 === this._helpKey;
const parsedQuery = this._parseQuery(newQuery);
this._inputElVal = newQuery;
this._suggester.suggest(parsedQuery);
this._setColorsFromQuery(newQuery);
if (!newQuery || isHelp) this.hide();
if (isHelp) this._toggleHelp();
if (this._instantRedirect && parsedQuery.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);
if (color) {
this._formEl.style.background = color;
$.bodyClassAdd('color');
} else {
this._formEl.style.background = '';
$.bodyClassRemove('color');
}
}
_submitForm(e) {
if (e) e.preventDefault();
const parsedQuery = this._parseQuery(this._inputEl.value);
if (parsedQuery.length) {
parsedQuery.forEach((r) => this._redirect(r.redirect, true));
} else {
this._redirect(parsedQuery.redirect);
}
this._suggester.success(parsedQuery);
this.hide();
}
_submitWithValue(value) {
this._inputEl.value = value;
this._submitForm();
}
}
$.bodyClassAdd(CONFIG.theme);
const help = new Help({
commands: CONFIG.commands,
newTab: CONFIG.queryNewTab,
});
const queryParser = new QueryParser({
commands: CONFIG.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,
suggestionDefaults: CONFIG.suggestionDefaults,
});
});
const suggester = new Suggester({
influencers,
limit: CONFIG.suggestionLimit,
});
const form = new Form({
helpKey: CONFIG.helpKey,
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,
timeZone: CONFIG.clockTimeZone,
twentyFourHour: CONFIG.clockTwentyFourHour,
});
</script>