feat: enhance hotkey functionality and command structure

- Added new hotkey scope for sub-layer functionality.
- Introduced `useCommandBinding` hook to streamline command binding.
- Updated command IDs to include focus to entry render functionality.
- Refactored hotkey registration to improve clarity and maintainability.
- Adjusted event handling for better focus management in the global hotkeys provider.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-05-19 23:11:23 +08:00
parent 596694c610
commit 243cd37a88
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
15 changed files with 103 additions and 49 deletions

View File

@ -8,4 +8,5 @@ export enum HotkeyScope {
Timeline = "timeline",
EntryRender = "entry-render",
SubscriptionList = "subscription-list",
SubLayer = "sub-layer",
}

View File

@ -39,7 +39,7 @@ import { EnvironmentIndicator } from "~/modules/app/EnvironmentIndicator"
import { NetworkStatusIndicator } from "~/modules/app/NetworkStatusIndicator"
import { LoginModalContent } from "~/modules/auth/LoginModalContent"
import { COMMAND_ID } from "~/modules/command/commands/id"
import { useCommandBinding } from "~/modules/command/hooks/use-register-hotkey"
import { useCommandBinding } from "~/modules/command/hooks/use-command-binding"
import { DebugRegistry } from "~/modules/debug/registry"
import { CmdF } from "~/modules/panel/cmdf"
import { SearchCmdK } from "~/modules/panel/cmdk"

View File

@ -6,10 +6,13 @@ import { ELECTRON_BUILD } from "@follow/shared/constants"
import { springScrollTo } from "@follow/utils/scroller"
import { cn, getOS } from "@follow/utils/utils"
import { useEffect, useRef, useState } from "react"
import { useHotkeys } from "react-hotkeys-hook"
import { useTranslation } from "react-i18next"
import { NavigationType, Outlet, useLocation, useNavigate, useNavigationType } from "react-router"
import { FABContainer, FABPortable } from "~/components/ui/fab"
import { HotkeyScope } from "~/constants"
import { useConditionalHotkeyScope } from "~/hooks/common"
import { useSubViewTitleValue } from "./hooks"
@ -53,6 +56,16 @@ export function SubviewLayout() {
// electron window has pt-[calc(var(--fo-window-padding-top)_-10px)]
const isElectronWindows = ELECTRON_BUILD && getOS() === "Windows"
useConditionalHotkeyScope(HotkeyScope.SubLayer, true)
const backHandler = () => {
if (prevLocation.pathname === location.pathname) {
navigate({ pathname: "" })
} else {
navigate(prevLocation)
}
}
useHotkeys("esc", backHandler)
return (
<div className="relative flex size-full">
<div
@ -64,13 +77,7 @@ export function SubviewLayout() {
)}
>
<MotionButtonBase
onClick={() => {
if (prevLocation.pathname === location.pathname) {
navigate({ pathname: "" })
} else {
navigate(prevLocation)
}
}}
onClick={backHandler}
className="no-drag-region hover:text-accent inline-flex items-center gap-1 duration-200"
>
<i className="i-mingcute-left-line" />

View File

@ -48,12 +48,12 @@ export const COMMAND_ID = {
toggleTimelineColumn: "layout:toggle-timeline-column",
focusToTimeline: "layout:focus-to-timeline",
focusToSubscription: "layout:focus-to-subscription",
focusToEntryRender: "layout:focus-to-entry-render",
},
timeline: {
switchToNext: "timeline:switch-to-next",
switchToPrevious: "timeline:switch-to-previous",
refetch: "timeline:refetch",
enter: "timeline:enter",
},
entryRender: {
scrollDown: "entry-render:scroll-down",

View File

@ -10,6 +10,7 @@ declare module "@follow/utils/event-bus" {
interface EventBusMap {
"layout:focus-to-timeline": never
"layout:focus-to-subscription": never
"layout:focus-to-entry-render": never
}
}
@ -36,6 +37,13 @@ export const useRegisterLayoutCommands = () => {
EventBus.dispatch(COMMAND_ID.layout.focusToSubscription)
},
},
{
id: COMMAND_ID.layout.focusToEntryRender,
label: "Enter Selected Entry",
run: () => {
EventBus.dispatch(COMMAND_ID.layout.focusToEntryRender)
},
},
])
}
@ -53,7 +61,12 @@ export type FocusToTimelineCommand = Command<{
id: typeof COMMAND_ID.layout.focusToTimeline
fn: () => void
}>
export type FocusToEntryRenderCommand = Command<{
id: typeof COMMAND_ID.layout.focusToEntryRender
fn: () => void
}>
export type LayoutCommand =
| ToggleTimelineColumnCommand
| FocusToTimelineCommand
| FocusToSubscriptionCommand
| FocusToEntryRenderCommand

View File

