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 1 commit
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
246 changes: 246 additions & 0 deletions frontend/src/lib/keybind.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
/**
* Enum to represent the key strings provided by {@link KeyboardEvent}
*/
export enum Keys {
J = 'j',
D = 'd',
K = 'k',
H = 'h',
V = 'v',
T = 't',
M = 'm',
L = 'l',
O = 'o',
S = 's',
SINGLE_QUOTE = "'",
FORWARD_SLASH = '/',
CTRL = 'Control',
ENTER = 'Enter',
ARROW_UP = 'ArrowUp',
ARROW_DOWN = 'ArrowDown',
}
lamemakes marked this conversation as resolved.
Show resolved Hide resolved

// The type used to declare the key downs that will trigger the callback
export interface KeybindCallback {
key: Keys;
callback: (e: KeyboardEvent) => void;
shift?: boolean;
ctrl?: boolean;
alt?: boolean;
}

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

/**
* 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): Keys | undefined {
for (const [_, key] of Object.entries(Keys)) {
if (key == str) return key;
}

return undefined;
}
Copy link
Member

@mikkeldenker mikkeldenker Mar 23, 2024

Choose a reason for hiding this comment

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

When Key is a type, this can be written as

private keyFromString(str: string): Key | undefined {
  return Keys.find((key) => key === str);
}


/**
* 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) {
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);
}
}
}

/*
Search callbacks
*/

/**
* Used to indicate what direction {@link navigateResults} should go (up/down)
*/
type Direction = 1 | -1;
Copy link
Member

Choose a reason for hiding this comment

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

type Direction = "up" | "down";

is a bit more descriptive. The type is also pretty self-explanatory and the TsDoc string therefore only clutters it a bit and could be removed


/**
* 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) => {
const currentActive = document.activeElement;

const results = [...document.getElementsByClassName('result')] as HTMLElement[];
let currentResultIndex: number | null = null;
let newIndex = 0;

results.forEach((el, index) => {
if (el.contains(currentActive)) currentResultIndex = index;
});

if (currentResultIndex != null) {
newIndex = currentResultIndex + direction;
if (0 > newIndex || newIndex >= results.length) {
newIndex = currentResultIndex;
}
}

const nextResult = results[newIndex];

// Get the anchor element and focus it
(nextResult.getElementsByClassName('result-main-link')[0] as HTMLElement)?.focus();
};
lamemakes marked this conversation as resolved.
Show resolved Hide resolved

/**
* Wrapper for {@link navigateResults} that will focus on the next result
*/
lamemakes marked this conversation as resolved.
Show resolved Hide resolved
const focusNextResult = () => navigateResults(1);
lamemakes marked this conversation as resolved.
Show resolved Hide resolved

/**
* Wrapper for {@link navigateResults} that will focus on the previous result
*/
lamemakes marked this conversation as resolved.
Show resolved Hide resolved
const focusPrevResult = () => navigateResults(-1);
lamemakes marked this conversation as resolved.
Show resolved Hide resolved

/**
* Focus the first result (if any)
*/
lamemakes marked this conversation as resolved.
Show resolved Hide resolved
const focusMainResult = () => {
const results = document.getElementsByClassName('result-main-link');
if (results.length > 0) {
(results[0] as HTMLElement).focus();
}
Copy link
Member

Choose a reason for hiding this comment

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

should take Refs as a parameter and use this instead of getElementsByClassName

};

/**
* Focus the search bar
*/
lamemakes marked this conversation as resolved.
Show resolved Hide resolved
const focusSearchBar = () => {
(document.getElementById('searchbar') as HTMLInputElement)?.select();
Copy link
Member

Choose a reason for hiding this comment

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

should take Refs as a parameter (with SearchBar added to the type) and use this instead of getElemtById

};

/**
* 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}
*/
lamemakes marked this conversation as resolved.
Show resolved Hide resolved
const openResult = (e: KeyboardEvent) => {
if ((e.target as HTMLElement).className.includes('result-main-link')) {
const resultLink = e.target as HTMLAnchorElement;
window.location.href = resultLink.href;
} else {
const results = document.getElementsByClassName('result-main-link');
if (results.length > 0) {
window.location.href = (results[0] as HTMLAnchorElement).href;
}
}
};
Copy link
Member

Choose a reason for hiding this comment

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

could be written as

const openResult = (_e: KeyboardEvent, refs: Refs) => {
  const focusedResult: Result | undefined = refs.results.find((result) => result.hasFocus());
  if (focusedResult) {
    focusedResult.open();
  }
};

