Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Implemented keybind module to handle keyboard shortcuts #186

Merged
merged 7 commits into from
Apr 2, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 33 additions & 17 deletions frontend/src/lib/components/Searchbar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@
import MagnifyingGlass from '~icons/heroicons/magnifying-glass';
import Button from '$lib/components/Button.svelte';
import { api, type HighlightedFragment } from '$lib/api';
import { safeSearchStore, hostRankingsStore, postSearchStore } from '$lib/stores';
import {
safeSearchStore,
hostRankingsStore,
postSearchStore,
useKeyboardShortcuts,
} from '$lib/stores';
import { browser } from '$app/environment';
import { derived } from 'svelte/store';
import { compressRanked, rankingsToRanked } from '$lib/rankings';
import { twJoin } from 'tailwind-merge';
import { P, match } from 'ts-pattern';
import { Keybind } from '$lib/keybind';

export let autofocus = false;

Expand Down Expand Up @@ -57,33 +63,42 @@
didChangeInput = false;
};

const onKeydown = (ev: KeyboardEvent) => {
match(ev.key)
.with('ArrowUp', () => {
ev.preventDefault();
moveSelection(-1);
})
.with('ArrowDown', () => {
ev.preventDefault();
moveSelection(1);
})
.with('Enter', () => {
hasFocus = false;
})
.otherwise(() => {
didChangeInput = true;
});
let keybind = new Keybind([
{ key: 'ArrowDown', callback: () => moveSelection(1) },
{ key: 'ArrowUp', callback: () => moveSelection(-1) },
{ key: 'Enter', callback: () => (hasFocus = true) },
]);

const onKeydown = (e: KeyboardEvent) => {
if (keybind.bindings.find((binding) => binding.key === e.key)) {
keybind.onKeyDown(e, $useKeyboardShortcuts);
} else {
didChangeInput = true;
}
};

let suggestionsDiv: HTMLDivElement | undefined;
let hasFocus = autofocus;

let formElem: HTMLFormElement;
let inputElem: HTMLInputElement;
export const getForm = () => formElem;
export const select = () => inputElem.select();
export const userQuery = () => lastRealQuery;
export const search = (q: string) => {
if (formElem && inputElem) {
inputElem.value = q;
formElem.submit();
}
};
</script>

<form
action="/search"
class="flex w-full justify-center"
id="searchbar-form"
method={$postSearchStore ? 'POST' : 'GET'}
bind:this={formElem}
>
<input type="hidden" value={$safeSearchStore ? 'true' : 'false'} name="ss" />
<input type="hidden" value={$compressedRanked} name="sr" id="host_rankingsUuid" />
Expand Down Expand Up @@ -124,6 +139,7 @@
}}
bind:value={query}
on:keydown={onKeydown}
bind:this={inputElem}
/>
<div class="h-full py-0.5 pr-0.5">
<Button _class="py-0 h-full" type="submit">search</Button>
Expand Down
239 changes: 239 additions & 0 deletions frontend/src/lib/keybind.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import { match } from 'ts-pattern';
import Result from '../routes/search/Result.svelte';
import type Searchbar from './components/Searchbar.svelte';
import type SpellCorrection from '../routes/search/SpellCorrection.svelte';

export const Keys = [
'j',
'd',
'k',
'h',
'v',
't',
'm',
'l',
'o',
's',
"'",
'/',
'Control',
'Enter',
'ArrowUp',
'ArrowDown',
];
export type Key = (typeof Keys)[number];

// The type used to declare the key downs that will trigger the callback
export interface KeybindCallback {
key: Key;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
callback: (e: KeyboardEvent, context: any) => void;
Copy link
Contributor Author

@lamemakes lamemakes Mar 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

had a bit of a battle with typing here. I tried to create a generic and use it both here (ie. context: Context) and then again on line 39 with export class Keybind<Context> but everything I tried complained or didn't register that they would be the same type when implemented. this has worked fine but totally open to any suggestions

shift?: boolean;
ctrl?: boolean;
alt?: boolean;
}

/**
* Used to create and interface with key bindings
*/
export class Keybind<T> {
// All of the keybindings to their callbacks
public bindings: KeybindCallback[];
private bindingEntries: Key[];

/**
* Constructor
*
* @param bindings An array of {@link KeybindCallback}s to be evaluated and utilized with {@link Keybind:onKeyDown}.
*/
constructor(bindings: KeybindCallback[]) {
this.bindings = bindings;
this.bindingEntries = this.bindings.map((x) => x.key);
}

/**
* Attempt to convert a string to {@link Keys} enum
*
* @param str - The string to attempt to convert
* @returns undefined if unable to convert, otherwise {@link Keys}
*/
private keyEnumFromString(str: string): Key | undefined {
for (const key of Keys) {
if (key == str) return key;
}

return undefined;
}

/**
* Handler for `keydown` events
*
* @remarks
* Requires a wrapper function to pass in `useKeyboardShortcuts` boolean store.
*
* Will run the given callback if all requirements are met from an item in the
* previously given {@link KeybindCallback} array.
*
* @param e - The `keydown` event of `KeyboardEvent` type
* @param useKeyboardShortcuts - A boolean of the user's `useKeyboardShortcuts` preference
*/
onKeyDown(e: KeyboardEvent, useKeyboardShortcuts: boolean, context?: T) {
if (!useKeyboardShortcuts || e.repeat) return;

const enum_key = this.keyEnumFromString(e.key);

if (!(enum_key && this.bindingEntries.includes(enum_key))) return;

// Conditionals to be able to later compare to a potentially undefined binding.shift/.al/.ctrl
const shift = e.shiftKey ? true : undefined;
const ctrl = e.ctrlKey ? true : undefined;
const alt = e.altKey ? true : undefined;

const binding = this.bindings.find((binding) => binding.key === enum_key);
if (binding && binding.alt == alt && binding.shift == shift && binding.ctrl == ctrl) {
e.preventDefault();
binding.callback(e, context);
}
}
}