@ -36,13 +36,6 @@ export const useRegisterTimelineCommand = () => {
EventBus.dispatch("timeline:refetch")
},
},
{
id: COMMAND_ID.timeline.enter,
label: "Enter Selected Entry",
run: () => {
EventBus.dispatch("timeline:enter")
},
},
])
}
@ -61,13 +54,7 @@ export type RefetchTimelineCommand = Command<{
fn: () => void
}>
export type EnterTimelineCommand = Command<{
id: typeof COMMAND_ID.timeline.enter
fn: () => void
}>
export type TimelineCommand =
| SwitchToNextTimelineCommand
| SwitchToPreviousTimelineCommand
| RefetchTimelineCommand
| EnterTimelineCommand

View File

@ -0,0 +1,19 @@
import type { BindingCommandId } from "./use-command-shortcut"
import { useCommandShortcut } from "./use-command-shortcut"
import type { RegisterHotkeyOptions } from "./use-register-hotkey"
import { useCommandHotkey } from "./use-register-hotkey"
export const useCommandBinding = <T extends BindingCommandId>({
commandId,
when = true,
args,
}: Omit<RegisterHotkeyOptions<T>, "shortcut">) => {
const commandShortcut = useCommandShortcut(commandId)
return useCommandHotkey({
shortcut: commandShortcut,
commandId,
when,
args,
})
}

View File