with related .open() and .hasFocus() functions in Result.svelte


/**
* 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) => {
// Ensure target event is a result url
if ((e.target as HTMLElement).className.includes('result-main-link')) {
const resultLink = e.target as HTMLAnchorElement;
window.open(resultLink.href, '_blank', 'noopener');
}
};
Copy link
Member

Choose a reason for hiding this comment

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

could be written as

const openResultInNewTab = (_e: KeyboardEvent, refs: Refs) => {
  const focusedResult: Result | undefined = refs.results.find((result) => result.hasFocus());
  if (focusedResult) {
    focusedResult.openInNewTab();
  }
}

with related .openResultInNewTab() and .hasFocus() functions in Result.svelte


/**
* 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) => {
// Ensure target event is a result url
if ((e.target as HTMLElement).className.includes('result-main-link')) {
const searchBar = document.getElementById('searchbar') as HTMLInputElement;
if (searchBar) {
// Parse the result url, pull out the hostname then resubmit search
const resultLink = new URL((e.target as HTMLAnchorElement)?.href);
searchBar.value += ` site:${resultLink.hostname}`;
searchBar.form?.submit();
}
}
Copy link
Member

Choose a reason for hiding this comment

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

should not directly reference the dom elements but instead call related .userQuery() and search(`${query} site:${resultLink.hostname}`) methods. ResultLink.svelte should also have a .url() method

Copy link
Contributor Author

Choose a reason for hiding this comment

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

can you expand on .userQuery() & search(...)? I couldn't find any userQuery methods anywhere and was just going to pull it from PageData and pass it in a wrapper. As for search I was going to use this if that's what you were referring to. Wasn't sure if these are existing need to be built out.

Copy link
Member

Choose a reason for hiding this comment

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

Yea I think it would be easiest to create those as new methods in Searchbar.svelte

export const userQuery = () => lastRealQuery;
export const search = (q: string) => {
  if (formElem && inputElem) {
    inputElem.value = q;
    formElem.submit();
  }
};

with an added formElem bind

let formElem: HTMLFormElement | undefined;
...
<form
  action="/search"
  class="flex w-full justify-center"
  id="searchbar-form"
  bind:this={formElem}
  method={$postSearchStore ? 'POST' : 'GET'}
>

The search function in search.ts effectively just calls the api and returns the results. Calling the search function in Searchbar.svelte would ensure that the user interface gets updated correctly with the new results.

domainSearch can then be something like

const domainSearch = (_e: KeyboardEvent, refs: Refs) => {
  const focusedResult: Result | undefined = refs.results.find((result) => result.hasFocus());

  if (refs.searchbar && focusedResult) {
    const query: string = refs.searchbar.userQuery();
    const url: string = focusedResult.getMainLink().url();
    const domain = new URL(url).hostname;

    const domainQuery = `site:${domain}`;

    if (query.includes(domainQuery)) {
      refs.searchbar.search(query);
    } else {
      refs.searchbar.search(`${query} site:${domain}`);
    }
  }
}

Where getMainLink would be a new method in Result.svelte

export const getMainLink = () => mainLink

and url would be a new method in ResultLink.svelte

export const url = () => href;

};

/**
* Redirect to the `Did you mean:` link (if exists)
*/
lamemakes marked this conversation as resolved.
Show resolved Hide resolved
const goToMisspellLink = () => {
const msLink = document.getElementById('misspell-link');
if (msLink) window.location.href = (msLink as HTMLAnchorElement).href;
Copy link
Member

Choose a reason for hiding this comment

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

Should use Refs with a binding for the mispell link added to the type instead of .getElementById and could then also use a .open() method

};

// Packaged callbacks to keep imports clean
export const searchCb = {
focusNextResult,
focusPrevResult,
focusMainResult,
focusSearchBar,
scrollToTop,
openResult,
openResultInNewTab,
domainSearch,
goToMisspellLink,
};
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
30 changes: 29 additions & 1 deletion frontend/src/routes/search/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
import RegionSelect from '$lib/components/RegionSelect.svelte';
import type { DisplayedWebpage } from '$lib/api';
import { onMount } from 'svelte';
import { searchQueryStore } from '$lib/stores';
import { searchQueryStore, useKeyboardShortcuts } from '$lib/stores';
import { Keys, Keybind, searchCb } from '$lib/keybind';
import { flip } from 'svelte/animate';
import Result from './Result.svelte';
import Sidebar from './Sidebar.svelte';
Expand All @@ -24,6 +25,30 @@