/*
Search callbacks
*/

export type Refs = {
results: Result[];
searchbar: Searchbar;
spellCorrection?: SpellCorrection;
};

type Direction = 'up' | 'down';

/**
* Utilized to bring the next/previous result into focus
*
* @remarks
* Utilizes the element that is currently in focus and the given
* direction to determine where and how to traverse the array of
* results. Moves a single result per call.
*
* @param direction - How to maneuver through the result list (up/down), indicated by {@link Direction}
*/
const navigateResults = (direction: Direction, context: Refs) => {
const currentActive = document.activeElement;
const results = context.results;

let newIndex = 0;
const currentResultIndex = results.findIndex((el) => el.getMainDiv().contains(currentActive));

if (currentResultIndex > -1) {
newIndex =
currentResultIndex +
match(direction)
.with('up', () => -1)
.with('down', () => 1)
.exhaustive();

if (newIndex < 0 || newIndex >= results.length) {
newIndex = currentResultIndex;
}
}

results[newIndex].getMainResultLink().focus();
};

const focusNextResult = (_e: KeyboardEvent, context: Refs) => navigateResults('down', context);

const focusPrevResult = (_e: KeyboardEvent, context: Refs) => navigateResults('up', context);

const focusMainResult = (_e: KeyboardEvent, context: Refs) => {
if (context.results[0]) context.results[0].getMainResultLink().focus();
};

const selectSearchBar = (_e: KeyboardEvent, context: Refs) => {
context.searchbar.select();
};

/**
* Scroll to the top of the window and reset focus
*/
lamemakes marked this conversation as resolved.
Show resolved Hide resolved
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur(); // reset focus
}
};

/**
* Redirect to the currently focused result
*
* @param _e - The triggering {@link KeyboardEvent}
*/
const openResult = (e: KeyboardEvent, context: Refs) => {
if (e.target && e.target instanceof HTMLElement) {
const activeElement = e.target as HTMLElement;
const currentResultIndex = context.results.findIndex((el) =>
el.getMainDiv().contains(activeElement),
);
if (currentResultIndex > -1) context.results[currentResultIndex].getMainResultLink().open();
}
};

/**
* Open the currently focused result in a new tab
*
* @remarks
* Requires pop-ups to be allowed for the window
*
* @param e - The triggering {@link KeyboardEvent}
*/
lamemakes marked this conversation as resolved.
Show resolved Hide resolved
const openResultInNewTab = (e: KeyboardEvent, context: Refs) => {
if (e.target && e.target instanceof HTMLElement) {
const activeElement = e.target as HTMLElement;
const currentResultIndex = context.results.findIndex((el) =>
el.getMainDiv().contains(activeElement),
);
if (currentResultIndex > -1)
context.results[currentResultIndex].getMainResultLink().openInNewTab();
}
};

/**
* Do a domain search using the domain of the currently focused result
*
* @param e - The triggering {@link KeyboardEvent}
*/
lamemakes marked this conversation as resolved.
Show resolved Hide resolved
const domainSearch = (e: KeyboardEvent, context: Refs) => {
if (e.target && e.target instanceof HTMLElement) {
const activeElement = e.target as HTMLElement;
const currentResultIndex = context.results.findIndex((el) =>
el.getMainDiv().contains(activeElement),
);
if (currentResultIndex > -1) {
const focusedResult = context.results[currentResultIndex];
const query = context.searchbar.userQuery();
const domain = focusedResult.getMainResultLink().getUrl().hostname;
const domainQuery = `site:${domain}`;

// Only run the domain query if it isn't already in the query
if (!query.includes(domainQuery)) context.searchbar.search(`${query} ${domainQuery}`);
}
}
};

const openSpellCorrection = (_e: KeyboardEvent, context: Refs) => {
if (context.spellCorrection) context.spellCorrection.open();
};

// Packaged callbacks to keep imports clean
export const searchCb = {
focusNextResult,
focusPrevResult,
focusMainResult,
selectSearchBar,
scrollToTop,
openResult,
openResultInNewTab,
domainSearch,
openSpellCorrection,
};
3 changes: 3 additions & 0 deletions frontend/src/lib/stores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ export const markPagesWithAdsStore = writableLocalStorage<boolean>(MARK_PAGES_WI
const RESULTS_IN_NEW_TAB_KEY = 'resultsInNewTab';
export const resultsInNewTab = writableLocalStorage<boolean>(RESULTS_IN_NEW_TAB_KEY, false);

const USE_KEYBOARD_SHORTCUTS = 'useKeyboardShortcuts';
export const useKeyboardShortcuts = writableLocalStorage<boolean>(USE_KEYBOARD_SHORTCUTS, true);

const MARK_PAGES_WITH_PAYWALL_KEY = 'markPagesWithPaywall';
export const markPagesWithPaywallStore = writableLocalStorage<boolean>(
MARK_PAGES_WITH_PAYWALL_KEY,
Expand Down
Loading
Loading