@ -4,13 +4,11 @@ import { tinykeys } from "tinykeys"
import type { FollowCommand, FollowCommandId } from "../types"
import { getCommand } from "./use-command"
import type { BindingCommandId } from "./use-command-shortcut"
import { useCommandShortcut } from "./use-command-shortcut"
export interface HotkeyOptions {
forceInputElement?: true
}
interface RegisterHotkeyOptions<T extends FollowCommandId> {
export interface RegisterHotkeyOptions<T extends FollowCommandId> {
shortcut: string
commandId: T
args?: Parameters<Extract<FollowCommand, { id: T }>["run"]>
@ -80,18 +78,3 @@ export const useCommandHotkey = <T extends FollowCommandId>({
return tinykeys(document.documentElement, keyMap)
}, [shortcut, commandId, when, argsRef, options?.forceInputElement])
}
export const useCommandBinding = <T extends BindingCommandId>({
commandId,
when = true,
args,
}: Omit<RegisterHotkeyOptions<T>, "shortcut">) => {
const commandShortcut = useCommandShortcut(commandId)
return useCommandHotkey({
shortcut: commandShortcut,
commandId,
when,
args,
})
}

View File

@ -13,7 +13,8 @@ import { useConditionalHotkeyScope } from "~/hooks/common"
import { useHotkeyScope } from "~/providers/hotkey-provider"
import { COMMAND_ID } from "../command/commands/id"
import { useCommandBinding, useCommandHotkey } from "../command/hooks/use-register-hotkey"
import { useCommandBinding } from "../command/hooks/use-command-binding"
import { useCommandHotkey } from "../command/hooks/use-register-hotkey"
export const EntryColumnShortcutHandler: FC<{
refetch: () => void
@ -43,7 +44,7 @@ export const EntryColumnShortcutHandler: FC<{
})
useCommandHotkey({
commandId: COMMAND_ID.timeline.enter,
commandId: COMMAND_ID.layout.focusToEntryRender,
shortcut: "Enter",
when,
})

View File

@ -5,7 +5,7 @@ import { CommandActionButton } from "~/components/ui/button/CommandActionButton"
import { useHasModal } from "~/components/ui/modal/stacked/hooks"
import { useSortedEntryActions } from "~/hooks/biz/useEntryActions"
import { COMMAND_ID } from "~/modules/command/commands/id"
import { useCommandBinding } from "~/modules/command/hooks/use-register-hotkey"
import { useCommandBinding } from "~/modules/command/hooks/use-command-binding"
import { useEntry } from "~/store/entry/hooks"
export const EntryHeaderActions = ({

View File

@ -34,7 +34,8 @@ import { useFeedById } from "~/store/feed"
import { useInboxById } from "~/store/inbox"
import { COMMAND_ID } from "../command/commands/id"
import { useCommandBinding, useCommandHotkey } from "../command/hooks/use-register-hotkey"
import { useCommandBinding } from "../command/hooks/use-command-binding"
import { useCommandHotkey } from "../command/hooks/use-register-hotkey"
import { EntryContentHTMLRenderer } from "../renderer/html"
import { AISummary } from "./AISummary"
import { EntryTimelineSidebar } from "./components/EntryTimelineSidebar"
@ -235,6 +236,7 @@ const EntryScrollArea: Component<{
}
return (
<ScrollArea.ScrollArea
focusable={false}
mask={false}
rootClassName={cn(
"h-0 min-w-0 grow overflow-y-auto print:h-auto print:overflow-visible",
@ -353,7 +355,7 @@ const RegisterCommands = ({
springScrollTo(currentScroll + delta, scrollerRef.current!)
}
}),
EventBus.subscribe(COMMAND_ID.timeline.enter, () => {
EventBus.subscribe(COMMAND_ID.layout.focusToEntryRender, () => {
const $scroller = scrollerRef.current
if ($scroller) {
springScrollTo(0, $scroller)

View File

@ -26,7 +26,8 @@ import {
} from "~/store/subscription"
import { COMMAND_ID } from "../command/commands/id"
import { useCommandBinding, useCommandHotkey } from "../command/hooks/use-register-hotkey"
import { useCommandBinding } from "../command/hooks/use-command-binding"
import { useCommandHotkey } from "../command/hooks/use-register-hotkey"
import { useIsPreviewFeed } from "../entry-column/hooks/useIsPreviewFeed"
import {
resetSelectedFeedIds,

View File

@ -27,7 +27,7 @@ import { useHotkeyScope } from "~/providers/hotkey-provider"
import { WindowUnderBlur } from "../../components/ui/background"
import { COMMAND_ID } from "../command/commands/id"
import { useCommandBinding } from "../command/hooks/use-register-hotkey"
import { useCommandBinding } from "../command/hooks/use-command-binding"
import { getSelectedFeedIds, resetSelectedFeedIds, setSelectedFeedIds } from "./atom"
import { useShouldFreeUpSpace } from "./hook"
import { TimelineColumnHeader } from "./TimelineColumnHeader"

View File

@ -1,10 +1,13 @@
import { highlightElement } from "@follow/components/common/Focusable/utils.js"
import { nextFrame } from "@follow/utils/dom"
import { EventBus } from "@follow/utils/event-bus"
import { useEffect } from "react"
import { tinykeys } from "tinykeys"
import { useEventListener } from "usehooks-ts"
import { HotkeyScope } from "~/constants/hotkeys"
import { COMMAND_ID } from "~/modules/command/commands/id"
import { useCommandBinding } from "~/modules/command/hooks/use-register-hotkey"
import { useCommandBinding } from "~/modules/command/hooks/use-command-binding"
import { useHotkeyScope } from "./hotkey-provider"
@ -31,5 +34,36 @@ export const GlobalHotkeysProvider = () => {
}
})
// Re force to sidebar focusable
useEventListener("focusin", (e) => {
if (
activeScopes.length === 1 &&
activeScopes[0] === HotkeyScope.Home &&
e.target === document.body
) {
EventBus.dispatch(COMMAND_ID.layout.focusToTimeline)
}
})
// Re force to sidebar focusable
useEventListener("focusout", () => {
const { activeElement } = document
if (
activeElement === document.body &&
activeScopes.length === 1 &&
activeScopes[0] === HotkeyScope.Home
) {
EventBus.dispatch(COMMAND_ID.layout.focusToTimeline)
}
})
// Show current focused element
useEffect(() => {
return tinykeys(window, {
"$mod+Period": () => {
highlightElement(document.activeElement as HTMLElement)
},
})
}, [])
return null
}

View File

@ -75,9 +75,11 @@ const Viewport = ({
ref: forwardedRef,
className,
mask = false,
focusable = true,
...rest
}: React.ComponentPropsWithoutRef<typeof ScrollAreaBase.Viewport> & {
mask?: boolean
focusable?: boolean
} & { ref?: React.Ref<React.ElementRef<typeof ScrollAreaBase.Viewport> | null> }) => {
const ref = React.useRef<HTMLDivElement>(null)
const [shouldAddMask, setShouldAddMask] = React.useState(false)
@ -105,7 +107,7 @@ const Viewport = ({
<ScrollAreaBase.Viewport
{...rest}
ref={ref}
tabIndex={-1}
tabIndex={focusable ? -1 : void 0}
className={cn("block size-full", shouldAddMask && styles["mask-scroller"], className)}
/>
)
@ -143,6 +145,8 @@ export const ScrollArea = ({
onScroll,
orientation = "vertical",
asChild = false,
focusable = true,
}: React.PropsWithChildren & {
rootClassName?: string
viewportClassName?: string
@ -152,6 +156,7 @@ export const ScrollArea = ({
onScroll?: (e: React.UIEvent<HTMLDivElement>) => void
orientation?: "vertical" | "horizontal"
asChild?: boolean
focusable?: boolean
} & { ref?: React.Ref<HTMLDivElement | null> }) => {
const [viewportRef, setViewportRef] = React.useState<HTMLDivElement | null>(null)
React.useImperativeHandle(ref, () => viewportRef as HTMLDivElement)
@ -166,6 +171,7 @@ export const ScrollArea = ({
mask={mask}
asChild={asChild}
onScroll={onScroll}
focusable={focusable}
>
{children}
</Viewport>