let modal: { top: number; left: number; site: DisplayedWebpage } | undefined;

const keybind = new Keybind([
{ key: Keys.J, callback: searchCb.focusNextResult },
{ key: Keys.ARROW_DOWN, callback: searchCb.focusNextResult },
{ key: Keys.K, callback: searchCb.focusPrevResult },
{ key: Keys.ARROW_UP, callback: searchCb.focusPrevResult },
{ key: Keys.H, callback: searchCb.focusSearchBar },
{ key: Keys.FORWARD_SLASH, callback: searchCb.focusSearchBar },
{ key: Keys.V, callback: searchCb.openResultInNewTab },
{ key: Keys.SINGLE_QUOTE, callback: searchCb.openResultInNewTab },
{ key: Keys.T, callback: searchCb.scrollToTop },
{ key: Keys.D, callback: searchCb.domainSearch },
{ key: Keys.L, callback: searchCb.openResult },
{ key: Keys.O, callback: searchCb.openResult },
{ key: Keys.M, callback: searchCb.focusMainResult },
{ key: Keys.S, callback: searchCb.goToMisspellLink },
]);
lamemakes marked this conversation as resolved.
Show resolved Hide resolved

const onKeyDown = (event: KeyboardEvent) => {
// Only call onKeyDown if the target is not an input element (ie. search bar)
if (!((event.target as HTMLElement).nodeName === 'INPUT')) {
keybind.onKeyDown(event, $useKeyboardShortcuts);
}
Copy link
Member

@mikkeldenker mikkeldenker Mar 23, 2024

Choose a reason for hiding this comment

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

this could be

if (event.target != searchbar?.getInputElem()) {
  keybind.onKeyDown(event, $useKeyboardShortcuts);
}

};

onMount(() => {
const listener = () => {
modal = void 0;
Expand Down Expand Up @@ -67,6 +92,8 @@
}
</script>

<svelte:window on:keydown={onKeyDown} />

{#if modal}
<Modal {query} {modal} />
{/if}
Expand Down Expand Up @@ -110,6 +137,7 @@
<div>
Did you mean:{' '}
<a
id="misspell-link"
class="font-medium"
href="/search?q={encodeURIComponent(results.spellCorrection.raw)}"
>{#each results.spellCorrection.highlighted as frag}
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/routes/search/Result.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
const dispatch = createEventDispatcher<{ modal: HTMLButtonElement }>();
</script>

<div class="flex min-w-0 grow flex-col space-y-0.5">
<div class="result flex min-w-0 grow flex-col space-y-0.5">
<div class="flex min-w-0">
<div class="flex min-w-0 grow flex-col space-y-0.5">
<div class="flex items-center text-sm">
Expand All @@ -37,7 +37,7 @@
</ResultLink>
</div>
<ResultLink
_class="max-w-[calc(100%-30px)] truncate text-xl font-medium text-link visited:text-link-visited hover:underline"
_class="result-main-link max-w-[calc(100%-30px)] truncate text-xl font-medium text-link visited:text-link-visited hover:underline"
title={webpage.title}
href={webpage.url}
{resultIndex}
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/routes/settings/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import MarkPagesWithAdsSelect from './MarkPagesWithAdsSelect.svelte';
import MarkPagesWithPaywallSelect from './MarkPagesWithPaywallSelect.svelte';
import ResultsInNewTabs from './ResultsInNewTabs.svelte';
import UseKeyboardShortcuts from './UseKeyboardShortcuts.svelte';

const settings = [
{
Expand Down Expand Up @@ -38,6 +39,11 @@
description: 'Keep the search results window open and have links open in new tabs',
type: 'results-in-new-tabs',
},
{
title: 'Keyboard shortcuts',
description: 'Enables the use of keyboard shortcuts on the site',
type: 'use-keeb-shortcuts',
},
] as const;
</script>

Expand Down Expand Up @@ -65,6 +71,8 @@
<MarkPagesWithPaywallSelect />
{:else if setting.type == 'results-in-new-tabs'}
<ResultsInNewTabs />
{:else if setting.type == 'use-keeb-shortcuts'}
<UseKeyboardShortcuts />
{/if}
</div>
</div>
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/routes/settings/UseKeyboardShortcuts.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<script lang="ts">
import { useKeyboardShortcuts } from '$lib/stores';
import RadioSelect from './RadioSelect.svelte';
</script>

<RadioSelect store={useKeyboardShortcuts} prefix="use-keeb-shortcuts" />