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

Conversation

lamemakes
Copy link
Contributor

Closes #146. The goal here was to create a simple system that could be reused to handle keyboard shortcuts. Thus as
Stract grows and potentially implements more features/views, new shortcuts can be easily added. Most of the TSDoc comments speak for themselves but in summary:

  • Created new class Keybind to handle all needed Keybindings for a route
    • For simplicity, limited to a single "normal" key that can be combined with shift, alt, & ctrl
    • Class obj is initialized using params for the keys for the shortcut, and their callback
  • Added search binding callbacks in keybind.ts
    • Done here to prevent bloat in /search/+page.svelte
    • exported in a single var to prevent unwieldy imports in /search/+page.svelte
  • Mapped search keyboard shortcuts/functionality according to DuckDuckGo's
  • Added new preference option to allow the user to globally disable keyboard shortcuts

Let me know whatever feedback you've got, totally willing to refactor to your preferences. Cheers!

@lamemakes lamemakes marked this pull request as draft March 22, 2024 04:33
@lamemakes
Copy link
Contributor Author

Drafting as my main branch was out of sync so this needs merge help but will still take feedback in the meantime @mikkeldenker!

@lamemakes lamemakes changed the title Implemented keybind module to handle keyboard shortcuts [WIP] Implemented keybind module to handle keyboard shortcuts Mar 22, 2024
Copy link
Member

@mikkeldenker mikkeldenker left a comment

Choose a reason for hiding this comment

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

The functionality seems to be absolutely spot-on, but we need to make some changes for maintainability. In general, we should avoid using query selectors and instead give references to binded elements to the relevant functions. This way, it's very easy to see directly in the code which components depend on eachother and avoid them coming out of sync.

I think you can bind multiple results in search/+page.svelte like this

let resultElems: Result[] = [];
...
{#each results.webpages as webpage, resultIndex (`${query}-${webpage.url}`)}
    <div animate:flip={{ duration: 150 }}>
        <Result
        bind:this={resultElems[resultIndex]}
        {webpage}
        {resultIndex}
        on:modal={openSearchModal(webpage)}
        />
    </div>
{/each}

I know there is a ton of information here, but please feel free to ask if anything is unclear.

frontend/src/lib/keybind.ts Outdated Show resolved Hide resolved
Comment on lines 56 to 62
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);
}

frontend/src/routes/search/+page.svelte Outdated Show resolved Hide resolved
/**
* 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

frontend/src/lib/keybind.ts Outdated Show resolved Hide resolved
Comment on lines 181 to 191
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

Comment on lines 201 to 207
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

Comment on lines 231 to 232
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

Comment on lines 215 to 224
// 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;

Comment on lines 47 to 49
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);
}

@lamemakes
Copy link
Contributor Author

This feedback is phenomenal! I really appreciate you taking the time to give in depth feedback. Svelte is completely new to me as I'm coming from an Angular/Vue/Django background so these pointers have been incredibly helpful. Especially cool all of the things you can do with bindings in regards to HTML Elements. I had no idea and wince a bit whenever I had to do DOM manipulation. I'll have some revisions up shortly!

@lamemakes
Copy link
Contributor Author

Changes will be up tomorrow - didn't catch the keybindings in Searchbar so those will also be included

callback: (e: KeyboardEvent) => void;
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

/**
* Redirect to the `Did you mean:` link (if exists)
*/
const goToMisspellLink = () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is also the only one that I can't get working. I'm pretty sure if has to do with the logic of the spell correction and it's conditional rendering - let me know if you look over this and see anything right off the bat

@lamemakes
Copy link
Contributor Author

Was hoping this method of implementation didn't clutter things but the aim was re-usability as Stract grows. Implementing it in the search bar felt like some overhead was definitely eliminated, along with code not being duplicated which was nice. Once feedback is given on those two comments I'll move the PR out of draft. Cheers!

It's always used with 'Refs' as context, so there is no need to have it generic
The functionality didn't work (for instance enter didn't trigger a search). It would require a lot of aditional complexity in 'Keybind' to also support the use case from searchbar. It's okay to have some code duplication if this results in a simpler solution that will therefore be more readable and mantainable long term
This forces us to not rely on direct manipulation of the event, but instead implement the necesarry functionality in helper methods in the different components
@mikkeldenker mikkeldenker marked this pull request as ready for review April 2, 2024 11:14
Copy link
Member

@mikkeldenker mikkeldenker left a comment

Choose a reason for hiding this comment

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

Neat! I hope it's okay I went ahead and finished the last few things so we could get this merged. I tried to document it a bit in the commit messages, but in broad strokes:

  • The callbacks in Keybind always use Refs so there is no need to make it generic.
  • I think it's better to not use Keybind in the searchbar and have a simple keydown match instead. It would add a fair bit of complexity to Keybind to be able to use it both in the shortcuts and searchbar, and I think this added complexity wouldn't be worth it compared to the (very small) code replication.
  • The callbacks can all be implemented without having to know about the keyboard event that triggered them.

Great work!

@mikkeldenker mikkeldenker merged commit cc612c5 into StractOrg:main Apr 2, 2024
2 checks passed
@lamemakes
Copy link
Contributor Author

This is great, thanks a ton for the fixes! Are you sure the keybind class isn't adding bloat? I can totally do a quick refactor and toss it all in the +page.svelte like the search's bindings. Cheers!

@mikkeldenker
Copy link
Member

I actually quite like to have it in a separate file to avoid routes/search/+page.svelte becoming too enormous. I guess we can move keybind.ts into routes/search to indicate this is the only place we currently use it though.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Keyboard shortcuts for navigating
2 participants