feat: customize shortcuts (#3786)

* refactor: update command categories and labels for consistency

- Replaced existing command categories with more descriptive names such as "Entry Render", "Global", "Integration", "Layout", "List", "Settings", "Timeline", and "Subscription".
- Updated command labels to remove unnecessary prefixes, enhancing clarity and uniformity across the application.
- Adjusted imports to include the new CommandCategory type for better type safety.

This refactor improves the organization and readability of command definitions, contributing to a more intuitive user experience.

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* feat: enhance command shortcut customization and recording functionality

- Added a new hotkey scope for recording commands.
- Introduced a mechanism to override command shortcuts using local storage.
- Implemented a key recorder for capturing user-defined shortcuts with validation rules.
- Updated the UI for editing command shortcuts, allowing users to customize their key bindings more intuitively.

This enhancement improves user experience by providing more flexibility in managing command shortcuts.

Signed-off-by: Innei <tukon479@gmail.com>

* feat: enhance command shortcut customization by adding new commands

- Updated the default command shortcut for toggling the timeline column.
- Expanded the set of customizable commands to include various subscription and entry actions, improving user flexibility in managing shortcuts.

This update enhances the customization capabilities for command shortcuts, allowing users to tailor their experience more effectively.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: scope

Signed-off-by: Innei <tukon479@gmail.com>

* feat: add global commands for quick add and toggle corner play

- Introduced new global commands: "Quick Add" and "Toggle Corner Play" with associated keyboard shortcuts.
- Updated command IDs and types to accommodate the new functionalities.
- Enhanced the event bus integration for better command handling.

This update improves user interaction by providing quick access to essential features through keyboard shortcuts.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: remove unused hotkey scope and streamline command bindings

- Deleted the `useSwitchHotkeyScope` hook and its references to simplify the hotkey management.
- Updated command bindings to ensure they function correctly without the removed scope.
- Enhanced the logic for determining when commands should be active based on the current focus and active scopes.

This refactor improves the clarity and efficiency of the command handling system, reducing unnecessary complexity.

Signed-off-by: Innei <tukon479@gmail.com>

* feat: add shortcut conflict detection and enhance shortcut customization UI

- Introduced a new hook `useIsShortcutConflict` to check for conflicts between user-defined shortcuts and existing ones.
- Updated the `EditableCommandShortcutItem` component to visually indicate when a shortcut is customized by the user.
- Enhanced the `ShortcutInputWrapper` to display conflict warnings and improve user interaction when editing shortcuts.

This update improves the user experience by allowing users to easily identify and resolve shortcut conflicts, enhancing the overall customization capabilities of command shortcuts.

Signed-off-by: Innei <tukon479@gmail.com>

* feat: update command structure and enhance shortcut descriptions

- Changed the command ID for toggling the timeline column to toggle the subscription column for better clarity.
- Updated command labels to include descriptions for various commands, improving user understanding of their functionalities.
- Enhanced localization files to reflect the new command descriptions, ensuring a consistent user experience across different languages.

This update improves the usability of command shortcuts by providing clearer descriptions and a more intuitive command structure.

Signed-off-by: Innei <tukon479@gmail.com>

* feat: introduce new keyboard shortcuts system

- Added a comprehensive command shortcut system that supports multiple categories including global operations, layout controls, timeline navigation, content rendering, subscription management, and entry actions.
- Users can access and customize key bindings through `Settings` -> `Shortcuts`.

This enhancement improves user efficiency by providing a structured way to manage keyboard shortcuts.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor

Signed-off-by: Innei <tukon479@gmail.com>

* feat: enhance focusable component with presets for improved command handling

- Introduced `FocusablePresets` to encapsulate common focus-related logic, improving code reusability.
- Updated command bindings in various components to utilize the new presets, streamlining the focus management process.
- Enhanced the `Focusable` component to support additional scopes, improving the overall functionality and user experience.

This update simplifies the management of focusable elements and enhances the command handling system.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: enhance Focusable component with asChild prop for improved flexibility

- Updated the Focusable component to support an `asChild` prop, allowing it to render a child element with focusable properties.
- Modified the BizFocusableProps interface to align with the new FocusableProps structure.
- Adjusted usage in the FeedColumn component to utilize the new `asChild` feature, enhancing the component's flexibility.

This change improves the usability of the Focusable component by allowing it to wrap existing elements while maintaining focus management capabilities.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: update WindowUnderBlur and FeedColumn components for improved structure and functionality

- Refactored the WindowUnderBlur component to accept a customizable `as` prop, enhancing its flexibility in rendering.
- Updated the FeedColumn component to utilize the new `WindowUnderBlur` structure, allowing it to wrap the Focusable component directly.
- Enhanced the Focusable component's logic to include additional conditions for the SubscriptionList scope, improving focus management.

These changes streamline the component architecture and improve the overall user experience by allowing for more versatile component usage.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: update command bindings in SubscriptionList for improved focus management

- Replaced the `isFocus` variable with `inSubscriptionScope` to streamline focus handling in the SubscriptionList component.
- Updated command bindings and hotkey conditions to utilize the new `inSubscriptionScope` logic, enhancing the clarity and functionality of focus management.

These changes improve the overall structure and usability of the SubscriptionList component, ensuring better focus handling within the subscription context.

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-05-23 21:26:55 +08:00 committed by GitHub
parent 75e0c06b05
commit 62fc0a0cb1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
80 changed files with 1414 additions and 863 deletions

View File

@ -2,6 +2,8 @@
## Shiny new things
- **New Keyboard Shortcuts System**: Introduced a comprehensive command shortcut system supporting multiple categories including global operations, layout controls, timeline navigation, content rendering, subscription management, and entry actions. Access all available shortcuts and customize key bindings via `Settings` -> `Shortcuts`.
## Improvements
## No longer broken

View File

@ -0,0 +1,27 @@
import type { FocusableProps } from "@follow/components/common/Focusable/Focusable.js"
import { Focusable as FocusableComponent } from "@follow/components/common/Focusable/Focusable.js"
import { FloatingLayerScope, HotkeyScope } from "~/constants"
interface BizFocusableProps extends Omit<FocusableProps, "scope"> {
scope: HotkeyScope
}
export const Focusable = FocusableComponent as Component<
Prettify<BizFocusableProps> &
React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
>
export const FocusablePresets = {
isNotFloatingLayerScope: (v: Set<string>) => !FloatingLayerScope.some((s) => v.has(s)),
isSubscriptionList: (scope: Set<string>) => {
return (
scope.size === 0 ||
scope.has(HotkeyScope.SubscriptionList) ||
(scope.has(HotkeyScope.Home) && scope.size === 1)
)
},
isSubscriptionOrTimeline: (v: Set<string>) => {
return v.has(HotkeyScope.SubscriptionList) || v.has(HotkeyScope.Timeline) || v.size === 0
},
}

View File

@ -1,22 +1,31 @@
import { Focusable } from "@follow/components/common/Focusable/index.js"
import { SYSTEM_CAN_UNDER_BLUR_WINDOW } from "@follow/shared/constants"
import { cn } from "@follow/utils/utils"
import type * as React from "react"
import type { ComponentPropsWithoutRef, ElementType } from "react"
import { useUISettingKey } from "~/atoms/settings/ui"
type Props = Component<
React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
>
const MacOSVibrancy: Props = ({ children, ...rest }) => <Focusable {...rest}>{children}</Focusable>
type Props<T extends ElementType = "div"> = {
as?: T
ref?: React.Ref<HTMLElement>
} & ComponentPropsWithoutRef<T>
const Noop: Props = ({ children, className, ...rest }) => (
<Focusable className={cn("bg-sidebar", className)} {...rest}>
{children}
</Focusable>
)
const MacOSVibrancy = <T extends ElementType = "div">({ children, as, ...rest }: Props<T>) => {
const Component = as || "div"
return <Component {...rest}>{children}</Component>
}
export const WindowUnderBlur: Props = SYSTEM_CAN_UNDER_BLUR_WINDOW
? (props) => {
const Noop = <T extends ElementType = "div">({ children, className, as, ...rest }: Props<T>) => {
const Component = as || "div"
return (
<Component className={cn("bg-sidebar", className)} {...rest}>
{children}
</Component>
)
}
export const WindowUnderBlur = SYSTEM_CAN_UNDER_BLUR_WINDOW
? <T extends ElementType = "div">(props: Props<T>) => {
const opaqueSidebar = useUISettingKey("opaqueSidebar")
if (opaqueSidebar) {
return <Noop {...props} />

View File

@ -6,19 +6,15 @@ import { cn } from "@follow/utils/utils"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import * as React from "react"
import { Focusable } from "~/components/common/Focusable"
import { HotkeyScope } from "~/constants"
import { useConditionalHotkeyScope } from "~/hooks/common"
const DropdownMenu: typeof DropdownMenuPrimitive.Root = (props) => {
const [open, setOpen] = React.useState(!!props.open)
useConditionalHotkeyScope(HotkeyScope.DropdownMenu, open)
return (
<DropdownMenuPrimitive.Root
{...props}
onOpenChange={useTypeScriptHappyCallback(
(open) => {
setOpen(open)
props.onOpenChange?.(open)
},
[props.onOpenChange],
@ -95,16 +91,18 @@ const DropdownMenuContent = ({
}) => {
return (
<RootPortal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"bg-material-medium backdrop-blur-background text-text shadow-context-menu z-[60] min-w-32 overflow-hidden rounded-[6px] border p-1",
"motion-scale-in-75 motion-duration-150 text-body lg:animate-none",
className,
)}
{...props}
/>
<Focusable scope={HotkeyScope.DropdownMenu} className="contents">
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"bg-material-medium backdrop-blur-background text-text shadow-context-menu z-[60] min-w-32 overflow-hidden rounded-[6px] border p-1",
"motion-scale-in-75 motion-duration-150 text-body lg:animate-none",
className,
)}
{...props}
/>
</Focusable>
</RootPortal>
)
}

View File

@ -1,4 +1,4 @@
import { Focusable } from "@follow/components/common/Focusable/index.js"
"~/components/common/Focusable.js"
import { Spring } from "@follow/components/constants/spring.js"
import { ActionButton, MotionButtonBase } from "@follow/components/ui/button/index.js"
import type { HTMLMediaState } from "@follow/hooks"
@ -23,7 +23,9 @@ import { createContext, useContext, useContextSelector } from "use-context-selec
import { useEventCallback } from "usehooks-ts"
import { AudioPlayer } from "~/atoms/player"
import { Focusable } from "~/components/common/Focusable"
import { IconScaleTransition } from "~/components/ux/transition/icon"
import { HotkeyScope } from "~/constants"
import { VolumeSlider } from "./VolumeSlider"
@ -137,7 +139,11 @@ export const VideoPlayer = ({
)
return (
<Focusable className="center group relative size-full" ref={wrapperRef}>
<Focusable
className="center group relative size-full"
ref={wrapperRef}
scope={HotkeyScope.VideoPlayer}
>
{element}
<div className="center pointer-events-none absolute inset-0">
<m.div

View File

@ -27,11 +27,11 @@ import { useEventCallback } from "usehooks-ts"
import { useUISettingKey } from "~/atoms/settings/ui"
import { AppErrorBoundary } from "~/components/common/AppErrorBoundary"
import { Focusable } from "~/components/common/Focusable"
import { SafeFragment } from "~/components/common/Fragment"
import { m } from "~/components/common/Motion"
import { ErrorComponentType } from "~/components/errors/enum"
import { ElECTRON_CUSTOM_TITLEBAR_HEIGHT, HotkeyScope } from "~/constants"
import { useConditionalHotkeyScope } from "~/hooks/common"
import { modalStackAtom } from "./atom"
import { MODAL_STACK_Z_INDEX, modalMontionConfig } from "./constants"
@ -192,8 +192,6 @@ export const ModalInternal = memo(function Modal({
}
}, [currentIsClosing])
useConditionalHotkeyScope(HotkeyScope.Modal, true)
const modalStyle = resizeableStyle
const { handleSelectStart, handleDetectSelectEnd, isSelectingRef } = useModalSelect()
const handleClickOutsideToDismiss = useCallback(
@ -253,7 +251,8 @@ export const ModalInternal = memo(function Modal({
onPointerDownOutside={preventDefault}
onOpenAutoFocus={openAutoFocus}
>
<div
<Focusable
scope={HotkeyScope.Modal}
ref={setEdgeElementRef}
className={cn(
"no-drag-region fixed",
@ -282,7 +281,7 @@ export const ModalInternal = memo(function Modal({
<CustomModalComponent>{finalChildren}</CustomModalComponent>
</ModalContext>
</div>
</div>
</Focusable>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
@ -304,7 +303,8 @@ export const ModalInternal = memo(function Modal({
onPointerDownOutside={preventDefault}
onOpenAutoFocus={openAutoFocus}
>
<div
<Focusable
scope={HotkeyScope.Modal}
ref={setEdgeElementRef}
onContextMenu={preventDefault}
className={cn(
@ -395,7 +395,7 @@ export const ModalInternal = memo(function Modal({
</div>
</ResizeSwitch>
</m.div>
</div>
</Focusable>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>

View File

@ -1,7 +1,9 @@
import { IN_ELECTRON } from "@follow/shared/constants"
const OpenInBrowser = (_t?: any) =>
IN_ELECTRON ? "keys.entry.openInBrowser" : "keys.entry.openInNewTab"
IN_ELECTRON
? tShortcuts("command.subscription.open_in_browser.title")
: tShortcuts("command.subscription.open_in_tab.title")
export const COPY_MAP = {
OpenInBrowser,

View File

@ -3,10 +3,19 @@ export enum HotkeyScope {
Menu = "menu",
Modal = "modal",
DropdownMenu = "dropdown-menu",
Recording = "recording",
// Atom Scope
VideoPlayer = "video-player",
Timeline = "timeline",
EntryRender = "entry-render",
SubscriptionList = "subscription-list",
SubLayer = "sub-layer",
}
export const FloatingLayerScope = [
HotkeyScope.Modal,
HotkeyScope.DropdownMenu,
HotkeyScope.Menu,
HotkeyScope.Recording,
] as const

View File

@ -1,178 +0,0 @@
import { transformShortcut } from "@follow/utils/utils"
import { COPY_MAP } from "~/constants"
type Shortcuts = Record<
string,
Record<string, { name: I18nKeysForShortcuts; key: string; extra?: string }>
>
const shortcutConfigs = {
subscriptions: {
add: {
name: tShortcuts("keys.subscriptions.add"),
key: "$mod+T",
},
switchToView: {
name: tShortcuts("keys.subscriptions.switchToView"),
key: "1, 2, 3, 4, 5, 6",
},
switchNextView: {
name: tShortcuts("keys.subscriptions.switchNextView"),
key: "Tab",
},
switchPreviousView: {
name: tShortcuts("keys.subscriptions.switchPreviousView"),
key: "Shift+Tab",
},
nextSubscription: {
name: tShortcuts("keys.subscriptions.nextSubscription"),
key: "J, ArrowDown",
},
previousSubscription: {
name: tShortcuts("keys.subscriptions.previousSubscription"),
key: "K, ArrowUp",
},
toggleFolderCollapse: {
name: tShortcuts("keys.subscriptions.toggleFolderCollapse"),
key: "Z",
},
openInBrowser: {
name: tShortcuts("keys.subscriptions.openInBrowser"),
key: "O",
},
openSiteInBrowser: {
name: tShortcuts("keys.subscriptions.openSiteInBrowser"),
key: "$mod+O",
},
markAllAsRead: {
name: tShortcuts("keys.entries.markAllAsRead"),
key: "Shift+$mod+A",
},
},
layout: {
toggleSidebar: {
name: tShortcuts("keys.layout.toggleSidebar"),
key: "$mod+B, [",
},
toggleWideMode: {
name: tShortcuts("keys.layout.toggleWideMode"),
key: "$mod+[",
},
toggleZenMode: {
name: tShortcuts("keys.layout.zenMode"),
key: "$mod+Shift+Z",
},
},
entries: {
refetch: {
name: tShortcuts("keys.entries.refetch"),
key: "R",
},
previous: {
name: tShortcuts("keys.entries.previous"),
key: "K, ArrowUp",
},
next: {
name: tShortcuts("keys.entries.next"),
key: "J, ArrowDown",
},
toggleUnreadOnly: {
name: tShortcuts("keys.entries.toggleUnreadOnly"),
key: "U",
},
},
entry: {
toggleRead: {
name: tShortcuts("keys.entry.toggleRead"),
key: "M",
},
toggleStarred: {
name: tShortcuts("keys.entry.toggleStarred"),
key: "S",
},
openInBrowser: {
name: COPY_MAP.OpenInBrowser(),
key: "B",
extra: "Double Click",
},
tts: {
name: tShortcuts("keys.entry.tts"),
key: "Shift+$mod+V",
},
copyLink: {
name: tShortcuts("keys.entry.copyLink"),
key: "Shift+$mod+C",
},
copyTitle: {
name: tShortcuts("keys.entry.copyTitle"),
key: "Shift+$mod+B",
},
tip: {
name: tShortcuts("keys.entry.tip"),
key: "Shift+$mod+T",
},
share: {
name: tShortcuts("keys.entry.share"),
key: "$mod+Alt+S",
},
scrollUp: {
name: tShortcuts("keys.entry.scrollUp"),
key: "K, ArrowUp",
},
scrollDown: {
name: tShortcuts("keys.entry.scrollDown"),
key: "J, ArrowDown",
},
nextEntry: {
name: tShortcuts("keys.entries.next"),
key: "L, ArrowRight",
},
previousEntry: {
name: tShortcuts("keys.entries.previous"),
key: "H, ArrowLeft",
},
},
audio: {
"play/pause": {
name: tShortcuts("keys.audio.playPause"),
key: "Space",
},
},
misc: {
quickSearch: {
name: tShortcuts("keys.misc.quickSearch"),
key: "$mod+K",
},
showShortcuts: {
name: tShortcuts("keys.misc.showShortcuts"),
key: "?",
},
},
} as const
function transformShortcuts<T extends Shortcuts>(configs: T) {
const result = configs
for (const category in configs) {
for (const shortcutKey in configs[category]) {
const config = configs[category][shortcutKey]
result[category]![shortcutKey]!.key = transformShortcut(config!.key)
}
}
return result
}
export const shortcuts = transformShortcuts(shortcutConfigs) satisfies Shortcuts
export const shortcutsType: { [key in keyof typeof shortcuts]: I18nKeysForShortcuts } = {
subscriptions: "keys.type.subscriptions",
layout: "keys.type.layout",
entries: "keys.type.entries",
entry: "keys.type.entry",
audio: "keys.type.audio",
misc: "keys.type.misc",
}

View File

@ -4,7 +4,6 @@ import { useHotkeys } from "react-hotkeys-hook"
import { Trans, useTranslation } from "react-i18next"
import { toast } from "sonner"
import { HotkeyScope } from "~/constants"
import { apiClient } from "~/lib/api-fetch"
import { subscription as subscriptionQuery } from "~/queries/subscriptions"
import type { SubscriptionFlatModel } from "~/store/subscription"
@ -92,7 +91,6 @@ export const useDeleteSubscription = ({ onSuccess }: { onSuccess?: () => void }
const UnfollowInfo = ({ title, undo }: { title: string; undo: () => any }) => {
useHotkeys("ctrl+z,meta+z", undo, {
scopes: HotkeyScope.Home,
preventDefault: true,
})
return (

View File

@ -2,5 +2,4 @@ export * from "./useBizQuery"
export * from "./useContextMenu"
export * from "./useI18n"
export * from "./usePreventOverscrollBounce"
export * from "./useSwitchHotkeyScope"
export * from "./useSyncTheme"

View File

@ -1,61 +0,0 @@
import { useCallback, useLayoutEffect, useRef } from "react"
import { useHotkeysContext } from "react-hotkeys-hook"
import { HotkeyScope } from "~/constants"
const allScopes = Object.keys(HotkeyScope).reduce((acc, key) => {
acc.push(HotkeyScope[key])
return acc
}, [] as string[])
export const useSwitchHotKeyScope = () => {
const { enableScope, disableScope } = useHotkeysContext()
return useCallback(
(scope: keyof typeof HotkeyScope) => {
const nextScope = HotkeyScope[scope]
if (!nextScope) return
for (const key of allScopes) {
disableScope(key)
}
enableScope(nextScope)
},
[disableScope, enableScope],
)
}
export const useConditionalHotkeyScopeFn = (scope: HotkeyScope, replaceAll = true) => {
const { enableScope, disableScope, activeScopes } = useHotkeysContext()
const currentScopeRef = useRef(activeScopes)
useLayoutEffect(() => {
currentScopeRef.current = activeScopes
}, [activeScopes])
return useCallback(() => {
const currentScope = currentScopeRef.current
if (replaceAll) {
for (const key of allScopes) {
disableScope(key)
}
}
enableScope(scope)
return () => {
disableScope(scope)
if (replaceAll) {
for (const key of currentScope) {
enableScope(key)
}
}
}
}, [enableScope, disableScope, scope, replaceAll])
}
export const useConditionalHotkeyScope = (scope: HotkeyScope, when: boolean, append = false) => {
const fn = useConditionalHotkeyScopeFn(scope, !append)
useLayoutEffect(() => {
if (when) {
return fn()
}
}, [fn, when])
}

View File

@ -11,6 +11,8 @@ import {
useRealInWideMode,
useUISettingKey,
} from "~/atoms/settings/ui"
import { Focusable } from "~/components/common/Focusable"
import { HotkeyScope } from "~/constants"
import { useRouteParams } from "~/hooks/biz/useRouteParams"
import { EntryColumn } from "~/modules/entry-column"
import { AppLayoutGridContainerProvider } from "~/providers/app-grid-layout-container-provider"
@ -40,7 +42,7 @@ export function CenterColumnDesktop() {
})
return (
<div ref={containerRef} className="relative flex min-w-0 grow">
<Focusable scope={HotkeyScope.Home} ref={containerRef} className="relative flex min-w-0 grow">
<div
className={cn("h-full shrink-0", inWideMode ? "flex-1" : "border-r", "will-change-[width]")}
data-hide-in-print
@ -56,6 +58,6 @@ export function CenterColumnDesktop() {
<PanelSplitter {...separatorProps} cursor={separatorCursor} isDragging={isDragging} />
)}
<Outlet />
</div>
</Focusable>
)
}

View File

@ -4,14 +4,13 @@ import { useWheel } from "@use-gesture/react"
import { easeOut } from "motion/react"
import type { FC, PropsWithChildren } from "react"
import { useState } from "react"
import { useHotkeys } from "react-hotkeys-hook"
import { useParams } from "react-router"
import { useRealInWideMode } from "~/atoms/settings/ui"
import { useTimelineColumnShow, useTimelineColumnTempShow } from "~/atoms/sidebar"
import { m } from "~/components/common/Motion"
import { FixedModalCloseButton } from "~/components/ui/modal/components/close"
import { HotkeyScope, ROUTE_ENTRY_PENDING } from "~/constants"
import { ROUTE_ENTRY_PENDING } from "~/constants"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { useRouteParams } from "~/hooks/biz/useRouteParams"
import { EntryContent } from "~/modules/entry-content"
@ -32,18 +31,6 @@ export const RightContentDesktop = () => {
const feedColumnShow = useTimelineColumnShow()
const shouldHeaderPaddingLeft = feedColumnTempShow && !feedColumnShow && settingWideMode
useHotkeys(
"Escape",
() => {
navigate({ entryId: null })
},
{
enabled: showEntryContent && settingWideMode,
scopes: HotkeyScope.Home,
preventDefault: true,
},
)
if (!showEntryContent) {
return null
}

View File

@ -1,5 +1,6 @@
import type { DragEndEvent } from "@dnd-kit/core"
import { DndContext, PointerSensor, pointerWithin, useSensor, useSensors } from "@dnd-kit/core"
import { useGlobalFocusableScope } from "@follow/components/common/Focusable/hooks.js"
import { PanelSplitter } from "@follow/components/ui/divider/index.js"
import { Kbd } from "@follow/components/ui/kbd/Kbd.js"
import { RootPortal } from "@follow/components/ui/portal/index.jsx"
@ -30,7 +31,7 @@ import { AppErrorBoundary } from "~/components/common/AppErrorBoundary"
import { ErrorComponentType } from "~/components/errors/enum"
import { PlainModal } from "~/components/ui/modal/stacked/custom-modal"
import { DeclarativeModal } from "~/components/ui/modal/stacked/declarative-modal"
import { HotkeyScope } from "~/constants"
import { FloatingLayerScope } from "~/constants"
import { ROOT_CONTAINER_ID } from "~/constants/dom"
import { useDailyTask } from "~/hooks/biz/useDailyTask"
import { useBatchUpdateSubscription } from "~/hooks/biz/useSubscriptionActions"
@ -45,12 +46,11 @@ import { CmdF } from "~/modules/panel/cmdf"
import { SearchCmdK } from "~/modules/panel/cmdk"
import { CmdNTrigger } from "~/modules/panel/cmdn"
import { CornerPlayer } from "~/modules/player/corner-player"
import { FeedColumn } from "~/modules/timeline-column"
import { getSelectedFeedIds, resetSelectedFeedIds } from "~/modules/timeline-column/atom"
import { FeedColumn } from "~/modules/subscription-column"
import { getSelectedFeedIds, resetSelectedFeedIds } from "~/modules/subscription-column/atom"
import { UpdateNotice } from "~/modules/update-notice/UpdateNotice"
import { AppNotificationContainer } from "~/modules/upgrade/lazy/index"
import { AppLayoutGridContainerProvider } from "~/providers/app-grid-layout-container-provider"
import { useHotkeyScope } from "~/providers/hotkey-provider"
import { NewUserGuide } from "./index.shared"
@ -241,11 +241,11 @@ const FeedResponsiveResizerContainer = ({
}
}, [feedColumnShow])
const activeScopes = useHotkeyScope()
const activeScopes = useGlobalFocusableScope()
useCommandBinding({
commandId: COMMAND_ID.layout.toggleTimelineColumn,
when: activeScopes.includes(HotkeyScope.Home),
commandId: COMMAND_ID.layout.toggleSubscriptionColumn,
when: !FloatingLayerScope.some((scope) => activeScopes.has(scope)),
})
const [delayShowSplitter, setDelayShowSplitter] = useState(feedColumnShow)

View File

@ -12,7 +12,7 @@ import { Link } from "react-router"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { FeedList } from "../../timeline-column/FeedList"
import { SubscriptionList } from "../../subscription-column/SubscriptionList"
import { MobileFloatBar } from "./float-bar.mobile"
export function FeedColumnMobile({ asWidget }: { asWidget?: boolean }) {
@ -47,7 +47,7 @@ export function FeedColumnMobile({ asWidget }: { asWidget?: boolean }) {
<SwipeWrapper active={view}>
{views.map((item, index) => (
<section key={item.name} className="size-full flex-none shrink-0 snap-center">
<FeedList
<SubscriptionList
ref={setFeedListScrollRef}
className="flex size-full flex-col text-sm"
view={index}

View File

@ -1,4 +1,5 @@
import { getReadonlyRoute } from "@follow/components/atoms/route.js"
import { useGlobalFocusableScope } from "@follow/components/common/Focusable/hooks.js"
import { MotionButtonBase } from "@follow/components/ui/button/index.js"
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
import { Routes } from "@follow/constants"
@ -10,14 +11,20 @@ import { useHotkeys } from "react-hotkeys-hook"
import { useTranslation } from "react-i18next"
import { NavigationType, Outlet, useLocation, useNavigate, useNavigationType } from "react-router"
import { Focusable } from "~/components/common/Focusable"
import { FABContainer, FABPortable } from "~/components/ui/fab"
import { HotkeyScope } from "~/constants"
import { useConditionalHotkeyScope } from "~/hooks/common"
import { useHotkeyScope } from "~/providers/hotkey-provider"
import { useSubViewTitleValue } from "./hooks"
export function SubviewLayout() {
return (
<Focusable className="contents" scope={HotkeyScope.SubLayer}>
<SubviewLayoutInner />
</Focusable>
)
}
function SubviewLayoutInner() {
const navigate = useNavigate()
const prevLocation = useRef(getReadonlyRoute().location).current
const title = useSubViewTitleValue()
@ -57,8 +64,6 @@ 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: "" })
@ -66,9 +71,9 @@ export function SubviewLayout() {
navigate(-1)
}
}
const activeScope = useHotkeyScope()
const activeScope = useGlobalFocusableScope()
useHotkeys("Escape", backHandler, {
enabled: activeScope.includes(HotkeyScope.SubLayer),
enabled: activeScope.has(HotkeyScope.SubLayer),
})
return (
<div className="relative flex size-full">

View File

@ -1,7 +1,8 @@
import { EventBus } from "@follow/utils/event-bus"
import { useTranslation } from "react-i18next"
import { useRegisterCommandEffect } from "../hooks/use-register-command"
import type { Command } from "../types"
import type { Command, CommandCategory } from "../types"
import { COMMAND_ID } from "./id"
declare module "@follow/utils/event-bus" {
@ -12,10 +13,10 @@ declare module "@follow/utils/event-bus" {
"entry-render:previous-entry": never
}
}
const LABEL_PREFIX = "Entry Render"
const category = "follow:entry-render"
const category: CommandCategory = "category.entry_render"
export const useRegisterEntryRenderCommand = () => {
const { t } = useTranslation("shortcuts")
useRegisterCommandEffect([
{
id: COMMAND_ID.entryRender.scrollDown,
@ -23,7 +24,10 @@ export const useRegisterEntryRenderCommand = () => {
EventBus.dispatch(COMMAND_ID.entryRender.scrollDown)
},
category,
label: `${LABEL_PREFIX}: Scroll down`,
label: {
title: t("command.entry.scroll_down.title"),
description: t("command.entry.scroll_down.description"),
},
},
{
id: COMMAND_ID.entryRender.scrollUp,
@ -31,7 +35,10 @@ export const useRegisterEntryRenderCommand = () => {
EventBus.dispatch(COMMAND_ID.entryRender.scrollUp)
},
category,
label: `${LABEL_PREFIX}: Scroll up`,
label: {
title: t("command.entry.scroll_up.title"),
description: t("command.entry.scroll_up.description"),
},
},
{
id: COMMAND_ID.entryRender.nextEntry,
@ -40,7 +47,10 @@ export const useRegisterEntryRenderCommand = () => {
EventBus.dispatch(COMMAND_ID.entryRender.nextEntry)
},
category,
label: `${LABEL_PREFIX}: Next entry`,
label: {
title: t("command.entry.next_entry.title"),
description: t("command.entry.next_entry.description"),
},
},
{
id: COMMAND_ID.entryRender.previousEntry,
@ -49,7 +59,10 @@ export const useRegisterEntryRenderCommand = () => {
EventBus.dispatch(COMMAND_ID.entryRender.previousEntry)
},
category,
label: `${LABEL_PREFIX}: Previous entry`,
label: {
title: t("command.entry.previous_entry.title"),
description: t("command.entry.previous_entry.description"),
},
},
])
}

View File

@ -28,9 +28,11 @@ import { useTipModal } from "~/modules/wallet/hooks"
import { entryActions, useEntryStore } from "~/store/entry"
import { useRegisterFollowCommand } from "../hooks/use-register-command"
import type { Command } from "../types"
import type { Command, CommandCategory } from "../types"
import { COMMAND_ID } from "./id"
const category: CommandCategory = "category.entry"
const useCollect = () => {
const { t } = useTranslation()
return useMutation({
@ -120,8 +122,7 @@ export const useRegisterEntryCommands = () => {
id: COMMAND_ID.entry.tip,
label: t("entry_actions.tip"),
icon: <i className="i-mgc-power-outline" />,
// keyBinding: shortcuts.entry.tip.key,
// when: !isInbox && feed?.ownerUserId !== whoami()?.id && !!populatedEntry,
category,
run: ({ userId, feedId, entryId }) => {
openTipModal({
userId,
@ -134,6 +135,7 @@ export const useRegisterEntryCommands = () => {
{
id: COMMAND_ID.entry.star,
label: t("entry_actions.star"),
category,
icon: (props) => (
<i
className={cn(
@ -147,15 +149,7 @@ export const useRegisterEntryCommands = () => {
toast.error("Failed to star: entry is not available", { duration: 3000 })
return
}
// if (type === "toolbar") {
// const absoluteStarAnimationUri = new URL(StarAnimationUri, import.meta.url).href
// mountLottie(absoluteStarAnimationUri, {
// x: e.clientX - 90,
// y: e.clientY - 70,
// height: 126,
// width: 252,
// })
// }
if (entry.collections) {
uncollect.mutate(entry.entries.id)
} else {
@ -167,6 +161,7 @@ export const useRegisterEntryCommands = () => {
id: COMMAND_ID.entry.delete,
label: t("entry_actions.delete"),
icon: <i className="i-mgc-delete-2-cute-re" />,
category,
run: ({ entryId }) => {
const entry = useEntryStore.getState().flatMapEntries[entryId]
if (!entry) {
@ -180,6 +175,7 @@ export const useRegisterEntryCommands = () => {
id: COMMAND_ID.entry.copyLink,
label: t("entry_actions.copy_link"),
icon: <i className="i-mgc-link-cute-re" />,
category,
run: ({ entryId }) => {
const entry = useEntryStore.getState().flatMapEntries[entryId]
if (!entry) {
@ -197,6 +193,7 @@ export const useRegisterEntryCommands = () => {
id: COMMAND_ID.entry.exportAsPDF,
label: t("entry_actions.export_as_pdf"),
icon: <i className="i-mgc-pdf-cute-re" />,
category,
run: ({ entryId }) => {
const entry = useEntryStore.getState().flatMapEntries[entryId]
@ -212,6 +209,7 @@ export const useRegisterEntryCommands = () => {
id: COMMAND_ID.entry.copyTitle,
label: t("entry_actions.copy_title"),
icon: <i className="i-mgc-copy-cute-re" />,
category,
run: ({ entryId }) => {
const entry = useEntryStore.getState().flatMapEntries[entryId]
if (!entry) {
@ -230,6 +228,7 @@ export const useRegisterEntryCommands = () => {
label: t("entry_actions.open_in_browser", {
which: t(IN_ELECTRON ? "words.browser" : "words.newTab"),
}),
category,
icon: <i className="i-mgc-world-2-cute-re" />,
run: ({ entryId }) => {
const entry = useEntryStore.getState().flatMapEntries[entryId]
@ -244,6 +243,7 @@ export const useRegisterEntryCommands = () => {
id: COMMAND_ID.entry.viewSourceContent,
label: t("entry_actions.view_source_content"),
icon: <i className="i-mgc-web-cute-re" />,
category,
run: ({ entryId, siteUrl }) => {
if (!getShowSourceContent()) {
const entry = useEntryStore.getState().flatMapEntries[entryId]
@ -276,6 +276,7 @@ export const useRegisterEntryCommands = () => {
id: COMMAND_ID.entry.share,
label: t("entry_actions.share"),
icon: <i className="i-mgc-share-forward-cute-re" />,
category,
run: ({ entryId }) => {
const entry = useEntryStore.getState().flatMapEntries[entryId]
if (!entry || !entry.entries.url) {
@ -300,6 +301,7 @@ export const useRegisterEntryCommands = () => {
{
id: COMMAND_ID.entry.readAbove,
label: t("entry_actions.mark_above_as_read"),
category,
run: ({ publishedAt }: { publishedAt: string }) => {
return markAllByRoute({
startTime: new Date(publishedAt).getTime() + 1,
@ -310,6 +312,7 @@ export const useRegisterEntryCommands = () => {
{
id: COMMAND_ID.entry.read,
label: t("entry_actions.mark_as_read"),
category,
icon: (props) => (
<i className={cn(props?.isActive ? "i-mgc-round-cute-re" : "i-mgc-round-cute-fi")} />
),
@ -329,6 +332,7 @@ export const useRegisterEntryCommands = () => {
{
id: COMMAND_ID.entry.readBelow,
label: t("entry_actions.mark_below_as_read"),
category,
run: ({ publishedAt }: { publishedAt: string }) => {
return markAllByRoute({
startTime: 1,
@ -340,6 +344,7 @@ export const useRegisterEntryCommands = () => {
id: COMMAND_ID.entry.imageGallery,
label: t("entry_actions.image_gallery"),
icon: <i className="i-mgc-pic-cute-fi" />,
category,
run: ({ entryId }) => {
openGalleryModal(entryId)
},
@ -347,6 +352,7 @@ export const useRegisterEntryCommands = () => {
{
id: COMMAND_ID.entry.tts,
label: t("entry_content.header.play_tts"),
category,
icon: <i className="i-mgc-voice-cute-re" />,
run: async ({ entryId, entryContent }) => {
if (getAudioPlayerAtomValue().entryId === entryId) {
@ -370,6 +376,7 @@ export const useRegisterEntryCommands = () => {
},
{
id: COMMAND_ID.entry.readability,
category,
label: {
title: t("entry_content.header.readability"),
description: t("entry_content.header.readability_description"),
@ -396,6 +403,7 @@ export const useRegisterEntryCommands = () => {
id: COMMAND_ID.entry.toggleAISummary,
label: t("entry_actions.toggle_ai_summary"),
icon: <i className="i-mgc-ai-cute-re" />,
category,
run: () => {
if (role === UserRole.Trial) {
presentActivationModal()
@ -408,6 +416,7 @@ export const useRegisterEntryCommands = () => {
id: COMMAND_ID.entry.toggleAITranslation,
label: t("entry_actions.toggle_ai_translation"),
icon: <i className="i-mgc-translate-2-ai-cute-re" />,
category,
run: () => {
if (role === UserRole.Trial) {
presentActivationModal()

View File

@ -1,19 +1,56 @@
import { EventBus } from "@follow/utils/event-bus"
import { useTranslation } from "react-i18next"
import { useShortcutsModal } from "~/modules/modal/shortcuts"
import { useRegisterCommandEffect } from "../hooks/use-register-command"
import type { Command } from "../types"
import type { Command, CommandCategory } from "../types"
import { COMMAND_ID } from "./id"
declare module "@follow/utils/event-bus" {
interface EventBusMap {
"global:toggle-corner-play": void
"global:quick-add": void
}
}
const category: CommandCategory = "category.global"
export const useRegisterGlobalCommands = () => {
const showShortcuts = useShortcutsModal()
const { t } = useTranslation("shortcuts")
useRegisterCommandEffect([
{
id: COMMAND_ID.global.showShortcuts,
label: "Show shortcuts",
label: {
title: t("command.global.show_shortcuts.title"),
description: t("command.global.show_shortcuts.description"),
},
run: () => {
showShortcuts()
},
category,
},
{
id: COMMAND_ID.global.toggleCornerPlay,
label: {
title: t("command.global.toggle_corner_play.title"),
description: t("command.global.toggle_corner_play.description"),
},
run: () => {
EventBus.dispatch("global:toggle-corner-play")
},
category,
},
{
id: COMMAND_ID.global.quickAdd,
label: {
title: t("command.global.quick_add.title"),
description: t("command.global.quick_add.description"),
},
run: () => {
EventBus.dispatch("global:quick-add")
},
category,
},
])
}
@ -23,4 +60,14 @@ export type ShowShortcutsCommand = Command<{
fn: () => void
}>
export type GlobalCommand = ShowShortcutsCommand
export type ToggleCornerPlayCommand = Command<{
id: typeof COMMAND_ID.global.toggleCornerPlay
fn: () => void
}>
export type QuickAddCommand = Command<{
id: typeof COMMAND_ID.global.quickAdd
fn: () => void
}>
export type GlobalCommand = ShowShortcutsCommand | ToggleCornerPlayCommand | QuickAddCommand

View File

@ -44,9 +44,11 @@ export const COMMAND_ID = {
},
global: {
showShortcuts: "global:show-shortcuts",
toggleCornerPlay: "global:toggle-corner-play",
quickAdd: "global:quick-add",
},
layout: {
toggleTimelineColumn: "layout:toggle-timeline-column",
toggleSubscriptionColumn: "layout:toggle-subscription-column",
focusToTimeline: "layout:focus-to-timeline",
focusToSubscription: "layout:focus-to-subscription",
focusToEntryRender: "layout:focus-to-entry-render",

View File

@ -29,7 +29,7 @@ import { useEntryStore } from "~/store/entry"
import { useRegisterCommandEffect } from "../hooks/use-register-command"
import { defineFollowCommand } from "../registry/command"
import type { Command } from "../types"
import type { Command, CommandCategory } from "../types"
import { COMMAND_ID } from "./id"
export const useRegisterIntegrationCommands = () => {
@ -43,6 +43,7 @@ export const useRegisterIntegrationCommands = () => {
useRegisterZoteroCommands()
}
const category: CommandCategory = "category.integration"
const useRegisterEagleCommands = () => {
const { t } = useTranslation()
const { view } = useRouteParams()
@ -125,6 +126,7 @@ const useRegisterReadwiseCommands = () => {
id: COMMAND_ID.integration.saveToReadwise,
label: t("entry_actions.save_to_readwise"),
icon: <SimpleIconsReadwise />,
category,
run: async ({ entryId }) => {
const entry = useEntryStore.getState().flatMapEntries[entryId]
if (!entry) {
@ -193,6 +195,7 @@ const useRegisterInstapaperCommands = () => {
id: COMMAND_ID.integration.saveToInstapaper,
label: t("entry_actions.save_to_instapaper"),
icon: <SimpleIconsInstapaper />,
category,
run: async ({ entryId }) => {
const entry = useEntryStore.getState().flatMapEntries[entryId]
if (!entry) {
@ -302,6 +305,7 @@ const useRegisterObsidianCommands = () => {
id: COMMAND_ID.integration.saveToObsidian,
label: t("entry_actions.save_to_obsidian"),
icon: <SimpleIconsObsidian />,
category,
run: async ({ entryId }) => {
const entry = useEntryStore.getState().flatMapEntries[entryId]
if (!entry) {
@ -346,6 +350,7 @@ const useRegisterOutlineCommands = () => {
id: COMMAND_ID.integration.saveToOutline,
label: t("entry_actions.save_to_outline"),
icon: <SimpleIconsOutline />,
category,
run: async ({ entryId }) => {
const entry = useEntryStore.getState().flatMapEntries[entryId]
if (!entry) {
@ -409,6 +414,7 @@ const useRegisterReadeckCommands = () => {
id: COMMAND_ID.integration.saveToReadeck,
label: t("entry_actions.save_to_readeck"),
icon: <SimpleIconsReadeck />,
category,
run: async ({ entryId }) => {
const entry = useEntryStore.getState().flatMapEntries[entryId]
if (!entry) {
@ -477,6 +483,7 @@ const useRegisterCuboxCommands = () => {
id: COMMAND_ID.integration.saveToCubox,
label: t("entry_actions.save_to_cubox"),
icon: <SimpleIconsCubox />,
category,
run: async ({ entryId }) => {
const entry = useEntryStore.getState().flatMapEntries[entryId]
if (!entry) {
@ -571,6 +578,7 @@ const useRegisterZoteroCommands = () => {
id: COMMAND_ID.integration.saveToZotero,
label: t("entry_actions.save_to_zotero"),
icon: <SimpleIconsZotero />,
category,
run: async ({ entryId }) => {
const entry = useEntryStore.getState().flatMapEntries[entryId]
if (!entry) {

View File

@ -1,10 +1,11 @@
import { EventBus } from "@follow/utils/event-bus"
import { useTranslation } from "react-i18next"
import { getIsZenMode, getUISettings, setUISetting, setZenMode } from "~/atoms/settings/ui"
import { setTimelineColumnShow } from "~/atoms/sidebar"
import { useRegisterCommandEffect } from "../hooks/use-register-command"
import type { Command } from "../types"
import type { Command, CommandCategory } from "../types"
import { COMMAND_ID } from "./id"
interface FocusEvent {
@ -18,39 +19,52 @@ declare module "@follow/utils/event-bus" {
}
}
const category: CommandCategory = "category.layout"
export const useRegisterLayoutCommands = () => {
const { t } = useTranslation("shortcuts")
useRegisterCommandEffect([
{
id: COMMAND_ID.layout.toggleTimelineColumn,
label: "Toggle timeline column",
id: COMMAND_ID.layout.toggleSubscriptionColumn,
label: {
title: t("command.layout.toggle_subscription_column.title"),
description: t("command.layout.toggle_subscription_column.description"),
},
category,
run: () => {
setTimelineColumnShow((show) => !show)
},
},
{
id: COMMAND_ID.layout.focusToTimeline,
label: "Focus to timeline",
label: t("command.layout.focus_to_timeline.title"),
category,
run: () => {
EventBus.dispatch(COMMAND_ID.layout.focusToTimeline, { highlightBoundary: true })
},
},
{
id: COMMAND_ID.layout.focusToSubscription,
label: "Focus to subscription",
label: t("command.layout.focus_to_subscription.title"),
category,
run: () => {
EventBus.dispatch(COMMAND_ID.layout.focusToSubscription, { highlightBoundary: true })
},
},
{
id: COMMAND_ID.layout.focusToEntryRender,
label: "Enter Selected Entry",
label: t("command.layout.focus_to_entry_render.title"),
category,
run: () => {
EventBus.dispatch(COMMAND_ID.layout.focusToEntryRender, { highlightBoundary: true })
},
},
{
id: COMMAND_ID.layout.toggleWideMode,
label: "Toggle wide mode",
label: {
title: t("command.layout.toggle_wide_mode.title"),
description: t("command.layout.toggle_wide_mode.description"),
},
category,
run: () => {
const { wideMode } = getUISettings()
setUISetting("wideMode", !wideMode)
@ -58,7 +72,11 @@ export const useRegisterLayoutCommands = () => {
},
{
id: COMMAND_ID.layout.toggleZenMode,
label: "Toggle zen mode",
label: {
title: t("command.layout.toggle_zen_mode.title"),
description: t("command.layout.toggle_zen_mode.description"),
},
category,
run: () => {
setZenMode(!getIsZenMode())
},
@ -72,7 +90,7 @@ export type FocusToSubscriptionCommand = Command<{
}>
export type ToggleTimelineColumnCommand = Command<{
id: typeof COMMAND_ID.layout.toggleTimelineColumn
id: typeof COMMAND_ID.layout.toggleSubscriptionColumn
fn: () => void
}>

View File

@ -10,8 +10,10 @@ import { UrlBuilder } from "~/lib/url-builder"
import { ListForm } from "~/modules/discover/ListForm"
import { useRegisterCommandEffect } from "../hooks/use-register-command"
import type { CommandCategory } from "../types"
import { COMMAND_ID } from "./id"
const category: CommandCategory = "category.list"
export const useRegisterListCommands = () => {
const { t } = useTranslation()
@ -23,7 +25,7 @@ export const useRegisterListCommands = () => {
{
id: COMMAND_ID.list.edit,
label: t("sidebar.feed_actions.edit"),
// keyBinding: "E",
category,
run: ({ listId }) => {
if (!listId) return
present({
@ -35,14 +37,13 @@ export const useRegisterListCommands = () => {
{
id: COMMAND_ID.list.unfollow,
label: t("sidebar.feed_actions.unfollow"),
// keyBinding: "Meta+Backspace",
category,
run: ({ subscription }) => deleteSubscription({ subscription }),
},
{
id: COMMAND_ID.list.navigateTo,
label: t("sidebar.feed_actions.navigate_to_list"),
// keyBinding: "Meta+G",
// when: routeListId !== listId,
category,
run: ({ listId }) => {
if (!listId) return
navigateEntry({ listId })
@ -53,7 +54,7 @@ export const useRegisterListCommands = () => {
label: t("sidebar.feed_actions.open_list_in_browser", {
which: IN_ELECTRON ? t("words.browser") : t("words.newTab"),
}),
// keyBinding: "O",
category,
run: ({ listId }) => {
if (!listId) return
const { view } = getRouteParams()
@ -63,7 +64,7 @@ export const useRegisterListCommands = () => {
{
id: COMMAND_ID.list.copyUrl,
label: t("sidebar.feed_actions.copy_list_url"),
// keyBinding: "Meta+C",
category,
run: async ({ listId }) => {
if (!listId) return
const { view } = getRouteParams()
@ -76,7 +77,7 @@ export const useRegisterListCommands = () => {
{
id: COMMAND_ID.list.copyId,
label: t("sidebar.feed_actions.copy_list_id"),
// keyBinding: "Meta+Shift+C",
category,
run: async ({ listId }) => {
if (!listId) return
await navigator.clipboard.writeText(listId)

View File

@ -5,7 +5,7 @@ import { useSetTheme } from "~/hooks/common"
import { useShowCustomizeToolbarModal } from "~/modules/customize-toolbar/modal"
import { useRegisterCommandEffect } from "../hooks/use-register-command"
import type { Command } from "../types"
import type { Command, CommandCategory } from "../types"
import { COMMAND_ID } from "./id"
export const useRegisterSettingsCommands = () => {
@ -13,6 +13,7 @@ export const useRegisterSettingsCommands = () => {
useRegisterThemeCommands()
}
const category: CommandCategory = "category.settings"
const useCustomizeToolbarCommand = () => {
const [t] = useTranslation("settings")
const showModal = useShowCustomizeToolbarModal()
@ -20,7 +21,7 @@ const useCustomizeToolbarCommand = () => {
{
id: COMMAND_ID.settings.customizeToolbar,
label: t("customizeToolbar.title"),
category: "follow:settings",
category,
icon: <i className="i-mgc-settings-7-cute-re" />,
run() {
showModal()
@ -38,7 +39,7 @@ const useRegisterThemeCommands = () => {
{
id: COMMAND_ID.settings.changeThemeToAuto,
label: `To ${t("appearance.theme.system")}`,
category: "follow:settings",
category,
icon: <i className="i-mgc-settings-7-cute-re" />,
when: theme !== "system",
run() {
@ -48,7 +49,7 @@ const useRegisterThemeCommands = () => {
{
id: COMMAND_ID.settings.changeThemeToDark,
label: `To ${t("appearance.theme.dark")}`,
category: "follow:settings",
category,
icon: <i className="i-mingcute-moon-line" />,
when: theme !== "dark",
run() {
@ -58,7 +59,7 @@ const useRegisterThemeCommands = () => {
{
id: COMMAND_ID.settings.changeThemeToLight,
label: `To ${t("appearance.theme.light")}`,
category: "follow:settings",
category,
icon: <i className="i-mingcute-sun-line" />,
when: theme !== "light",
run() {

View File

@ -1,10 +1,11 @@
import { EventBus } from "@follow/utils/event-bus"
import { useTranslation } from "react-i18next"
import type { BizRouteParams } from "~/hooks/biz/useRouteParams"
import { getRouteParams } from "~/hooks/biz/useRouteParams"
import { useRegisterCommandEffect } from "../hooks/use-register-command"
import type { Command } from "../types"
import type { Command, CommandCategory } from "../types"
import { COMMAND_ID } from "./id"
declare module "@follow/utils/event-bus" {
@ -26,89 +27,138 @@ declare module "@follow/utils/event-bus" {
"subscription:open-site-in-browser": never
}
}
const LABEL_PREFIX = "Subscription"
const category: CommandCategory = "category.subscription"
export const useRegisterSubscriptionCommands = () => {
const { t } = useTranslation("shortcuts")
useRegisterCommandEffect([
{
id: COMMAND_ID.subscription.switchTabToNext,
label: `${LABEL_PREFIX}: Switch to next tab`,
label: {
title: t("command.subscription.switch_tab_to_next.title"),
description: t("command.subscription.switch_tab_to_next.description"),
},
category,
run: () => {
EventBus.dispatch(COMMAND_ID.subscription.switchTabToNext)
},
},
{
id: COMMAND_ID.subscription.switchTabToPrevious,
label: `${LABEL_PREFIX}: Switch to previous tab`,
label: {
title: t("command.subscription.switch_tab_to_previous.title"),
description: t("command.subscription.switch_tab_to_previous.description"),
},
category,
run: () => {
EventBus.dispatch(COMMAND_ID.subscription.switchTabToPrevious)
},
},
{
id: COMMAND_ID.subscription.switchTabToArticle,
label: `${LABEL_PREFIX}: Switch to article tab`,
label: {
title: t("command.subscription.switch_tab_to_article.title"),
description: t("command.subscription.switch_tab_to_article.description"),
},
category,
run: () => {
EventBus.dispatch(COMMAND_ID.subscription.switchTabToArticle)
},
},
{
id: COMMAND_ID.subscription.switchTabToSocial,
label: `${LABEL_PREFIX}: Switch to social tab`,
label: {
title: t("command.subscription.switch_tab_to_social.title"),
description: t("command.subscription.switch_tab_to_social.description"),
},
category,
run: () => {
EventBus.dispatch(COMMAND_ID.subscription.switchTabToSocial)
},
},
{
id: COMMAND_ID.subscription.switchTabToPicture,
label: `${LABEL_PREFIX}: Switch to picture tab`,
label: {
title: t("command.subscription.switch_tab_to_picture.title"),
description: t("command.subscription.switch_tab_to_picture.description"),
},
category,
run: () => {
EventBus.dispatch(COMMAND_ID.subscription.switchTabToPicture)
},
},
{
id: COMMAND_ID.subscription.switchTabToVideo,
label: `${LABEL_PREFIX}: Switch to video tab`,
label: {
title: t("command.subscription.switch_tab_to_video.title"),
description: t("command.subscription.switch_tab_to_video.description"),
},
category,
run: () => {
EventBus.dispatch(COMMAND_ID.subscription.switchTabToVideo)
},
},
{
id: COMMAND_ID.subscription.switchTabToAudio,
label: `${LABEL_PREFIX}: Switch to audio tab`,
label: {
title: t("command.subscription.switch_tab_to_audio.title"),
description: t("command.subscription.switch_tab_to_audio.description"),
},
category,
run: () => {
EventBus.dispatch(COMMAND_ID.subscription.switchTabToAudio)
},
},
{
id: COMMAND_ID.subscription.switchTabToNotification,
label: `${LABEL_PREFIX}: Switch to notification tab`,
label: {
title: t("command.subscription.switch_tab_to_notification.title"),
description: t("command.subscription.switch_tab_to_notification.description"),
},
category,
run: () => {
EventBus.dispatch(COMMAND_ID.subscription.switchTabToNotification)
},
},
{
id: COMMAND_ID.subscription.nextSubscription,
label: `${LABEL_PREFIX}: Next Subscription`,
label: {
title: t("command.subscription.next_subscription.title"),
description: t("command.subscription.next_subscription.description"),
},
category,
run: () => {
EventBus.dispatch(COMMAND_ID.subscription.nextSubscription)
},
},
{
id: COMMAND_ID.subscription.previousSubscription,
label: `${LABEL_PREFIX}: Previous Subscription`,
label: {
title: t("command.subscription.previous_subscription.title"),
description: t("command.subscription.previous_subscription.description"),
},
category,
run: () => {
EventBus.dispatch(COMMAND_ID.subscription.previousSubscription)
},
},
{
id: COMMAND_ID.subscription.toggleFolderCollapse,
label: `${LABEL_PREFIX}: Toggle Folder Collapse`,
label: {
title: t("command.subscription.toggle_folder_collapse.title"),
description: t("command.subscription.toggle_folder_collapse.description"),
},
category,
run: () => {
EventBus.dispatch(COMMAND_ID.subscription.toggleFolderCollapse)
},
},
{
id: COMMAND_ID.subscription.markAllAsRead,
label: `${LABEL_PREFIX}: Mark All as Read`,
label: {
title: t("command.subscription.mark_all_as_read.title"),
},
category,
run: () => {
const routeParams = getRouteParams()
EventBus.dispatch(COMMAND_ID.subscription.markAllAsRead, routeParams)
@ -116,14 +166,20 @@ export const useRegisterSubscriptionCommands = () => {
},
{
id: COMMAND_ID.subscription.openInBrowser,
label: `${LABEL_PREFIX}: Open in Browser`,
label: {
title: t("command.subscription.open_in_browser.title"),
},
category,
run: () => {
EventBus.dispatch(COMMAND_ID.subscription.openInBrowser)
},
},
{
id: COMMAND_ID.subscription.openSiteInBrowser,
label: `${LABEL_PREFIX}: Open site in Browser`,
label: {
title: t("command.subscription.open_site_in_browser.title"),
},
category,
run: () => {
EventBus.dispatch(COMMAND_ID.subscription.openSiteInBrowser)
},

View File

@ -1,9 +1,10 @@
import { EventBus } from "@follow/utils/event-bus"
import { useTranslation } from "react-i18next"
import { setGeneralSetting } from "~/atoms/settings/general"
import { useRegisterCommandEffect } from "../hooks/use-register-command"
import type { Command } from "../types"
import type { Command, CommandCategory } from "../types"
import { COMMAND_ID } from "./id"
declare module "@follow/utils/event-bus" {
@ -14,11 +15,18 @@ declare module "@follow/utils/event-bus" {
"timeline:enter": never
}
}
const category: CommandCategory = "category.timeline"
export const useRegisterTimelineCommand = () => {
const { t } = useTranslation("shortcuts")
useRegisterCommandEffect([
{
id: COMMAND_ID.timeline.switchToNext,
label: "Switch to next timeline",
label: {
title: t("command.timeline.switch_to_next.title"),
description: t("command.timeline.switch_to_next.description"),
},
category,
run: () => {
EventBus.dispatch("timeline:switch-to-next")
@ -26,21 +34,33 @@ export const useRegisterTimelineCommand = () => {
},
{
id: COMMAND_ID.timeline.switchToPrevious,
label: "Switch to previous timeline",
label: {
title: t("command.timeline.switch_to_previous.title"),
description: t("command.timeline.switch_to_previous.description"),
},
category,
run: () => {
EventBus.dispatch("timeline:switch-to-previous")
},
},
{
id: COMMAND_ID.timeline.refetch,
label: "Refetch timeline",
label: {
title: t("command.timeline.refetch.title"),
description: t("command.timeline.refetch.description"),
},
category,
run: () => {
EventBus.dispatch("timeline:refetch")
},
},
{
id: COMMAND_ID.timeline.unreadOnly,
label: "Unread Only",
label: {
title: t("command.timeline.toggle_unread_only.title"),
description: t("command.timeline.toggle_unread_only.description"),
},
category,
run: (unreadOnly: boolean) => {
setGeneralSetting("unreadOnly", unreadOnly)
},

View File

@ -1,62 +1,201 @@
import { shortcuts } from "~/constants/shortcuts"
import { getStorageNS } from "@follow/utils/ns"
import { transformShortcut } from "@follow/utils/utils"
import { useAtomValue, useSetAtom } from "jotai"
import { atomWithStorage, selectAtom } from "jotai/utils"
import { useCallback, useMemo } from "react"
import { COMMAND_ID } from "../commands/id"
import type { CommandCategory, FollowCommandId } from "../types"
import { getCommand } from "./use-command"
import type { RegisterHotkeyOptions } from "./use-register-hotkey"
import { useCommandHotkey } from "./use-register-hotkey"
const defaultCommandShortcuts = {
// Entry commands
[COMMAND_ID.entry.copyLink]: shortcuts.entry.copyLink.key,
[COMMAND_ID.entry.copyTitle]: shortcuts.entry.copyTitle.key,
[COMMAND_ID.entry.openInBrowser]: shortcuts.entry.openInBrowser.key,
[COMMAND_ID.entry.read]: shortcuts.entry.toggleRead.key,
[COMMAND_ID.entry.share]: shortcuts.entry.share.key,
[COMMAND_ID.entry.star]: shortcuts.entry.toggleStarred.key,
[COMMAND_ID.entry.tip]: shortcuts.entry.tip.key,
[COMMAND_ID.entry.tts]: shortcuts.entry.tts.key,
// Entry render commands
[COMMAND_ID.entryRender.nextEntry]: shortcuts.entry.nextEntry.key,
[COMMAND_ID.entryRender.previousEntry]: shortcuts.entry.previousEntry.key,
[COMMAND_ID.entryRender.scrollDown]: shortcuts.entry.scrollDown.key,
[COMMAND_ID.entryRender.scrollUp]: shortcuts.entry.scrollUp.key,
// Global commands
[COMMAND_ID.global.showShortcuts]: shortcuts.misc.showShortcuts.key,
export const defaultCommandShortcuts = {
// Layout commands
[COMMAND_ID.layout.toggleTimelineColumn]: shortcuts.layout.toggleSidebar.key,
[COMMAND_ID.layout.toggleWideMode]: shortcuts.layout.toggleWideMode.key,
[COMMAND_ID.layout.toggleZenMode]: shortcuts.layout.toggleZenMode.key,
[COMMAND_ID.layout.toggleSubscriptionColumn]: transformShortcut("$mod+B"),
[COMMAND_ID.layout.toggleWideMode]: transformShortcut("$mod+["),
[COMMAND_ID.layout.toggleZenMode]: transformShortcut("$mod+Shift+Z"),
// Subscription commands
[COMMAND_ID.subscription.markAllAsRead]: shortcuts.subscriptions.markAllAsRead.key,
[COMMAND_ID.subscription.nextSubscription]: shortcuts.subscriptions.nextSubscription.key,
[COMMAND_ID.subscription.openInBrowser]: shortcuts.subscriptions.openInBrowser.key,
[COMMAND_ID.subscription.openSiteInBrowser]: shortcuts.subscriptions.openSiteInBrowser.key,
[COMMAND_ID.subscription.previousSubscription]: shortcuts.subscriptions.previousSubscription.key,
[COMMAND_ID.subscription.switchTabToNext]: shortcuts.subscriptions.switchNextView.key,
[COMMAND_ID.subscription.switchTabToPrevious]: shortcuts.subscriptions.switchPreviousView.key,
[COMMAND_ID.subscription.toggleFolderCollapse]: shortcuts.subscriptions.toggleFolderCollapse.key,
[COMMAND_ID.subscription.markAllAsRead]: transformShortcut("Shift+$mod+A"),
[COMMAND_ID.subscription.openInBrowser]: "O",
[COMMAND_ID.subscription.openSiteInBrowser]: transformShortcut("$mod+O"),
[COMMAND_ID.subscription.previousSubscription]: "K, ArrowUp",
[COMMAND_ID.subscription.nextSubscription]: "J, ArrowDown",
[COMMAND_ID.subscription.switchTabToNext]: "Tab",
[COMMAND_ID.subscription.switchTabToPrevious]: transformShortcut("Shift+Tab"),
[COMMAND_ID.subscription.toggleFolderCollapse]: "Z",
// Timeline commands
[COMMAND_ID.timeline.refetch]: shortcuts.entries.refetch.key,
[COMMAND_ID.timeline.switchToNext]: shortcuts.entries.next.key,
[COMMAND_ID.timeline.switchToPrevious]: shortcuts.entries.previous.key,
[COMMAND_ID.timeline.unreadOnly]: shortcuts.entries.toggleUnreadOnly.key,
[COMMAND_ID.timeline.refetch]: "R",
[COMMAND_ID.timeline.unreadOnly]: "U",
[COMMAND_ID.timeline.switchToPrevious]: "K, ArrowUp",
[COMMAND_ID.timeline.switchToNext]: "J, ArrowDown",
// Entry commands
[COMMAND_ID.entry.copyLink]: transformShortcut("Shift+$mod+C"),
[COMMAND_ID.entry.copyTitle]: transformShortcut("Shift+$mod+B"),
[COMMAND_ID.entry.openInBrowser]: "B",
[COMMAND_ID.entry.read]: "M",
[COMMAND_ID.entry.share]: transformShortcut("$mod+Alt+S"),
[COMMAND_ID.entry.star]: "S",
[COMMAND_ID.entry.tip]: transformShortcut("Shift+$mod+T"),
[COMMAND_ID.entry.tts]: transformShortcut("Shift+$mod+V"),
// Entry render commands
[COMMAND_ID.entryRender.nextEntry]: "L, ArrowRight",
[COMMAND_ID.entryRender.previousEntry]: "H, ArrowLeft",
[COMMAND_ID.entryRender.scrollUp]: "K, ArrowUp",
[COMMAND_ID.entryRender.scrollDown]: "J, ArrowDown",
// Global commands
[COMMAND_ID.global.toggleCornerPlay]: "Space",
[COMMAND_ID.global.quickAdd]: transformShortcut("$mod+N"),
[COMMAND_ID.global.showShortcuts]: "?",
} as const
const overrideCommandShortcutsAtom = atomWithStorage<
Partial<Record<AllowCustomizeCommandId, string>>
>(getStorageNS("command-shortcuts"), {}, undefined, {
getOnInit: true,
})
export const useCommandShortcutItems = () => {
const commandShortcuts = useCommandShortcuts()
return useMemo(() => {
const groupedCommands = {} as Record<CommandCategory, FollowCommandId[]>
for (const commandKey in commandShortcuts) {
const command = getCommand(commandKey as FollowCommandId)
if (!command) {
continue
}
groupedCommands[command.category] ??= []
groupedCommands[command.category].push(commandKey as FollowCommandId)
}
return groupedCommands
}, [commandShortcuts])
}
export const allowCustomizeCommands = new Set([
COMMAND_ID.layout.toggleSubscriptionColumn,
COMMAND_ID.layout.toggleWideMode,
COMMAND_ID.layout.toggleZenMode,
COMMAND_ID.subscription.markAllAsRead,
COMMAND_ID.subscription.openInBrowser,
COMMAND_ID.subscription.openSiteInBrowser,
COMMAND_ID.subscription.switchTabToNext,
COMMAND_ID.subscription.switchTabToPrevious,
COMMAND_ID.subscription.toggleFolderCollapse,
COMMAND_ID.timeline.refetch,
COMMAND_ID.timeline.unreadOnly,
COMMAND_ID.entry.copyLink,
COMMAND_ID.entry.copyTitle,
COMMAND_ID.entry.openInBrowser,
COMMAND_ID.entry.read,
COMMAND_ID.entry.share,
COMMAND_ID.entry.star,
COMMAND_ID.entry.tip,
COMMAND_ID.entry.tts,
] as const)
type ExtractSetType<T extends Set<unknown>> = T extends Set<infer U> ? U : never
export type AllowCustomizeCommandId = ExtractSetType<typeof allowCustomizeCommands>
export type BindingCommandId = keyof typeof defaultCommandShortcuts
// eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-prefix, @eslint-react/hooks-extra/ensure-custom-hooks-using-other-hooks
const useCommandShortcut = (commandId: BindingCommandId): string => {
const commandShortcut = defaultCommandShortcuts[commandId]
return commandShortcut
export const useCommandShortcut = (commandId: BindingCommandId): string => {
return useAtomValue(
useMemo(
() =>
selectAtom(overrideCommandShortcutsAtom, (v) => {
return v[commandId] ?? defaultCommandShortcuts[commandId]
}),
[commandId],
),
)
}
export const useSetCustomCommandShortcut = () => {
const setOverrideCommandShortcuts = useSetAtom(overrideCommandShortcutsAtom)
return useCallback(
(commandId: AllowCustomizeCommandId, shortcut: string | null) => {
setOverrideCommandShortcuts((prev) => {
if (shortcut === null) {
const { [commandId]: _, ...rest } = prev
return rest
}
return { ...prev, [commandId]: shortcut }
})
},
[setOverrideCommandShortcuts],
)
}
/**
*
* @deprecated Use `useCommandShortcut` for more granular control
*/
export const useCommandShortcuts = () => {
return defaultCommandShortcuts
const overrideCommandShortcuts = useAtomValue(overrideCommandShortcutsAtom)
return {
...defaultCommandShortcuts,
...overrideCommandShortcuts,
}
}
export const useIsShortcutConflict = (
shortcut: string,
excludeCommandId?: AllowCustomizeCommandId,
) => {
const overrideCommandShortcuts = useAtomValue(overrideCommandShortcutsAtom)
return useMemo(() => {
const allShortcuts = {
...defaultCommandShortcuts,
...overrideCommandShortcuts,
}
// Check if the shortcut conflicts with any existing shortcuts
for (const [commandId, existingShortcut] of Object.entries(allShortcuts)) {
// Skip the command we're excluding (useful when editing an existing shortcut)
if (excludeCommandId && commandId === excludeCommandId) {
continue
}
// Normalize shortcuts for comparison (handle multiple shortcuts separated by comma)
const normalizedShortcut = shortcut.trim().toLowerCase()
const normalizedExisting = existingShortcut.toLowerCase()
// Check if shortcuts match exactly or if one is contained in the other's alternatives
const shortcutAlternatives = normalizedShortcut.split(",").map((s) => s.trim())
const existingAlternatives = normalizedExisting.split(",").map((s) => s.trim())
for (const shortcutAlt of shortcutAlternatives) {
for (const existingAlt of existingAlternatives) {
if (shortcutAlt === existingAlt) {
return {
hasConflict: true,
conflictingCommandId: commandId as FollowCommandId,
}
}
}
}
}
return {
hasConflict: false,
conflictingCommandId: null,
}
}, [shortcut, excludeCommandId, overrideCommandShortcuts])
}
export const useCommandBinding = <T extends BindingCommandId>({

View File

@ -16,7 +16,7 @@ export function createCommand<
id: options.id,
run: options.run,
icon: options.icon,
category: options.category ?? "follow:general",
category: options.category ?? "category.global",
get label() {
let { label } = options
label = typeof label === "function" ? label?.() : label

View File

@ -1,34 +1,41 @@
import { useReplaceGlobalFocusableScope } from "@follow/components/common/Focusable/hooks.js"
import { KbdCombined } from "@follow/components/ui/kbd/Kbd.js"
import { RootPortal } from "@follow/components/ui/portal/index.js"
import { Tooltip, TooltipContent, TooltipTrigger } from "@follow/components/ui/tooltip/index.js"
import { cn } from "@follow/utils/utils"
import type { FC, RefObject, SVGProps } from "react"
import { memo, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { useOnClickOutside } from "usehooks-ts"
import { shortcuts, shortcutsType } from "~/constants/shortcuts"
import { HotkeyScope } from "~/constants"
export const SettingShortcuts = () => {
import { useCommand } from "../hooks/use-command"
import type { AllowCustomizeCommandId } from "../hooks/use-command-binding"
import {
allowCustomizeCommands,
defaultCommandShortcuts,
useCommandShortcutItems,
useCommandShortcuts,
useIsShortcutConflict,
useSetCustomCommandShortcut,
} from "../hooks/use-command-binding"
import type { CommandCategory, FollowCommandId } from "../types"
export const ShortcutsGuideline = () => {
const { t } = useTranslation("shortcuts")
const commandShortcuts = useCommandShortcutItems()
return (
<div className="mt-4 space-y-6">
{Object.keys(shortcuts).map((type) => (
{Object.entries(commandShortcuts).map(([type, commands]) => (
<section key={type}>
<div className="text-text-secondary mb-2 pl-3 text-sm font-medium capitalize">
{t(shortcutsType[type])}
{t(type as CommandCategory)}
</div>
<div className="text-text rounded-md border text-[13px]">
{Object.keys(shortcuts[type]).map((action, index) => (
<div
key={`${type}-${action}`}
className={cn(
"flex h-9 items-center justify-between px-3 py-1.5",
index % 2 && "bg-fill-quinary",
)}
>
<div>{t(shortcuts[type][action].name)}</div>
<div>
<KbdCombined joint>
{`${shortcuts[type][action].key}${shortcuts[type][action].extra ? `, ${shortcuts[type][action].extra}` : ""}`}
</KbdCombined>
</div>
</div>
{commands.map((commandId) => (
<CommandShortcutItem key={commandId} commandId={commandId} />
))}
</div>
</section>
@ -36,3 +43,406 @@ export const SettingShortcuts = () => {
</div>
)
}
export const ShortcutSetting = () => {
const { t } = useTranslation("shortcuts")
const commandShortcuts = useCommandShortcutItems()
return (
<div>
<p className="mb-6 mt-4 space-y-2 text-sm">{t("settings.shortcuts.description")}</p>
{Object.entries(commandShortcuts).map(([type, commands]) => (
<section key={type} className="mb-8">
<div className="text-text border-border mb-4 border-b pb-2 text-base font-medium">
{t(type as CommandCategory)}
</div>
<div className="space-y-4">
{commands.map((commandId) => (
<EditableCommandShortcutItem key={commandId} commandId={commandId} />
))}
</div>
</section>
))}
</div>
)
}
const EditableCommandShortcutItem = memo(({ commandId }: { commandId: FollowCommandId }) => {
const command = useCommand(commandId)
const commandShortcuts = useCommandShortcuts()
const [isEditing, setIsEditing] = useState(false)
const setCustomCommandShortcut = useSetCustomCommandShortcut()
const allowCustomize = allowCustomizeCommands.has(commandId as AllowCustomizeCommandId)
if (!command) return null
const isUserCustomize = commandShortcuts[commandId] !== defaultCommandShortcuts[commandId]
return (
<div
className={
"relative box-content grid h-8 grid-cols-[auto_200px] items-center justify-between gap-x-8 py-1.5"
}
>
<div className="flex min-w-0 flex-col gap-1">
<div className="text-text flex items-center gap-2 text-sm">
{command.label.title}
{isUserCustomize && (
<Tooltip>
<TooltipTrigger asChild>
<div
className="bg-accent/10 text-accent hover:bg-accent/20 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium transition-all duration-200"
title="User customized shortcut"
>
<div className="bg-accent mr-1 size-2 rounded-full" />
Custom
</div>
</TooltipTrigger>
<TooltipContent>This shortcut is customized by you</TooltipContent>
</Tooltip>
)}
</div>
{!!command.label.description && (
<small className="text-text-secondary text-xs">{command.label.description}</small>
)}
</div>
<ShortcutInputWrapper
commandId={commandId}
shortcut={commandShortcuts[commandId]}
isEditing={isEditing}
isUserCustomize={isUserCustomize}
allowCustomize={allowCustomize}
onEditingChange={setIsEditing}
onShortcutChange={(shortcut) => {
setCustomCommandShortcut(commandId as AllowCustomizeCommandId, shortcut)
setIsEditing(false)
}}
/>
</div>
)
})
interface ShortcutInputWrapperProps {
commandId: FollowCommandId
shortcut: string
isEditing: boolean
isUserCustomize: boolean
allowCustomize: boolean
onEditingChange: (editing: boolean) => void
onShortcutChange: (shortcut: string | null) => void
}
const ShortcutInputWrapper = memo(
({
commandId,
shortcut,
isEditing,
isUserCustomize,
allowCustomize,
onEditingChange,
onShortcutChange,
}: ShortcutInputWrapperProps) => {
const conflictResult = useIsShortcutConflict(shortcut, commandId as AllowCustomizeCommandId)
const hasConflict = allowCustomize && conflictResult.hasConflict
const conflictingCommandId = allowCustomize ? conflictResult.conflictingCommandId : null
const conflictCommand = useCommand(conflictingCommandId as FollowCommandId)
const getBorderColor = () => {
if (hasConflict) {
return "border-red/70 hover:!border-red"
}
if (isEditing) {
return "border-border bg-material-ultra-thick"
}
if (allowCustomize) {
return "border-border/50 bg-material-ultra-thin data-[customized=true]:bg-accent/10 data-[customized=true]:border-accent/50"
}
return "border-transparent"
}
const getBackgroundColor = () => {
if (hasConflict && !isEditing) {
return "bg-red/5"
}
if (isEditing) {
return "bg-material-ultra-thick"
}
return ""
}
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
data-customized={isUserCustomize}
className={cn(
"flex h-full cursor-text justify-end rounded-md border px-1 duration-200",
allowCustomize && "hover:!border-border hover:!bg-material-medium",
getBorderColor(),
getBackgroundColor(),
!allowCustomize && "pointer-events-none",
)}
onClick={() => {
if (allowCustomize) {
onEditingChange(!isEditing)
}
}}
>
{isEditing ? (
<KeyRecorder
onBlur={() => {
onEditingChange(false)
}}
onChange={(keys) => {
onShortcutChange(Array.isArray(keys) ? keys.join("+") : null)
}}
/>
) : (
<KbdCombined kbdProps={{ wrapButton: false }} joint={false}>
{shortcut}
</KbdCombined>
)}
</button>
</TooltipTrigger>
{hasConflict && (
<RootPortal>
<TooltipContent className="max-w-xs">
<div className="space-y-1">
<div className="font-medium text-red-400">Shortcut Conflict</div>
<div className="text-xs">
This shortcut conflicts with command:
<p className="text-sm font-medium">{conflictCommand?.label.title}</p>
</div>
</div>
</TooltipContent>
</RootPortal>
)}
</Tooltip>
)
},
)
const KeyRecorder: FC<{
onChange: (keys: string[] | null) => void
onBlur: () => void
}> = ({ onChange, onBlur }) => {
const { currentKeys } = useShortcutRecorder()
const setGlobalScope = useReplaceGlobalFocusableScope()
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const { rollback } = setGlobalScope(HotkeyScope.Recording)
if (ref.current) {
ref.current.focus()
}
return () => {
rollback()
}
}, [setGlobalScope])
useOnClickOutside(ref as RefObject<HTMLElement>, () => {
if (currentKeys.length > 0) {
onChange(currentKeys)
}
onBlur()
})
return (
<div
className="text-text-secondary flex h-full items-center justify-center px-1 text-xs"
tabIndex={-1}
role="textbox"
ref={ref}
>
{currentKeys.length > 0 ? (
<div className="pr-4">
<KbdCombined kbdProps={{ wrapButton: false }} joint={false}>
{currentKeys.join("+")}
</KbdCombined>
</div>
) : (
<span className="text-text-secondary pr-4">Press keys to record</span>
)}
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<button
type="button"
className="hover:text-text absolute inset-y-0 right-0 z-[1] flex items-center justify-center px-1"
onClick={(e) => {
e.stopPropagation()
if (currentKeys.length === 0) {
onChange(null)
} else {
onBlur()
}
}}
>
{currentKeys.length > 0 ? (
<i className="i-mingcute-close-circle-fill size-4" />
) : (
<FamiconsArrowUndoCircle className="size-4" />
)}
</button>
</TooltipTrigger>
<TooltipContent>{currentKeys.length > 0 ? "Undo" : "Reset"}</TooltipContent>
</Tooltip>
</div>
)
}
function FamiconsArrowUndoCircle(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 512 512"
{...props}
>
{/* Icon from Famicons by Family - https://github.com/familyjs/famicons/blob/main/LICENSE */}
<path
fill="currentColor"
d="M256 48C141.13 48 48 141.13 48 256s93.13 208 208 208s208-93.13 208-208S370.87 48 256 48m97.67 281.1c-24.07-25.21-51.51-38.68-108.58-38.68v37.32a8.32 8.32 0 0 1-14.05 6L146.58 254a8.2 8.2 0 0 1 0-11.94L231 162.29a8.32 8.32 0 0 1 14.05 6v37.32c88.73 0 117.42 55.64 122.87 117.09c.73 7.72-8.85 12.05-14.25 6.4"
/>
</svg>
)
}
const CommandShortcutItem = memo(({ commandId }: { commandId: FollowCommandId }) => {
const command = useCommand(commandId)
const commandShortcuts = useCommandShortcuts()
if (!command) return null
return (
<div className={"odd:bg-fill-quinary flex h-9 items-center justify-between px-3 py-1.5"}>
<div>{command.label.title}</div>
<div>
<KbdCombined joint>{commandShortcuts[commandId]}</KbdCombined>
</div>
</div>
)
})
///////
const MODIFIER_KEYS_MAP = {
Control: "Control",
Alt: "Alt",
Shift: "Shift",
Meta: "Meta",
} as const
const MODIFIER_KEYS_SET = new Set<string>(Object.values(MODIFIER_KEYS_MAP))
const F_KEY_REGEX = /^F(?:[1-9]|1[0-2])$/
function getKeySortValue(key: string): number {
if (key === MODIFIER_KEYS_MAP.Meta) return 0
if (key === MODIFIER_KEYS_MAP.Control) return 1
if (key === MODIFIER_KEYS_MAP.Alt) return 2
if (key === MODIFIER_KEYS_MAP.Shift) return 3
if (F_KEY_REGEX.test(key)) return 4
return 5
}
function sortShortcutKeys(keys: string[]): string[] {
return [...keys].sort((a, b) => {
const sortValueA = getKeySortValue(a)
const sortValueB = getKeySortValue(b)
if (sortValueA !== sortValueB) {
return sortValueA - sortValueB
}
return a.localeCompare(b)
})
}
const useShortcutRecorder = () => {
const [currentKeys, setCurrentKeys] = useState<string[]>([])
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()
const { altKey, ctrlKey, metaKey, shiftKey, key: eventKey } = event
let mainKeyPressed = eventKey
if (mainKeyPressed.length === 1 && mainKeyPressed >= "a" && mainKeyPressed <= "z") {
mainKeyPressed = mainKeyPressed.toUpperCase()
} else if (mainKeyPressed === " ") {
mainKeyPressed = "Space"
}
const pressedKeysSet = new Set<string>()
// 添加修饰键
if (metaKey) pressedKeysSet.add(MODIFIER_KEYS_MAP.Meta)
if (ctrlKey) pressedKeysSet.add(MODIFIER_KEYS_MAP.Control)
if (altKey) pressedKeysSet.add(MODIFIER_KEYS_MAP.Alt)
if (shiftKey) pressedKeysSet.add(MODIFIER_KEYS_MAP.Shift)
// If mainKeyPressed (from event.key) is not a modifier key, add it as the main key.
// If mainKeyPressed is a modifier key (e.g., user only pressed Shift key, event.key is "Shift"),
// it has already been handled and added to pressedKeysSet by the above if (shiftKey) logic,
// so we don't need to add it again here.
if (!MODIFIER_KEYS_SET.has(mainKeyPressed)) {
pressedKeysSet.add(mainKeyPressed)
}
const currentCombination = Array.from(pressedKeysSet)
// --- Start validation rules ---
const nonModifierKeysInCombo = currentCombination.filter((key) => !MODIFIER_KEYS_SET.has(key))
// Rule 2: Pure modifier key combinations are not allowed (e.g., just Shift, or Ctrl+Alt)
if (nonModifierKeysInCombo.length === 0) {
// When only modifier keys are pressed, currentCombination will still contain these modifiers.
// For example, pressing only Shift, currentCombination is ["Shift"]
// Here we don't update the state, indicating this is an invalid recording.
// You can provide temporary UI feedback here, e.g.: "Recording: Shift"
console.info(
"Recording (invalid - modifiers only):",
sortShortcutKeys(currentCombination).join(" + "),
)
return
}
// Typically shortcuts have only one "main" function key (e.g., Ctrl+A, Shift+F1)
// If multiple non-modifier keys are detected (e.g., theoretically user pressing A and B simultaneously),
// this is usually not a standard shortcut recording scenario
// This check is mainly for code robustness, as `keydown` events typically focus on one main key at a time.
if (nonModifierKeysInCombo.length > 1) {
console.warn(
"Recording (invalid - multiple main keys, this shouldn't normally happen):",
sortShortcutKeys(currentCombination).join(" + "),
)
return
}
const primaryKey = nonModifierKeysInCombo[0]
// Rule 3: Fn keys (F1-F12) can be single keys or modifier+Fn key combinations
if (F_KEY_REGEX.test(primaryKey ?? "")) {
setCurrentKeys(sortShortcutKeys(currentCombination))
return
}
// Rule 1: Single "ASCII" main keys are allowed (here referring to all non-modifier, non-F keys)
// Examples: A, 1, Space, Enter, ArrowUp, etc. They can be used alone or with modifiers.
// For these keys, as long as they're not pure modifier combinations, they're considered valid.
setCurrentKeys(sortShortcutKeys(currentCombination))
}
window.addEventListener("keydown", handleKeyDown)
return () => {
window.removeEventListener("keydown", handleKeyDown)
}
}, [setCurrentKeys])
return { currentKeys }
}

View File

@ -2,13 +2,8 @@ import type { ReactNode } from "react"
import type { BasicCommand } from "./commands/types"
export type CommandCategory =
| "follow:settings"
| "follow:layout"
| "follow:updates"
| "follow:help"
| "follow:general"
| "follow:entry-render"
type ExtractCategory<T extends string> = T extends `category.${string}` ? T : never
export type CommandCategory = ExtractCategory<Parameters<typeof tShortcuts>[0]>
export interface KeybindingOptions {
binding: string

View File

@ -1,4 +1,7 @@
import { useFocusable, useFocusActions } from "@follow/components/common/Focusable/hooks.js"
import {
useFocusActions,
useGlobalFocusableScope,
} from "@follow/components/common/Focusable/hooks.js"
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
import { useRefValue } from "@follow/hooks"
import { nextFrame } from "@follow/utils/dom"
@ -9,8 +12,6 @@ import { memo, useEffect } from "react"
import { HotkeyScope } from "~/constants"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { useRouteEntryId } from "~/hooks/biz/useRouteParams"
import { useConditionalHotkeyScope } from "~/hooks/common"
import { useHotkeyScope } from "~/providers/hotkey-provider"
import { COMMAND_ID } from "../command/commands/id"
import { useCommandBinding } from "../command/hooks/use-command-binding"
@ -23,10 +24,9 @@ export const EntryColumnShortcutHandler: FC<{
}> = memo(({ data, refetch, handleScrollTo }) => {
const dataRef = useRefValue(data!)
const activeScope = useHotkeyScope()
const activeScope = useGlobalFocusableScope()
const when =
activeScope.includes(HotkeyScope.Timeline) && !activeScope.includes(HotkeyScope.EntryRender)
const when = activeScope.has(HotkeyScope.Timeline) && !activeScope.has(HotkeyScope.EntryRender)
useCommandBinding({
commandId: COMMAND_ID.timeline.switchToNext,
@ -111,9 +111,5 @@ export const EntryColumnShortcutHandler: FC<{
)
}, [$scrollArea, highlightBoundary])
const isFocusIn = useFocusable()
useConditionalHotkeyScope(HotkeyScope.Timeline, isFocusIn, true)
return null
})

View File

@ -1,3 +1,4 @@
import { useGlobalFocusableScope } from "@follow/components/common/Focusable/hooks.js"
import { ActionButton, Button } from "@follow/components/ui/button/index.js"
import { Kbd, KbdCombined } from "@follow/components/ui/kbd/Kbd.js"
import { useCountdown } from "@follow/hooks"
@ -13,7 +14,6 @@ import { HotkeyScope } from "~/constants"
import { useI18n } from "~/hooks/common"
import { COMMAND_ID } from "~/modules/command/commands/id"
import { useCommandBinding, useCommandShortcuts } from "~/modules/command/hooks/use-command-binding"
import { useHotkeyScope } from "~/providers/hotkey-provider"
import type { MarkAllFilter } from "../hooks/useMarkAll"
import { markAllByRoute } from "../hooks/useMarkAll"
@ -33,11 +33,11 @@ export const MarkAllReadButton = ({
const { t } = useTranslation()
const { t: commonT } = useTranslation("common")
const activeScope = useHotkeyScope()
const activeScope = useGlobalFocusableScope()
useCommandBinding({
commandId: COMMAND_ID.subscription.markAllAsRead,
when: [HotkeyScope.Timeline, HotkeyScope.SubscriptionList].some((scope) =>
activeScope.includes(scope),
activeScope.has(scope),
),
})
@ -105,7 +105,6 @@ const ConfirmMarkAllReadInfo = ({ undo }: { undo: () => any }) => {
const [countdown] = useCountdown({ countStart: 3 })
useHotkeys("ctrl+z,meta+z", undo, {
scopes: HotkeyScope.Home,
preventDefault: true,
})

View File

@ -1,4 +1,3 @@
import { Focusable } from "@follow/components/common/Focusable/index.js"
import { useMobile } from "@follow/components/hooks/useMobile.js"
import { FeedViewType, views } from "@follow/constants"
import { useTitle } from "@follow/hooks"
@ -7,8 +6,9 @@ import type { Range, Virtualizer } from "@tanstack/react-virtual"
import { memo, useCallback, useEffect, useRef } from "react"
import { useGeneralSettingKey } from "~/atoms/settings/general"
import { Focusable } from "~/components/common/Focusable"
import { FeedNotFound } from "~/components/errors/FeedNotFound"
import { FEED_COLLECTION_LIST, ROUTE_FEED_PENDING } from "~/constants"
import { FEED_COLLECTION_LIST, HotkeyScope, ROUTE_FEED_PENDING } from "~/constants"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { useRouteParams, useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { useFeedQuery } from "~/queries/feed"
@ -118,6 +118,7 @@ function EntryColumnImpl() {
const ListComponent = views[view]!.gridMode ? EntryColumnGrid : EntryList
return (
<Focusable
scope={HotkeyScope.Timeline}
data-hide-in-print
className="@container relative flex h-full flex-1 flex-col"
onClick={

View File

@ -19,7 +19,7 @@ import { useEntryIsRead } from "~/hooks/biz/useAsRead"
import { useEntryActions } from "~/hooks/biz/useEntryActions"
import { useFeedActions } from "~/hooks/biz/useFeedActions"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { getRouteParams, useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { useContextMenu } from "~/hooks/common/useContextMenu"
import { COMMAND_ID } from "~/modules/command/commands/id"
import type { FlatEntryModel } from "~/store/entry"
@ -70,17 +70,20 @@ export const EntryItemWrapper: FC<
(e) => {
e.stopPropagation()
const shouldNavigate = getRouteParams().entryId !== entry.entries.id
if (!shouldNavigate) return
if (!asRead) {
entryActions.markRead({ feedId: entry.feedId, entryId: entry.entries.id, read: true })
}
navigate({
entryId: entry.entries.id,
})
setTimeout(
() => EventBus.dispatch(COMMAND_ID.layout.focusToEntryRender, { highlightBoundary: false }),
60,
)
navigate({
entryId: entry.entries.id,
})
},
[asRead, entry.entries.id, entry.feedId, navigate],
)

View File

@ -3,7 +3,7 @@ import { useCallback } from "react"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { useRouteParams } from "~/hooks/biz/useRouteParams"
import { InboxItem, ListItem } from "~/modules/timeline-column/FeedItem"
import { InboxItem, ListItem } from "~/modules/subscription-column/FeedItem"
import { useSubscriptionStore } from "~/store/subscription"
export const TimelineTabs = () => {

View File

@ -1,7 +1,6 @@
import {
Focusable,
useFocusable,
useFocusActions,
useGlobalFocusableScope,
} from "@follow/components/common/Focusable/index.js"
import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDangerousHTMLStyle.js"
import { Spring } from "@follow/components/constants/spring.js"
@ -23,15 +22,14 @@ import { useEffect, useMemo, useRef, useState } from "react"
import { useEntryIsInReadability } from "~/atoms/readability"
import { useIsZenMode, useUISettingKey } from "~/atoms/settings/ui"
import { Focusable } from "~/components/common/Focusable"
import { ShadowDOM } from "~/components/common/ShadowDOM"
import type { TocRef } from "~/components/ui/markdown/components/Toc"
import { useInPeekModal } from "~/components/ui/modal/inspire/InPeekModal"
import { HotkeyScope } from "~/constants"
import { useRenderStyle } from "~/hooks/biz/useRenderStyle"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { useConditionalHotkeyScope } from "~/hooks/common"
import { useFeedSafeUrl } from "~/hooks/common/useFeedSafeUrl"
import { useHotkeyScope } from "~/providers/hotkey-provider"
import { WrappedElementProvider } from "~/providers/wrapped-element-provider"
import { useEntry } from "~/store/entry"
import { useFeedById } from "~/store/feed"
@ -94,7 +92,6 @@ export const EntryContent: Component<EntryContentProps> = ({
const isInPeekModal = useInPeekModal()
const [isUserInteraction, setIsUserInteraction] = useState(false)
const isZenMode = useIsZenMode()
const [panelPortalElement, setPanelPortalElement] = useState<HTMLDivElement | null>(null)
@ -130,16 +127,11 @@ export const EntryContent: Component<EntryContentProps> = ({
<div className="w-full" ref={setPanelPortalElement} />
<Focusable
scope={HotkeyScope.EntryRender}
className="@container relative flex size-full flex-col overflow-hidden print:size-auto print:overflow-visible"
onFocus={() => setIsUserInteraction(true)}
>
<RootPortal to={panelPortalElement}>
<RegisterCommands
scrollAnimationRef={scrollAnimationRef}
scrollerRef={scrollerRef}
isUserInteraction={isUserInteraction}
setIsUserInteraction={setIsUserInteraction}
/>
<RegisterCommands scrollAnimationRef={scrollAnimationRef} scrollerRef={scrollerRef} />
</RootPortal>
<EntryTimelineSidebar entryId={entry.entries.id} />
<EntryScrollArea className={className} scrollerRef={scrollerRef}>
@ -178,8 +170,6 @@ export const EntryContent: Component<EntryContentProps> = ({
)}
<article
tabIndex={-1}
onFocus={() => setIsUserInteraction(true)}
data-testid="entry-render"
onContextMenu={stopPropagation}
className="@[950px]:max-w-[70ch] @7xl:max-w-[80ch] relative m-auto min-w-0 max-w-[550px]"
@ -318,23 +308,17 @@ const Renderer: React.FC<{
const RegisterCommands = ({
scrollerRef,
isUserInteraction,
setIsUserInteraction,
scrollAnimationRef,
}: {
scrollerRef: React.RefObject<HTMLDivElement | null>
isUserInteraction: boolean
setIsUserInteraction: (isUserInteraction: boolean) => void
scrollAnimationRef: React.RefObject<JSAnimation<any> | null>
}) => {
const isAlreadyScrolledBottomRef = useRef(false)
const [showKeepScrollingPanel, setShowKeepScrollingPanel] = useState(false)
const containerFocused = useFocusable()
useConditionalHotkeyScope(HotkeyScope.EntryRender, isUserInteraction && containerFocused, true)
const activeScope = useHotkeyScope()
const when = activeScope.includes(HotkeyScope.EntryRender)
const activeScope = useGlobalFocusableScope()
const when = activeScope.has(HotkeyScope.EntryRender)
useCommandBinding({
commandId: COMMAND_ID.entryRender.scrollUp,
@ -437,11 +421,10 @@ const RegisterCommands = ({
if (highlight) {
nextFrame(highlightBoundary)
}
setIsUserInteraction(true)
},
),
)
}, [highlightBoundary, scrollAnimationRef, scrollerRef, setIsUserInteraction])
}, [highlightBoundary, scrollAnimationRef, scrollerRef])
return (
<AnimatePresence>

View File

@ -1,26 +1,21 @@
import { MotionButtonBase } from "@follow/components/ui/button/index.js"
import { KbdCombined } from "@follow/components/ui/kbd/Kbd.js"
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
import { clsx, cn } from "@follow/utils/utils"
import { clsx } from "@follow/utils/utils"
import { m, useDragControls } from "motion/react"
import { useCallback, useEffect } from "react"
import { useCallback } from "react"
import { useTranslation } from "react-i18next"
import { useUISettingKey } from "~/atoms/settings/ui"
import { PlainModal } from "~/components/ui/modal/stacked/custom-modal"
import { useCurrentModal, useModalStack } from "~/components/ui/modal/stacked/hooks"
import { shortcuts, shortcutsType } from "~/constants/shortcuts"
import { useSwitchHotKeyScope } from "~/hooks/common"
import { ShortcutsGuideline } from "../command/shortcuts/SettingShortcuts"
const ShortcutModalContent = () => {
const { dismiss } = useCurrentModal()
const modalOverlay = useUISettingKey("modalOverlay")
const dragControls = useDragControls()
const switchScope = useSwitchHotKeyScope()
useEffect(() => {
switchScope("Home")
}, [])
const { t } = useTranslation("shortcuts")
return (
<m.div
@ -52,32 +47,8 @@ const ShortcutModalContent = () => {
<i className="i-mgc-close-cute-re" />
</MotionButtonBase>
<ScrollArea.ScrollArea scrollbarClassName="w-2" rootClassName="w-full h-full">
<div className="w-full space-y-6 px-4 pb-5 pt-6">
{Object.keys(shortcuts).map((type) => (
<section key={type}>
<div className="text-text-secondary mb-2 pl-3 text-xs font-medium capitalize">
{t(shortcutsType[type])}
</div>
<div className="text-text rounded-md border text-[13px]">
{Object.keys(shortcuts[type]).map((action, index) => (
<div
key={`${type}-${action}`}
className={cn(
"flex h-9 items-center justify-between px-3 py-1.5",
index % 2 && "bg-fill-quinary",
)}
>
<div>{t(shortcuts[type][action].name)}</div>
<div>
<KbdCombined joint>
{`${shortcuts[type][action].key}${shortcuts[type][action].extra ? `, ${shortcuts[type][action].extra}` : ""}`}
</KbdCombined>
</div>
</div>
))}
</div>
</section>
))}
<div className="w-full space-y-6 px-4 pb-5">
<ShortcutsGuideline />
</div>
</ScrollArea.ScrollArea>
</m.div>

View File

@ -1,21 +1,25 @@
import { useGlobalFocusableScopeSelector } from "@follow/components/common/Focusable/hooks.js"
import { Form, FormControl, FormField, FormItem } from "@follow/components/ui/form/index.jsx"
import { useRegisterGlobalContext } from "@follow/shared/bridge"
import { tracker } from "@follow/tracker"
import { EventBus } from "@follow/utils/event-bus"
import { cn } from "@follow/utils/utils"
import { zodResolver } from "@hookform/resolvers/zod"
import { useLayoutEffect } from "react"
import { useEffect, useLayoutEffect } from "react"
import { useForm } from "react-hook-form"
import { useHotkeys } from "react-hotkeys-hook"
import { useTranslation } from "react-i18next"
import { useEventCallback } from "usehooks-ts"
import { z } from "zod"
import { FocusablePresets } from "~/components/common/Focusable"
import { m } from "~/components/common/Motion"
import { PlainModal } from "~/components/ui/modal/stacked/custom-modal"
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { HotkeyScope } from "~/constants"
import { getRouteParams } from "~/hooks/biz/useRouteParams"
import { tipcClient } from "~/lib/client"
import { COMMAND_ID } from "../command/commands/id"
import { useCommandBinding } from "../command/hooks/use-command-binding"
import { FeedForm } from "../discover/FeedForm"
const CmdNPanel = () => {
@ -102,7 +106,7 @@ const CmdNPanel = () => {
export const CmdNTrigger = () => {
const { t } = useTranslation()
const { present } = useModalStack()
const handler = () => {
const handler = useEventCallback(() => {
present({
title: t("quick_add.title"),
content: CmdNPanel,
@ -111,14 +115,18 @@ export const CmdNTrigger = () => {
id: "quick-add",
clickOutsideToDismiss: true,
})
}
})
useCommandBinding({
commandId: COMMAND_ID.global.quickAdd,
when: useGlobalFocusableScopeSelector(FocusablePresets.isNotFloatingLayerScope),
})
useEffect(() => {
return EventBus.subscribe(COMMAND_ID.global.quickAdd, handler)
}, [handler])
useRegisterGlobalContext("quickAdd", handler)
useHotkeys("meta+n,ctrl+n", handler, {
scopes: HotkeyScope.Home,
preventDefault: true,
})
return null
}

View File

@ -1,16 +1,16 @@
import { useFocusable } from "@follow/components/common/Focusable/index.js"
import { useGlobalFocusableScopeSelector } from "@follow/components/common/Focusable/index.js"
import { Spring } from "@follow/components/constants/spring.js"
import { useMobile } from "@follow/components/hooks/useMobile.js"
import { Tooltip, TooltipContent, TooltipTrigger } from "@follow/components/ui/tooltip/index.jsx"
import { FeedViewType } from "@follow/constants"
import { tracker } from "@follow/tracker"
import { EventBus } from "@follow/utils/event-bus"
import { cn } from "@follow/utils/utils"
import * as Slider from "@radix-ui/react-slider"
import dayjs from "dayjs"
import { AnimatePresence, m } from "motion/react"
import { useEffect, useMemo, useState } from "react"
import Marquee from "react-fast-marquee"
import { useHotkeys } from "react-hotkeys-hook"
import { useTranslation } from "react-i18next"
import {
@ -19,8 +19,8 @@ import {
useAudioPlayerAtomSelector,
useAudioPlayerAtomValue,
} from "~/atoms/player"
import { FocusablePresets } from "~/components/common/Focusable"
import { VolumeSlider } from "~/components/ui/media/VolumeSlider"
import { HotkeyScope } from "~/constants"
import type { NavigateEntryOptions } from "~/hooks/biz/useNavigateEntry"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { FeedIcon } from "~/modules/feed/feed-icon"
@ -28,6 +28,9 @@ import { useEntry } from "~/store/entry"
import { useFeedById } from "~/store/feed"
import { useListById } from "~/store/list"
import { COMMAND_ID } from "../command/commands/id"
import { useCommandBinding } from "../command/hooks/use-command-binding"
const handleClickPlay = () => {
AudioPlayer.togglePlayAndPause()
}
@ -115,13 +118,17 @@ const CornerPlayerImpl = ({ hideControls, rounded }: ControlButtonProps) => {
const feed = useFeedById(entry?.feedId)
const list = useListById(listId)
const isFocused = useFocusable()
useHotkeys("space", handleClickPlay, {
preventDefault: true,
scopes: HotkeyScope.Home,
enabled: isFocused,
useCommandBinding({
commandId: COMMAND_ID.global.toggleCornerPlay,
when: useGlobalFocusableScopeSelector(FocusablePresets.isSubscriptionOrTimeline),
})
useEffect(() => {
return EventBus.subscribe(COMMAND_ID.global.toggleCornerPlay, () => {
handleClickPlay()
})
}, [])
useEffect(() => {
setNowPlaying({
title: entry?.entries.title || undefined,

View File

@ -105,7 +105,9 @@ export const SettingTabbedSegment: Component<{
}
export const SettingDescription: Component = ({ children, className }) => (
<small className={cn("text-text-secondary text-body block w-4/5", className)}>{children}</small>
<small className={cn("text-text-secondary text-body block w-4/5 leading-snug", className)}>
{children}
</small>
)
export const SettingActionItem = ({

View File

@ -113,12 +113,12 @@ export function SettingModalLayout(
onResizeStop={handleResizeStop}
enable={resizableOnly("bottomRight")}
defaultSize={{
width: 800,
height: 700,
width: 950,
height: 800,
}}
maxHeight="80vh"
minHeight={500}
minWidth={600}
maxHeight="90vh"
minHeight={600}
minWidth={700}
maxWidth="95vw"
className="flex !select-none flex-col"
>

View File

@ -1,8 +1,8 @@
import { useDraggable } from "@dnd-kit/core"
import {
useFocusable,
useFocusableContainerRef,
useFocusActions,
useGlobalFocusableScopeSelector,
} from "@follow/components/common/Focusable/hooks.js"
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
import { nextFrame } from "@follow/utils/dom"
@ -13,6 +13,7 @@ import { useTranslation } from "react-i18next"
import Selecto from "react-selecto"
import { useEventCallback, useEventListener } from "usehooks-ts"
import { FocusablePresets } from "~/components/common/Focusable"
import { useRouteParams } from "~/hooks/biz/useRouteParams"
import { useAuthQuery } from "~/hooks/common"
import { Queries } from "~/queries"
@ -37,12 +38,12 @@ import {
} from "./atom"
import { DraggableContext } from "./context"
import { FeedItem, ListItemAutoHideUnread } from "./FeedItem"
import type { FeedListProps } from "./FeedList"
import { EmptyFeedList, ListHeader, StarredItem } from "./FeedList.shared"
import { useShouldFreeUpSpace } from "./hook"
import { SortableFeedList, SortByAlphabeticalInbox, SortByAlphabeticalList } from "./sort-by"
import type { SubscriptionProps } from "./SubscriptionList"
import { EmptyFeedList, ListHeader, StarredItem } from "./SubscriptionList.shared"
const FeedListImpl = ({ ref, className, view }: FeedListProps) => {
const SubscriptionImpl = ({ ref, className, view }: SubscriptionProps) => {
const feedsData = useFeedsGroupedData(view)
const listsData = useListsGroupedData(view)
const inboxesData = useInboxesGroupedData(view)
@ -229,6 +230,7 @@ const FeedListImpl = ({ ref, className, view }: FeedListProps) => {
/>
<ScrollArea.ScrollArea
focusable={false}
ref={scrollerRef}
onScroll={() => {
selectoRef.current?.checkScroll()
@ -295,36 +297,38 @@ const FeedListImpl = ({ ref, className, view }: FeedListProps) => {
)
}
FeedListImpl.displayName = "FeedListImpl"
SubscriptionImpl.displayName = "FeedListImpl"
export const FeedList = memo(FeedListImpl)
export const SubscriptionList = memo(SubscriptionImpl)
const FeedCategoryPrefix = "feed-category-"
const useRegisterCommand = () => {
const isFocus = useFocusable()
const focusableContainerRef = useFocusableContainerRef()
const focusActions = useFocusActions()
const inSubscriptionScope = useGlobalFocusableScopeSelector(FocusablePresets.isSubscriptionList)
useCommandBinding({
commandId: COMMAND_ID.subscription.nextSubscription,
when: isFocus,
when: inSubscriptionScope,
})
useCommandBinding({
commandId: COMMAND_ID.subscription.previousSubscription,
when: isFocus,
when: inSubscriptionScope,
})
useCommandHotkey({
commandId: COMMAND_ID.layout.focusToTimeline,
when: isFocus,
when: inSubscriptionScope,
shortcut: "Enter, L, ArrowRight",
})
useCommandBinding({
commandId: COMMAND_ID.subscription.toggleFolderCollapse,
when: isFocus,
when: inSubscriptionScope,
})
const getCurrentActiveSubscriptionElement = useEventCallback(() => {

View File

@ -14,12 +14,12 @@ import {
useListsGroupedData,
} from "~/store/subscription"
import type { FeedListProps } from "./FeedList"
import { EmptyFeedList, ListHeader, StarredItem } from "./FeedList.shared"
import { SortableFeedList, SortByAlphabeticalInbox, SortByAlphabeticalList } from "./sort-by"
import { feedColumnStyles } from "./styles"
import type { SubscriptionProps } from "./SubscriptionList"
import { EmptyFeedList, ListHeader, StarredItem } from "./SubscriptionList.shared"
const FeedListImpl = ({ className, view }: FeedListProps) => {
const FeedListImpl = ({ className, view }: SubscriptionProps) => {
const feedsData = useFeedsGroupedData(view)
const listsData = useListsGroupedData(view)
const inboxesData = useInboxesGroupedData(view)
@ -116,4 +116,4 @@ const FeedListImpl = ({ className, view }: FeedListProps) => {
}
FeedListImpl.displayName = "FeedListImpl"
export const FeedList = memo(FeedListImpl)
export const SubscriptionList = memo(FeedListImpl)

View File

@ -0,0 +1,12 @@
import { withResponsiveSyncComponent } from "@follow/components/utils/selector.js"
import { SubscriptionList as FeedListDesktop } from "./SubscriptionList.electron"
import { SubscriptionList as FeedListMobile } from "./SubscriptionList.mobile"
export const SubscriptionList = withResponsiveSyncComponent(FeedListDesktop, FeedListMobile)
export type SubscriptionProps = ComponentType<
{ className?: string; view: number } & {
ref?: React.Ref<HTMLDivElement | null> | ((node: HTMLDivElement | null) => void)
}
>

View File

@ -2,11 +2,11 @@ import type { FeedViewType } from "@follow/constants"
import { ROUTE_TIMELINE_OF_VIEW } from "~/constants"
import { FeedList } from "./FeedList"
import { SubscriptionList } from "./SubscriptionList"
export default function TimelineList({ timelineId }: { timelineId: string }) {
if (timelineId.startsWith(ROUTE_TIMELINE_OF_VIEW)) {
const id = Number.parseInt(timelineId.slice(ROUTE_TIMELINE_OF_VIEW.length), 10) as FeedViewType
return <FeedList className="flex size-full flex-col text-sm" view={id} />
return <SubscriptionList className="flex size-full flex-col text-sm" view={id} />
}
}

View File

@ -1,4 +1,4 @@
import { useFocusable } from "@follow/components/common/Focusable/hooks.js"
import { useGlobalFocusableScope } from "@follow/components/common/Focusable/hooks.js"
import { ActionButton } from "@follow/components/ui/button/index.js"
import { RootPortal } from "@follow/components/ui/portal/index.js"
import { Routes } from "@follow/constants"
@ -17,13 +17,12 @@ import { useLocation } from "react-router"
import { useRootContainerElement } from "~/atoms/dom"
import { useUISettingKey } from "~/atoms/settings/ui"
import { setTimelineColumnShow, useTimelineColumnShow } from "~/atoms/sidebar"
import { Focusable } from "~/components/common/Focusable"
import { HotkeyScope } from "~/constants"
import { navigateEntry, useBackHome } from "~/hooks/biz/useNavigateEntry"
import { useReduceMotion } from "~/hooks/biz/useReduceMotion"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { useTimelineList } from "~/hooks/biz/useTimelineList"
import { useConditionalHotkeyScope } from "~/hooks/common"
import { useHotkeyScope } from "~/providers/hotkey-provider"
import { WindowUnderBlur } from "../../components/ui/background"
import { COMMAND_ID } from "../command/commands/id"
@ -115,11 +114,12 @@ export function FeedColumn({ children, className }: PropsWithChildren<{ classNam
return (
<WindowUnderBlur
as={Focusable}
scope={HotkeyScope.SubscriptionList}
data-hide-in-print
className={cn(
"relative flex h-full flex-col pt-2.5",
!feedColumnShow && ELECTRON_BUILD && "bg-material-opaque",
"relative flex h-full flex-col pt-2.5",
className,
)}
ref={focusableContainerRef}
@ -243,9 +243,12 @@ const CommandsHandler = ({
setActive: (args: string | ((prev: string | undefined, index: number) => string)) => void
timelineList: string[]
}) => {
const activeScope = useHotkeyScope()
const activeScope = useGlobalFocusableScope()
const when =
activeScope.includes(HotkeyScope.SubscriptionList) || activeScope.includes(HotkeyScope.Timeline)
activeScope.has(HotkeyScope.SubscriptionList) ||
activeScope.has(HotkeyScope.Timeline) ||
activeScope.size === 0
useCommandBinding({
commandId: COMMAND_ID.subscription.switchTabToNext,
when,
@ -268,8 +271,5 @@ const CommandsHandler = ({
})
}, [activeScope, setActive, timelineList])
const focus = useFocusable()
useConditionalHotkeyScope(HotkeyScope.SubscriptionList, focus, true)
return null
}

View File

@ -1,12 +0,0 @@
import { withResponsiveSyncComponent } from "@follow/components/utils/selector.js"
import { FeedList as FeedListDesktop } from "./FeedList.electron"
import { FeedList as FeedListMobile } from "./FeedList.mobile"
export const FeedList = withResponsiveSyncComponent(FeedListDesktop, FeedListMobile)
export type FeedListProps = ComponentType<
{ className?: string; view: number } & {
ref?: React.Ref<HTMLDivElement | null> | ((node: HTMLDivElement | null) => void)
}
>

View File

@ -1,4 +1,6 @@
import { SettingShortcuts } from "~/modules/command/shortcuts/SettingShortcuts"
import { isMobile } from "@follow/components/hooks/useMobile.js"
import { ShortcutSetting } from "~/modules/command/shortcuts/SettingShortcuts"
import { SettingsTitle } from "~/modules/settings/title"
import { defineSettingPageData } from "~/modules/settings/utils"
@ -9,12 +11,13 @@ export const loader = defineSettingPageData({
icon: iconName,
name: "titles.shortcuts",
priority,
hideIf: () => isMobile(),
})
export function Component() {
return (
<>
<SettingsTitle />
<SettingShortcuts />
<ShortcutSetting />
</>
)
}

View File

@ -1,3 +1,4 @@
import { useGlobalFocusableHasScope } from "@follow/components/common/Focusable/hooks.js"
import { useMobile } from "@follow/components/hooks/useMobile.js"
import { KbdCombined } from "@follow/components/ui/kbd/Kbd.js"
import { nextFrame, preventDefault } from "@follow/utils/dom"
@ -25,7 +26,6 @@ import {
ContextMenuTrigger,
} from "~/components/ui/context-menu"
import { HotkeyScope } from "~/constants"
import { useSwitchHotKeyScope } from "~/hooks/common"
export const ContextMenuProvider: Component = ({ children }) => (
<>
@ -38,16 +38,6 @@ const Handler = () => {
const ref = useRef<HTMLSpanElement>(null)
const [contextMenuState, setContextMenuState] = useContextMenuState()
const switchHotkeyScope = useSwitchHotKeyScope()
useEffect(() => {
if (!contextMenuState.open) return
switchHotkeyScope("Menu")
return () => {
switchHotkeyScope("Home")
}
}, [contextMenuState.open, switchHotkeyScope])
useEffect(() => {
if (!contextMenuState.open) return
const triggerElement = ref.current
@ -111,10 +101,13 @@ const Item = memo(({ item }: { item: FollowMenuItem }) => {
}
}, [item])
const itemRef = useRef<HTMLDivElement>(null)
useHotkeys((item as any as MenuItemText).shortcut!, () => itemRef.current?.click(), {
// enabled: item.enabled !== false && item.shortcut !== undefined,
enabled: item instanceof MenuItemText && !!item.shortcut,
scopes: HotkeyScope.Menu,
enabled:
useGlobalFocusableHasScope(HotkeyScope.Menu) &&
item instanceof MenuItemText &&
!!item.shortcut,
preventDefault: true,
})

View File

@ -5,21 +5,15 @@ import {
preventDefault,
stopPropagation,
} 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 { useRunCommandFn } from "~/modules/command/hooks/use-command"
import { useCommandBinding, useCommandShortcuts } from "~/modules/command/hooks/use-command-binding"
import { useHotkeyScope } from "./hotkey-provider"
export const GlobalHotkeysProvider = () => {
const activeScopes = useHotkeyScope()
useCommandBinding({
commandId: COMMAND_ID.global.showShortcuts,
})
@ -35,29 +29,6 @@ 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.focusToSubscription, { highlightBoundary: false })
}
})
// 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.focusToSubscription, { highlightBoundary: false })
}
})
const commandShortcuts = useCommandShortcuts()
const runCommandFn = useRunCommandFn()

View File

@ -1,42 +1,12 @@
import type { PropsWithChildren } from "react"
import { createContext, use, useEffect } from "react"
import { HotkeysProvider, useHotkeysContext } from "react-hotkeys-hook"
import { HotkeyScope } from "~/constants"
import { appLog } from "~/lib/log"
import { HotkeysProvider } from "react-hotkeys-hook"
import { GlobalHotkeysProvider } from "./global-hotkeys-provider"
const initialActiveScopes = [HotkeyScope.Home]
const HotkeyScopeContext = createContext<HotkeyScope[]>(null!)
export const HotkeyProvider: Component = ({ children }) => {
return (
<HotkeysProvider initiallyActiveScopes={initialActiveScopes}>
<HotkeyScopeProvider>
{children}
<GlobalHotkeysProvider />
</HotkeyScopeProvider>
<HotkeysProvider>
{children}
<GlobalHotkeysProvider />
</HotkeysProvider>
)
}
const HotkeyScopeProvider = ({ children }: PropsWithChildren) => {
const { activeScopes } = useHotkeysContext()
if (import.meta.env.DEV) {
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
appLog("activeScopes change to:", activeScopes)
}, [JSON.stringify(activeScopes)])
}
return <HotkeyScopeContext value={activeScopes as HotkeyScope[]}>{children}</HotkeyScopeContext>
}
export const useHotkeyScope = () => {
const hotkeyScope = use(HotkeyScopeContext)
if (!hotkeyScope) {
throw new Error("HotkeyScopeContext not found")
}
return hotkeyScope
}

View File

@ -1,3 +1,4 @@
import { GlobalFocusableProvider } from "@follow/components/common/Focusable/GlobalFocusableProvider.js"
import { MotionProvider } from "@follow/components/common/MotionProvider.jsx"
import { EventProvider } from "@follow/components/providers/event-provider.js"
import { StableRouterProvider } from "@follow/components/providers/stable-router-provider.js"
@ -40,34 +41,36 @@ export const RootProviders: FC<PropsWithChildren> = ({ children }) => (
<Provider store={jotaiStore}>
<MotionProvider>
<PersistQueryClientProvider persistOptions={persistConfig} client={queryClient}>
<HotkeyProvider>
<I18nProvider>
<ModalStackProvider>
<Toaster />
<EventProvider />
<GlobalFocusableProvider>
<HotkeyProvider>
<I18nProvider>
<ModalStackProvider>
<Toaster />
<EventProvider />
<UserProvider />
<ServerConfigsProvider />
<UserProvider />
<ServerConfigsProvider />
<StableRouterProvider />
<SettingSync />
<FollowCommandManager />
<StableRouterProvider />
<SettingSync />
<FollowCommandManager />
{import.meta.env.DEV && <Devtools />}
{import.meta.env.DEV && <Devtools />}
{children}
{children}
<Suspense>
<LazyExtensionExposeProvider />
<LazyContextMenuProvider />
<LazyLottieRenderContainer />
<LazyExternalJumpInProvider />
<LazyReloadPrompt />
{!IN_ELECTRON && <LazyPWAPrompt />}
</Suspense>
</ModalStackProvider>
</I18nProvider>
</HotkeyProvider>
<Suspense>
<LazyExtensionExposeProvider />
<LazyContextMenuProvider />
<LazyLottieRenderContainer />
<LazyExternalJumpInProvider />
<LazyReloadPrompt />
{!IN_ELECTRON && <LazyPWAPrompt />}
</Suspense>
</ModalStackProvider>
</I18nProvider>
</HotkeyProvider>
</GlobalFocusableProvider>
<InvalidateQueryProvider />
</PersistQueryClientProvider>

View File

@ -3,6 +3,13 @@ import AST from "unplugin-ast/vite"
export const astPlugin = AST({
transformer: [
RemoveWrapperFunction(["tw", "defineSettingPageData", "t_", "tShortcuts", "tSettings"]),
RemoveWrapperFunction([
"tw",
"defineSettingPageData",
"t_",
"tShortcuts",
"tSettings",
"defineFollowCommand",
]),
],
})

View File

@ -112,23 +112,23 @@
"entry_actions.mark_as_read": "Mark as Read / UnRead",
"entry_actions.mark_as_unread": "Mark as Unread",
"entry_actions.mark_below_as_read": "Mark Below as Read",
"entry_actions.open_in_browser": "Open In {{which}}",
"entry_actions.recent_reader": "Recent reader:",
"entry_actions.save_media_to_eagle": "Save Media To Eagle",
"entry_actions.open_in_browser": "Open in {{which}}",
"entry_actions.recent_reader": "Recent Reader:",
"entry_actions.save_media_to_eagle": "Save Media to Eagle",
"entry_actions.save_to_cubox": "Save to Cubox",
"entry_actions.save_to_instapaper": "Save To Instapaper",
"entry_actions.save_to_instapaper": "Save to Instapaper",
"entry_actions.save_to_obsidian": "Save to Obsidian",
"entry_actions.save_to_outline": "Save To Outline",
"entry_actions.save_to_readeck": "Save To Readeck",
"entry_actions.save_to_readwise": "Save To Readwise",
"entry_actions.save_to_outline": "Save to Outline",
"entry_actions.save_to_readeck": "Save to Readeck",
"entry_actions.save_to_readwise": "Save to Readwise",
"entry_actions.save_to_zotero": "Save to Zotero",
"entry_actions.saved_to_cubox": "Saved to Cubox",
"entry_actions.saved_to_eagle": "Saved To Eagle.",
"entry_actions.saved_to_instapaper": "Saved To Instapaper.",
"entry_actions.saved_to_eagle": "Saved to Eagle.",
"entry_actions.saved_to_instapaper": "Saved to Instapaper.",
"entry_actions.saved_to_obsidian": "Saved to Obsidian",
"entry_actions.saved_to_outline": "Saved To Outline.",
"entry_actions.saved_to_readeck": "Saved To Readeck.",
"entry_actions.saved_to_readwise": "Saved To Readwise.",
"entry_actions.saved_to_outline": "Saved to Outline.",
"entry_actions.saved_to_readeck": "Saved to Readeck.",
"entry_actions.saved_to_readwise": "Saved to Readwise.",
"entry_actions.saved_to_zotero": "Saved to Zotero",
"entry_actions.share": "Share",
"entry_actions.star": "Star / UnStar",

View File

@ -1,41 +1,73 @@
{
"keys.audio.playPause": "Play/Pause (When the Audio Player Is Open)",
"keys.entries.markAllAsRead": "Mark All as Read",
"keys.entries.next": "Next Entry",
"keys.entries.previous": "Previous Entry",
"keys.entries.refetch": "Refetch",
"keys.entries.toggleUnreadOnly": "Toggle Unread Only",
"keys.entry.copyLink": "Copy Link",
"keys.entry.copyTitle": "Copy Title",
"keys.entry.openInBrowser": "Open in Browser",
"keys.entry.openInNewTab": "Open in New Tab",
"keys.entry.scrollDown": "Scroll Down",
"keys.entry.scrollUp": "Scroll Up",
"keys.entry.share": "Share",
"keys.entry.tip": "Tip Power",
"keys.entry.toggleRead": "Toggle Read",
"keys.entry.toggleStarred": "Toggle Starred",
"keys.entry.tts": "Play TTS",
"keys.layout.toggleSidebar": "Show/Hide Feed Sidebar",
"keys.layout.toggleWideMode": "Toggle Wide Mode",
"keys.layout.zenMode": "Zen Mode",
"keys.misc.quickSearch": "Quick Search",
"keys.misc.showShortcuts": "Show/Hide Shortcuts",
"keys.subscriptions.add": "Add Subscription",
"keys.subscriptions.nextSubscription": "Next Subscription",
"keys.subscriptions.openInBrowser": "Open in Browser",
"keys.subscriptions.openSiteInBrowser": "Open Site in Browser",
"keys.subscriptions.previousSubscription": "Previous Subscription",
"keys.subscriptions.switchBetweenViews": "Switch Between Views",
"keys.subscriptions.switchNextView": "Switch to Next View",
"keys.subscriptions.switchPreviousView": "Switch to Previous View",
"keys.subscriptions.switchToView": "Switch to View",
"keys.subscriptions.toggleFolderCollapse": "Toggle Folder Collapse",
"keys.type.audio": "audio",
"keys.type.entries": "entries",
"keys.type.entry": "entry",
"keys.type.layout": "layout",
"keys.type.misc": "misc",
"keys.type.subscriptions": "subscriptions",
"sidebar_title": "Shortcuts"
"category.entry": "Entry",
"category.entry_render": "Entry Render",
"category.global": "Global",
"category.integration": "Integration",
"category.layout": "Layout",
"category.list": "List",
"category.settings": "Settings",
"category.subscription": "Subscription",
"category.timeline": "Timeline",
"command.entry.next_entry.description": "Switch to the next entry based on the current timeline",
"command.entry.next_entry.title": "Next Entry",
"command.entry.previous_entry.description": "Switch to the previous entry based on the current timeline",
"command.entry.previous_entry.title": "Previous Entry",
"command.entry.scroll_down.description": "Scroll down in the entry render",
"command.entry.scroll_down.title": "Scroll Down",
"command.entry.scroll_up.description": "Scroll up in the entry render",
"command.entry.scroll_up.title": "Scroll Up",
"command.global.quick_add.description": "Open quick add panel to follow a new feed or others",
"command.global.quick_add.title": "Quick Add",
"command.global.show_shortcuts.description": "Show the shortcuts guideline modal",
"command.global.show_shortcuts.title": "Show Shortcuts",
"command.global.toggle_corner_play.description": "Play/Pause playing status(If there is currently playing audio in the background)",
"command.global.toggle_corner_play.title": "Toggle Corner Play",
"command.layout.focus_to_entry_render.title": "Focus to Entry Render",
"command.layout.focus_to_subscription.title": "Focus to Subscription",
"command.layout.focus_to_timeline.title": "Focus to Timeline",
"command.layout.toggle_subscription_column.description": "Show/Hide the Subscription column in the layout",
"command.layout.toggle_subscription_column.title": "Toggle Subscription Column",
"command.layout.toggle_wide_mode.description": "Enable/Disable the wide mode in the layout",
"command.layout.toggle_wide_mode.title": "Toggle Wide Mode",
"command.layout.toggle_zen_mode.description": "Enable/Disable the zen mode in the layout",
"command.layout.toggle_zen_mode.title": "Toggle Zen Mode",
"command.subscription.mark_all_as_read.title": "Mark All as Read",
"command.subscription.next_subscription.description": "Next Subscription",
"command.subscription.next_subscription.title": "Next Subscription",
"command.subscription.open_in_browser.title": "Open in Browser",
"command.subscription.open_in_tab.title": "Open in Tab",
"command.subscription.open_site_in_browser.title": "Open Site in Browser",
"command.subscription.open_site_in_tab.title": "Open Site in Tab",
"command.subscription.previous_subscription.description": "Previous Subscription",
"command.subscription.previous_subscription.title": "Previous Subscription",
"command.subscription.switch_between_views.title": "Switch Between Views",
"command.subscription.switch_next_view.title": "Switch to Next View",
"command.subscription.switch_previous_view.title": "Switch to Previous View",
"command.subscription.switch_tab_to_article.description": "Switch to the article tab in the subscription view",
"command.subscription.switch_tab_to_article.title": "Switch to Article Tab",
"command.subscription.switch_tab_to_audio.description": "Switch to the audio tab in the subscription view",
"command.subscription.switch_tab_to_audio.title": "Switch to Audio Tab",
"command.subscription.switch_tab_to_next.description": "Switch to the next tab in the subscription view",
"command.subscription.switch_tab_to_next.title": "Switch to Next Tab",
"command.subscription.switch_tab_to_notification.description": "Switch to the notification tab in the subscription view",
"command.subscription.switch_tab_to_notification.title": "Switch to Notification Tab",
"command.subscription.switch_tab_to_picture.description": "Switch to the picture tab in the subscription view",
"command.subscription.switch_tab_to_picture.title": "Switch to Picture Tab",
"command.subscription.switch_tab_to_previous.description": "Switch to the previous tab in the subscription view",
"command.subscription.switch_tab_to_previous.title": "Switch to Previous Tab",
"command.subscription.switch_tab_to_social.description": "Switch to the social tab in the subscription view",
"command.subscription.switch_tab_to_social.title": "Switch to Social Tab",
"command.subscription.switch_tab_to_video.description": "Switch to the video tab in the subscription view",
"command.subscription.switch_tab_to_video.title": "Switch to Video Tab",
"command.subscription.toggle_folder_collapse.description": "Expand/Collapse the current selected folder in the Subscription view",
"command.subscription.toggle_folder_collapse.title": "Toggle Folder Collapse",
"command.timeline.refetch.description": "Refetch the current timeline",
"command.timeline.refetch.title": "Refetch",
"command.timeline.switch_to_next.description": "Switch to the next timeline item",
"command.timeline.switch_to_next.title": "Switch to Next Timeline",
"command.timeline.switch_to_previous.description": "Switch to the previous timeline item",
"command.timeline.switch_to_previous.title": "Switch to Previous Timeline",
"command.timeline.toggle_unread_only.description": "Enable/Disable the unread only mode in the timeline",
"command.timeline.toggle_unread_only.title": "Toggle Unread Only",
"settings.shortcuts.description": "Customize the application's shortcuts. Below is a list of some commands that can be customized."
}

View File

@ -1,34 +1 @@
{
"keys.audio.playPause": "再生/一時停止(オーディオプレーヤーが開いているとき)",
"keys.entries.markAllAsRead": "すべて既読にする",
"keys.entries.next": "次のエントリ",
"keys.entries.previous": "前のエントリ",
"keys.entries.refetch": "再取得",
"keys.entries.toggleUnreadOnly": "未読のみ切り替え",
"keys.entry.copyLink": "リンクをコピー",
"keys.entry.copyTitle": "タイトルをコピ",
"keys.entry.openInBrowser": "ブラウザで開く",
"keys.entry.openInNewTab": "新しいタブで開く",
"keys.entry.scrollDown": "下にスクロール",
"keys.entry.scrollUp": "上にスクロール",
"keys.entry.share": "共有",
"keys.entry.tip": "チップを送る",
"keys.entry.toggleRead": "既読/未読の切り替え",
"keys.entry.toggleStarred": "スターの切り替え",
"keys.entry.tts": "TTS を再生",
"keys.layout.toggleSidebar": "フィードサイドバーの表示/非表示",
"keys.layout.toggleWideMode": "ワイドモードに切り替え",
"keys.layout.zenMode": "Zen モード",
"keys.misc.quickSearch": "クイック検索",
"keys.misc.showShortcuts": "ショートカットを表示/非表示",
"keys.subscriptions.add": "購読を追加",
"keys.subscriptions.switchBetweenViews": "ビューを切り替え",
"keys.subscriptions.switchToView": "ビューの切り替え",
"keys.type.audio": "音声",
"keys.type.entries": "エントリー",
"keys.type.entry": "エントリー",
"keys.type.layout": "レイアウト",
"keys.type.misc": "その他",
"keys.type.subscriptions": "フィード",
"sidebar_title": "ショートカット"
}
{}

View File

@ -1,39 +1 @@
{
"keys.audio.playPause": "播放/暂停",
"keys.entries.markAllAsRead": "全部标记为已读",
"keys.entries.next": "下一个条目",
"keys.entries.previous": "上一个条目",
"keys.entries.refetch": "刷新",
"keys.entries.toggleUnreadOnly": "切换仅未读",
"keys.entry.copyLink": "复制链接",
"keys.entry.copyTitle": "复制标题",
"keys.entry.openInBrowser": "在浏览器打开",
"keys.entry.openInNewTab": "在新的标签页打开",
"keys.entry.scrollDown": "向下滚动",
"keys.entry.scrollUp": "向上滚动",
"keys.entry.share": "分享",
"keys.entry.tip": "打赏",
"keys.entry.toggleRead": "标记为已读/未读",
"keys.entry.toggleStarred": "加入收藏/取消收藏",
"keys.entry.tts": "文本转语音",
"keys.layout.toggleSidebar": "显示/隐藏侧边栏",
"keys.layout.toggleWideMode": "切换宽屏模式",
"keys.layout.zenMode": "禅定模式",
"keys.misc.quickSearch": "快速搜索",
"keys.misc.showShortcuts": "显示/隐藏快捷键",
"keys.subscriptions.add": "添加订阅源",
"keys.subscriptions.nextSubscription": "下一个订阅",
"keys.subscriptions.previousSubscription": "上一个订阅",
"keys.subscriptions.switchBetweenViews": "在类型之间切换",
"keys.subscriptions.switchNextView": "切换到下一个视图",
"keys.subscriptions.switchPreviousView": "切换到上一个视图",
"keys.subscriptions.switchToView": "切换到指定类型",
"keys.subscriptions.toggleFolderCollapse": "展开或折叠文件夹",
"keys.type.audio": "音频",
"keys.type.entries": "条目列表",
"keys.type.entry": "条目",
"keys.type.layout": "布局",
"keys.type.misc": "杂项",
"keys.type.subscriptions": "订阅源",
"sidebar_title": "快捷键"
}
{}

View File

@ -1,34 +1 @@
{
"keys.audio.playPause": "播放/暫停(當音訊播放器開啟時)",
"keys.entries.markAllAsRead": "全部標記為已讀",
"keys.entries.next": "下一條目",
"keys.entries.previous": "上一條目",
"keys.entries.refetch": "重新整理",
"keys.entries.toggleUnreadOnly": "切換僅顯示未讀",
"keys.entry.copyLink": "複製連結",
"keys.entry.copyTitle": "複製標題",
"keys.entry.openInBrowser": "在瀏覽器中打開",
"keys.entry.openInNewTab": "在新分頁中打開",
"keys.entry.scrollDown": "向下捲動",
"keys.entry.scrollUp": "向上捲動",
"keys.entry.share": "分享",
"keys.entry.tip": "贊助",
"keys.entry.toggleRead": "切換標記為已讀/未讀",
"keys.entry.toggleStarred": "切換收藏/取消收藏",
"keys.entry.tts": "播放文字轉語音",
"keys.layout.toggleSidebar": "顯示/隱藏摘要側邊欄",
"keys.layout.toggleWideMode": "切換寬螢幕模式",
"keys.layout.zenMode": "禪定模式",
"keys.misc.quickSearch": "快速搜尋",
"keys.misc.showShortcuts": "顯示/隱藏快捷鍵",
"keys.subscriptions.add": "新增 RSS 摘要",
"keys.subscriptions.switchBetweenViews": "在類別之間切換",
"keys.subscriptions.switchToView": "切換到指定類別",
"keys.type.audio": "音訊",
"keys.type.entries": "條目列表",
"keys.type.entry": "條目",
"keys.type.layout": "佈局",
"keys.type.misc": "雜項",
"keys.type.subscriptions": "RSS 摘要",
"sidebar_title": "快捷鍵"
}
{}

View File

@ -1,4 +1,13 @@
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"
import * as React from "react"
import {
cloneElement,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react"
import { useEventListener } from "usehooks-ts"
import {
@ -7,12 +16,18 @@ import {
FocusActionsContext,
FocusTargetRefContext,
} from "./context"
import { useSetGlobalFocusableScope } from "./hooks"
import { highlightElement } from "./utils"
export interface FocusableProps {
scope?: string
asChild?: boolean
}
export const Focusable: Component<
React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
> = ({ ref, ...props }) => {
React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & FocusableProps
> = ({ ref, scope, asChild, ...props }) => {
const { onBlur, onFocus, ...rest } = props
const [isFocusWithIn, setIsFocusWithIn] = useState(false)
const focusTargetRef = useRef<HTMLElement | undefined>(void 0)
@ -30,12 +45,38 @@ export const Focusable: Component<
highlightElement(element)
}, [])
const setGlobalFocusableScope = useSetGlobalFocusableScope()
useEffect(() => {
if (!scope) {
return
}
const $container = containerRef.current
if (!$container) return
const focusIn = () => {
setGlobalFocusableScope(scope, "append")
}
$container.addEventListener("focusin", focusIn)
const focusOut = () => {
setGlobalFocusableScope(scope, "remove")
}
$container.addEventListener("focusout", focusOut)
return () => {
$container.removeEventListener("focusin", focusIn)
$container.removeEventListener("focusout", focusOut)
}
}, [scope, setGlobalFocusableScope])
// highlight boundary
useEventListener("focusin", (e) => {
if (containerRef.current?.contains(e.target as Node)) {
setIsFocusWithIn(true)
focusTargetRef.current = e.target as HTMLElement
if (import.meta.env.DEV) {
highlightElement(containerRef.current!, "14, 165, 233")
console.info("[Focusable] focusin", containerRef.current)
}
} else {
setIsFocusWithIn(false)
@ -47,15 +88,39 @@ export const Focusable: Component<
setIsFocusWithIn(containerRef.current.contains(document.activeElement as Node))
}, [containerRef])
if (asChild) {
assertChildren(rest.children)
}
return (
<FocusableContext value={isFocusWithIn}>
<FocusTargetRefContext value={focusTargetRef}>
<FocusActionsContext value={useMemo(() => ({ highlightBoundary }), [highlightBoundary])}>
<FocusableContainerRefContext value={containerRef}>
<div tabIndex={-1} role="region" ref={containerRef} {...rest} />
{asChild ? (
cloneElement(
rest.children as React.ReactElement<React.HTMLAttributes<HTMLDivElement>>,
{
tabIndex: -1,
role: "region",
...rest,
},
)
) : (
<div tabIndex={-1} role="region" ref={containerRef} {...rest} />
)}
</FocusableContainerRefContext>
</FocusActionsContext>
</FocusTargetRefContext>
</FocusableContext>
)
}
const assertChildren = (children: React.ReactNode) => {
if (!children) {
throw new Error("[Focusable] `asChild` must have a child")
}
const child = React.Children.count(children)
if (child !== 1) {
throw new Error("[Focusable] `asChild` must have exactly one child")
}
}

View File

@ -0,0 +1,24 @@
import { jotaiStore } from "@follow/utils/jotai"
import { atom } from "jotai"
import type { PropsWithChildren } from "react"
import { useEffect, useMemo } from "react"
import { GlobalFocusableContext } from "./context"
export const GlobalFocusableProvider = ({ children }: PropsWithChildren) => {
const ctxValue = useMemo(() => {
return atom(new Set<string>())
}, [])
if (import.meta.env.DEV) {
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
return jotaiStore.sub(ctxValue, () => {
const v = jotaiStore.get(ctxValue)
console.info("[GlobalFocusableProvider] scope changed to:", v)
})
}, [ctxValue])
}
return <GlobalFocusableContext value={ctxValue}>{children}</GlobalFocusableContext>
}

View File

@ -1,3 +1,4 @@
import type { PrimitiveAtom } from "jotai"
import { createContext } from "react"
export const FocusableContext = createContext(false)
@ -8,3 +9,5 @@ export const FocusableContainerRefContext = createContext<React.RefObject<HTMLDi
export const FocusActionsContext = createContext<{
highlightBoundary: () => void
}>(null!)
export const GlobalFocusableContext = createContext<PrimitiveAtom<Set<string>>>(null!)

View File

@ -1,10 +1,14 @@
import { use } from "react"
import { jotaiStore } from "@follow/utils/jotai"
import { useAtomValue, useSetAtom } from "jotai"
import { selectAtom } from "jotai/utils"
import { use, useCallback, useMemo } from "react"
import {
FocusableContainerRefContext,
FocusableContext,
FocusActionsContext,
FocusTargetRefContext,
GlobalFocusableContext,
} from "./context"
export const useFocusable = () => {
@ -22,3 +26,78 @@ export const useFocusActions = () => {
export const useFocusableContainerRef = () => {
return use(FocusableContainerRefContext)
}
export const useGlobalFocusableScope = () => {
return useAtomValue(use(GlobalFocusableContext))
}
export const useGlobalFocusableHasScope = (scope: string) => {
return useGlobalFocusableScopeSelector((v) => v.has(scope))
}
export const useGlobalFocusableScopeSelector = (selector: (scope: Set<string>) => boolean) => {
const ctx = use(GlobalFocusableContext)
return useAtomValue(useMemo(() => selectAtom(ctx, selector), [ctx, selector]))
}
export const useSetGlobalFocusableScope = () => {
const ctx = use(GlobalFocusableContext)
const setter = useSetAtom(ctx)
return useCallback(
(scope: string, mode: "append" | "switch" | "remove") => {
const snapshot = jotaiStore.get(ctx)
setter((v) => {
if (mode === "append") {
if (v.has(scope)) {
return v
}
const newSet = new Set(v)
newSet.add(scope)
return newSet
} else if (mode === "switch") {
const newSet = new Set(v)
if (newSet.has(scope)) {
newSet.delete(scope)
} else {
newSet.add(scope)
}
return newSet
} else {
if (!v.has(scope)) return v
const newSet = new Set(v)
newSet.delete(scope)
return newSet
}
})
return {
original: snapshot,
new: jotaiStore.get(ctx),
}
},
[ctx, setter],
)
}
export const useReplaceGlobalFocusableScope = () => {
const ctx = use(GlobalFocusableContext)
const setter = useSetAtom(ctx)
return useCallback(
(...scopes: string[]) => {
const snapshot = jotaiStore.get(ctx)
setter(() => {
const newSet = new Set<string>()
for (const scope of scopes) {
newSet.add(scope)
}
return newSet
})
return {
rollback: () => {
setter(snapshot)
},
}
},
[ctx, setter],
)
}

View File

@ -51,24 +51,31 @@ export const KbdCombined: FC<{
children: string
className?: string
joint?: boolean
}> = ({ children, joint, className }) => {
kbdProps?: Partial<React.ComponentProps<typeof Kbd>>
}> = ({ children, joint, className, kbdProps }) => {
const keys = children.split(",")
return (
<div className="flex items-center gap-1">
{keys.map((k, i) => (
<Fragment key={k}>
{joint ? (
<Kbd className={className}>{k}</Kbd>
<Kbd className={className} {...kbdProps}>
{k}
</Kbd>
) : (
<div className="flex items-center gap-1">
{k.split("+").map((key) => (
<Kbd key={key} className={className}>
<Kbd key={key} className={className} {...kbdProps}>
{key}
</Kbd>
))}
</div>
)}
{i !== keys.length - 1 && " / "}
{i !== keys.length - 1 && (
<span>
<i className="i-mgc-line-cute-re text-text-secondary size-[0.75em] shrink-0 origin-center translate-y-[0.15em] rotate-[-25deg]" />
</span>
)}
</Fragment>
))}
</div>
@ -330,9 +337,9 @@ export const Kbd: FC<{ children: string; className?: string; wrapButton?: boolea
const Kbd = (
<kbd
className={cn(
"kbd text-text box-border h-5 space-x-1 font-sans text-[0.7rem]",
"kbd text-text box-border h-5 space-x-1 font-sans text-[0.7rem] tabular-nums transition-[border] duration-200",
isKeyPressed ? "" : "border-b-2",
wrapButton && (isKeyPressed ? "" : "border-b-2 hover:border-b"),
className,
)}
>
@ -383,7 +390,7 @@ export const Kbd: FC<{ children: string; className?: string; wrapButton?: boolea
</kbd>
)
return wrapButton ? (
<button type="button" onClick={handleClick}>
<button type="button" className="contents" onClick={handleClick}>
{Kbd}
</button>
) : (