Merge pull request 'feat: add keyboard shortcuts' (#649) from fernandoporazzi/snort:scroll-up-shortcut into main
Reviewed-on: #649
This commit is contained in:
commit
5360c5ad3b
30
packages/app/src/Hooks/useKeyboardShortcut.ts
Normal file
30
packages/app/src/Hooks/useKeyboardShortcut.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export default function useKeyboardShortcut(key: string, callback: (event: KeyboardEvent) => void, node = null) {
|
||||||
|
// implement the callback ref pattern
|
||||||
|
const callbackRef = useRef(callback);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
callbackRef.current = callback;
|
||||||
|
});
|
||||||
|
|
||||||
|
// handle what happens on key press
|
||||||
|
const handleKeyPress = useCallback(
|
||||||
|
(event: KeyboardEvent) => {
|
||||||
|
// check if one of the key is part of the ones we want
|
||||||
|
if (event.key === key) {
|
||||||
|
callbackRef.current(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[key],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// target is either the provided node or the document
|
||||||
|
const targetNode = node ?? document;
|
||||||
|
// attach the event listener
|
||||||
|
targetNode && targetNode.addEventListener("keydown", handleKeyPress);
|
||||||
|
|
||||||
|
// remove the event listener
|
||||||
|
return () => targetNode && targetNode.removeEventListener("keydown", handleKeyPress);
|
||||||
|
}, [handleKeyPress, node]);
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
import "./Layout.css";
|
import "./Layout.css";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
|
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
import { useUserProfile } from "@snort/system-react";
|
import { useUserProfile } from "@snort/system-react";
|
||||||
@ -13,7 +13,7 @@ import { NoteCreator } from "Element/Event/NoteCreator";
|
|||||||
import { mapPlanName } from "./subscribe";
|
import { mapPlanName } from "./subscribe";
|
||||||
import useLogin from "Hooks/useLogin";
|
import useLogin from "Hooks/useLogin";
|
||||||
import Avatar from "Element/User/Avatar";
|
import Avatar from "Element/User/Avatar";
|
||||||
import { profileLink } from "SnortUtils";
|
import { isFormElement, profileLink } from "SnortUtils";
|
||||||
import { getCurrentSubscription } from "Subscription";
|
import { getCurrentSubscription } from "Subscription";
|
||||||
import Toaster from "Toaster";
|
import Toaster from "Toaster";
|
||||||
import Spinner from "Icons/Spinner";
|
import Spinner from "Icons/Spinner";
|
||||||
@ -22,6 +22,7 @@ import { useTheme } from "Hooks/useTheme";
|
|||||||
import { useLoginRelays } from "Hooks/useLoginRelays";
|
import { useLoginRelays } from "Hooks/useLoginRelays";
|
||||||
import { useNoteCreator } from "State/NoteCreator";
|
import { useNoteCreator } from "State/NoteCreator";
|
||||||
import { LoginUnlock } from "Element/PinPrompt";
|
import { LoginUnlock } from "Element/PinPrompt";
|
||||||
|
import useKeyboardShortcut from "Hooks/useKeyboardShortcut";
|
||||||
import { LoginStore } from "Login";
|
import { LoginStore } from "Login";
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
@ -32,6 +33,16 @@ export default function Layout() {
|
|||||||
useLoginFeed();
|
useLoginFeed();
|
||||||
useTheme();
|
useTheme();
|
||||||
useLoginRelays();
|
useLoginRelays();
|
||||||
|
useKeyboardShortcut(".", event => {
|
||||||
|
// if event happened in a form element, do nothing, otherwise focus on search input
|
||||||
|
if (event.target && !isFormElement(event.target as HTMLElement)) {
|
||||||
|
event.preventDefault();
|
||||||
|
window.scrollTo({
|
||||||
|
top: 0,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const shouldHideHeader = useMemo(() => {
|
const shouldHideHeader = useMemo(() => {
|
||||||
const hideOn = ["/login", "/new"];
|
const hideOn = ["/login", "/new"];
|
||||||
@ -78,10 +89,21 @@ export default function Layout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const NoteCreatorButton = () => {
|
const NoteCreatorButton = () => {
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { readonly } = useLogin(s => ({ readonly: s.readonly }));
|
const { readonly } = useLogin(s => ({ readonly: s.readonly }));
|
||||||
const { show, replyTo, update } = useNoteCreator(v => ({ show: v.show, replyTo: v.replyTo, update: v.update }));
|
const { show, replyTo, update } = useNoteCreator(v => ({ show: v.show, replyTo: v.replyTo, update: v.update }));
|
||||||
|
|
||||||
|
useKeyboardShortcut("n", event => {
|
||||||
|
// if event happened in a form element, do nothing, otherwise focus on search input
|
||||||
|
if (event.target && !isFormElement(event.target as HTMLElement)) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (buttonRef.current) {
|
||||||
|
buttonRef.current.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const shouldHideNoteCreator = useMemo(() => {
|
const shouldHideNoteCreator = useMemo(() => {
|
||||||
const isReplyNoteCreatorShowing = replyTo && show;
|
const isReplyNoteCreatorShowing = replyTo && show;
|
||||||
const hideOn = ["/settings", "/messages", "/new", "/login", "/donate", "/e", "/subscribe"];
|
const hideOn = ["/settings", "/messages", "/new", "/login", "/donate", "/e", "/subscribe"];
|
||||||
@ -92,6 +114,7 @@ const NoteCreatorButton = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
className="primary note-create-button"
|
className="primary note-create-button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
update(v => {
|
update(v => {
|
||||||
@ -110,6 +133,14 @@ const AccountHeader = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
|
|
||||||
|
useKeyboardShortcut("/", event => {
|
||||||
|
// if event happened in a form element, do nothing, otherwise focus on search input
|
||||||
|
if (event.target && !isFormElement(event.target as HTMLElement)) {
|
||||||
|
event.preventDefault();
|
||||||
|
document.querySelector<HTMLInputElement>(".search input")?.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const { publicKey, latestNotification, readNotifications, readonly } = useLogin(s => ({
|
const { publicKey, latestNotification, readNotifications, readonly } = useLogin(s => ({
|
||||||
publicKey: s.publicKey,
|
publicKey: s.publicKey,
|
||||||
latestNotification: s.latestNotification,
|
latestNotification: s.latestNotification,
|
||||||
|
@ -504,3 +504,11 @@ export function kvToObject<T>(o: string, sep?: string) {
|
|||||||
export function defaultAvatar(input: string) {
|
export function defaultAvatar(input: string) {
|
||||||
return `https://robohash.v0l.io/${input}.png`;
|
return `https://robohash.v0l.io/${input}.png`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isFormElement(target: HTMLElement): boolean {
|
||||||
|
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user