-
Notifications
You must be signed in to change notification settings - Fork 49
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
Changes from 3 commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
e57f090
Implemented `keybind` module to handle keyboard shortcuts
lamemakes 25c8a19
Merged with main
lamemakes dae5d01
Removal of direct DOM querying and the addition of searchbar keybindings
lamemakes e83a78a
Remove generics from 'keybind'
mikkeldenker 1621c54
Revert 'Searchbar' to use simple keydown match instead of 'Keybind'
mikkeldenker 2b0f882
Remove need to know about keyboard event in keybind callbacks
mikkeldenker ccc5abd
forgot to remove a console.log...
mikkeldenker File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
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, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 withexport 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