From 62fc0a0cb11aa96943a99ddec16ea16138ee006a Mon Sep 17 00:00:00 2001 From: Innei Date: Fri, 23 May 2025 21:26:55 +0800 Subject: [PATCH] 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 * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * 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 * 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 * refactor: scope Signed-off-by: Innei * 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 * 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 * 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 * 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 * 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 * refactor Signed-off-by: Innei * 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 * 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 * 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 * 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 --------- Signed-off-by: Innei --- apps/desktop/changelog/next.md | 2 + .../src/components/common/Focusable.tsx | 27 ++ .../ui/background/WindowUnderBlur.tsx | 33 +- .../ui/dropdown-menu/dropdown-menu.tsx | 28 +- .../src/components/ui/media/VideoPlayer.tsx | 10 +- .../src/components/ui/modal/stacked/modal.tsx | 14 +- .../layer/renderer/src/constants/copy.ts | 4 +- .../layer/renderer/src/constants/hotkeys.ts | 9 + .../layer/renderer/src/constants/shortcuts.ts | 178 ------- .../src/hooks/biz/useSubscriptionActions.tsx | 2 - .../layer/renderer/src/hooks/common/index.ts | 1 - .../src/hooks/common/useSwitchHotkeyScope.ts | 61 --- .../app-layout/entry-column/desktop.tsx | 6 +- .../app-layout/entry-content/desktop.tsx | 15 +- .../app-layout/feed-column/desktop.tsx | 14 +- .../modules/app-layout/feed-column/mobile.tsx | 4 +- .../app-layout/subview/index.electron.tsx | 17 +- .../modules/command/commands/entry-render.tsx | 27 +- .../src/modules/command/commands/entry.tsx | 33 +- .../src/modules/command/commands/global.tsx | 55 ++- .../src/modules/command/commands/id.ts | 4 +- .../modules/command/commands/integration.tsx | 10 +- .../src/modules/command/commands/layout.tsx | 36 +- .../src/modules/command/commands/list.tsx | 15 +- .../src/modules/command/commands/settings.tsx | 11 +- .../modules/command/commands/subscription.tsx | 88 +++- .../src/modules/command/commands/timeline.tsx | 30 +- .../command/hooks/use-command-binding.ts | 223 +++++++-- .../src/modules/command/registry/command.ts | 2 +- .../command/shortcuts/SettingShortcuts.tsx | 448 +++++++++++++++++- .../renderer/src/modules/command/types.ts | 9 +- .../EntryColumnShortcutHandler.tsx | 16 +- .../components/mark-all-button.tsx | 7 +- .../src/modules/entry-column/index.tsx | 5 +- .../entry-column/layouts/EntryItemWrapper.tsx | 11 +- .../entry-column/layouts/TimelineTabs.tsx | 2 +- .../modules/entry-content/index.electron.tsx | 33 +- .../renderer/src/modules/modal/shortcuts.tsx | 41 +- .../layer/renderer/src/modules/panel/cmdn.tsx | 28 +- .../src/modules/player/corner-player.tsx | 23 +- .../renderer/src/modules/settings/control.tsx | 4 +- .../src/modules/settings/modal/layout.tsx | 10 +- .../CategoryRemoveDialogContent.tsx | 0 .../CategoryRenameContent.tsx | 0 .../FeedCategory.tsx | 0 .../FeedItem.tsx | 0 .../SubscriptionList.electron.tsx} | 26 +- .../SubscriptionList.mobile.tsx} | 8 +- .../SubscriptionList.shared.tsx} | 0 .../subscription-column/SubscriptionList.tsx | 12 + .../TimelineColumnHeader.tsx | 0 .../TimelineList.tsx | 4 +- .../TimelineSwitchButton.tsx | 0 .../UnreadNumber.tsx | 0 .../atom.ts | 0 .../context.ts | 0 .../hook.ts | 0 .../index.tsx | 20 +- .../sort-by/SortByAlphabeticalList.tsx | 0 .../sort-by/SortByUnreadList.tsx | 0 .../sort-by/index.tsx | 0 .../sort-by/types.tsx | 0 .../styles.ts | 0 .../src/modules/timeline-column/FeedList.tsx | 12 - .../pages/settings/(settings)/shortcuts.tsx | 7 +- .../src/providers/context-menu-provider.tsx | 21 +- .../src/providers/global-hotkeys-provider.tsx | 29 -- .../src/providers/hotkey-provider.tsx | 38 +- .../renderer/src/providers/root-providers.tsx | 49 +- apps/desktop/plugins/vite/ast.ts | 9 +- locales/app/en.json | 24 +- locales/shortcuts/en.json | 110 +++-- locales/shortcuts/ja.json | 35 +- locales/shortcuts/zh-CN.json | 40 +- locales/shortcuts/zh-TW.json | 35 +- .../src/common/Focusable/Focusable.tsx | 73 ++- .../Focusable/GlobalFocusableProvider.tsx | 24 + .../src/common/Focusable/context.ts | 3 + .../components/src/common/Focusable/hooks.ts | 81 +++- .../internal/components/src/ui/kbd/Kbd.tsx | 21 +- 80 files changed, 1414 insertions(+), 863 deletions(-) create mode 100644 apps/desktop/layer/renderer/src/components/common/Focusable.tsx delete mode 100644 apps/desktop/layer/renderer/src/constants/shortcuts.ts delete mode 100644 apps/desktop/layer/renderer/src/hooks/common/useSwitchHotkeyScope.ts rename apps/desktop/layer/renderer/src/modules/{timeline-column => subscription-column}/CategoryRemoveDialogContent.tsx (100%) rename apps/desktop/layer/renderer/src/modules/{timeline-column => subscription-column}/CategoryRenameContent.tsx (100%) rename apps/desktop/layer/renderer/src/modules/{timeline-column => subscription-column}/FeedCategory.tsx (100%) rename apps/desktop/layer/renderer/src/modules/{timeline-column => subscription-column}/FeedItem.tsx (100%) rename apps/desktop/layer/renderer/src/modules/{timeline-column/FeedList.electron.tsx => subscription-column/SubscriptionList.electron.tsx} (95%) rename apps/desktop/layer/renderer/src/modules/{timeline-column/FeedList.mobile.tsx => subscription-column/SubscriptionList.mobile.tsx} (93%) rename apps/desktop/layer/renderer/src/modules/{timeline-column/FeedList.shared.tsx => subscription-column/SubscriptionList.shared.tsx} (100%) create mode 100644 apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionList.tsx rename apps/desktop/layer/renderer/src/modules/{timeline-column => subscription-column}/TimelineColumnHeader.tsx (100%) rename apps/desktop/layer/renderer/src/modules/{timeline-column => subscription-column}/TimelineList.tsx (71%) rename apps/desktop/layer/renderer/src/modules/{timeline-column => subscription-column}/TimelineSwitchButton.tsx (100%) rename apps/desktop/layer/renderer/src/modules/{timeline-column => subscription-column}/UnreadNumber.tsx (100%) rename apps/desktop/layer/renderer/src/modules/{timeline-column => subscription-column}/atom.ts (100%) rename apps/desktop/layer/renderer/src/modules/{timeline-column => subscription-column}/context.ts (100%) rename apps/desktop/layer/renderer/src/modules/{timeline-column => subscription-column}/hook.ts (100%) rename apps/desktop/layer/renderer/src/modules/{timeline-column => subscription-column}/index.tsx (95%) rename apps/desktop/layer/renderer/src/modules/{timeline-column => subscription-column}/sort-by/SortByAlphabeticalList.tsx (100%) rename apps/desktop/layer/renderer/src/modules/{timeline-column => subscription-column}/sort-by/SortByUnreadList.tsx (100%) rename apps/desktop/layer/renderer/src/modules/{timeline-column => subscription-column}/sort-by/index.tsx (100%) rename apps/desktop/layer/renderer/src/modules/{timeline-column => subscription-column}/sort-by/types.tsx (100%) rename apps/desktop/layer/renderer/src/modules/{timeline-column => subscription-column}/styles.ts (100%) delete mode 100644 apps/desktop/layer/renderer/src/modules/timeline-column/FeedList.tsx create mode 100644 packages/internal/components/src/common/Focusable/GlobalFocusableProvider.tsx diff --git a/apps/desktop/changelog/next.md b/apps/desktop/changelog/next.md index eb0db6436..30e1ec95e 100644 --- a/apps/desktop/changelog/next.md +++ b/apps/desktop/changelog/next.md @@ -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 diff --git a/apps/desktop/layer/renderer/src/components/common/Focusable.tsx b/apps/desktop/layer/renderer/src/components/common/Focusable.tsx new file mode 100644 index 000000000..78adf3ef1 --- /dev/null +++ b/apps/desktop/layer/renderer/src/components/common/Focusable.tsx @@ -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 { + scope: HotkeyScope +} +export const Focusable = FocusableComponent as Component< + Prettify & + React.DetailedHTMLProps, HTMLDivElement> +> + +export const FocusablePresets = { + isNotFloatingLayerScope: (v: Set) => !FloatingLayerScope.some((s) => v.has(s)), + isSubscriptionList: (scope: Set) => { + return ( + scope.size === 0 || + scope.has(HotkeyScope.SubscriptionList) || + (scope.has(HotkeyScope.Home) && scope.size === 1) + ) + }, + + isSubscriptionOrTimeline: (v: Set) => { + return v.has(HotkeyScope.SubscriptionList) || v.has(HotkeyScope.Timeline) || v.size === 0 + }, +} diff --git a/apps/desktop/layer/renderer/src/components/ui/background/WindowUnderBlur.tsx b/apps/desktop/layer/renderer/src/components/ui/background/WindowUnderBlur.tsx index 6c4d03a08..e369fd66d 100644 --- a/apps/desktop/layer/renderer/src/components/ui/background/WindowUnderBlur.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/background/WindowUnderBlur.tsx @@ -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, HTMLDivElement> -> -const MacOSVibrancy: Props = ({ children, ...rest }) => {children} +type Props = { + as?: T + ref?: React.Ref +} & ComponentPropsWithoutRef -const Noop: Props = ({ children, className, ...rest }) => ( - - {children} - -) +const MacOSVibrancy = ({ children, as, ...rest }: Props) => { + const Component = as || "div" + return {children} +} -export const WindowUnderBlur: Props = SYSTEM_CAN_UNDER_BLUR_WINDOW - ? (props) => { +const Noop = ({ children, className, as, ...rest }: Props) => { + const Component = as || "div" + return ( + + {children} + + ) +} + +export const WindowUnderBlur = SYSTEM_CAN_UNDER_BLUR_WINDOW + ? (props: Props) => { const opaqueSidebar = useUISettingKey("opaqueSidebar") if (opaqueSidebar) { return diff --git a/apps/desktop/layer/renderer/src/components/ui/dropdown-menu/dropdown-menu.tsx b/apps/desktop/layer/renderer/src/components/ui/dropdown-menu/dropdown-menu.tsx index 025549318..5eeb42619 100644 --- a/apps/desktop/layer/renderer/src/components/ui/dropdown-menu/dropdown-menu.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/dropdown-menu/dropdown-menu.tsx @@ -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 ( { - setOpen(open) props.onOpenChange?.(open) }, [props.onOpenChange], @@ -95,16 +91,18 @@ const DropdownMenuContent = ({ }) => { return ( - + + + ) } diff --git a/apps/desktop/layer/renderer/src/components/ui/media/VideoPlayer.tsx b/apps/desktop/layer/renderer/src/components/ui/media/VideoPlayer.tsx index afa8d7b64..ee1214f12 100644 --- a/apps/desktop/layer/renderer/src/components/ui/media/VideoPlayer.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/media/VideoPlayer.tsx @@ -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 ( - + {element}
-
{finalChildren}
-
+
@@ -304,7 +303,8 @@ export const ModalInternal = memo(function Modal({ onPointerDownOutside={preventDefault} onOpenAutoFocus={openAutoFocus} > -
-
+
diff --git a/apps/desktop/layer/renderer/src/constants/copy.ts b/apps/desktop/layer/renderer/src/constants/copy.ts index 35df8c4f4..d229dbc16 100644 --- a/apps/desktop/layer/renderer/src/constants/copy.ts +++ b/apps/desktop/layer/renderer/src/constants/copy.ts @@ -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, diff --git a/apps/desktop/layer/renderer/src/constants/hotkeys.ts b/apps/desktop/layer/renderer/src/constants/hotkeys.ts index 4365ba12f..b32cacc26 100644 --- a/apps/desktop/layer/renderer/src/constants/hotkeys.ts +++ b/apps/desktop/layer/renderer/src/constants/hotkeys.ts @@ -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 diff --git a/apps/desktop/layer/renderer/src/constants/shortcuts.ts b/apps/desktop/layer/renderer/src/constants/shortcuts.ts deleted file mode 100644 index d80f1d91f..000000000 --- a/apps/desktop/layer/renderer/src/constants/shortcuts.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { transformShortcut } from "@follow/utils/utils" - -import { COPY_MAP } from "~/constants" - -type Shortcuts = Record< - string, - Record -> - -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(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", -} diff --git a/apps/desktop/layer/renderer/src/hooks/biz/useSubscriptionActions.tsx b/apps/desktop/layer/renderer/src/hooks/biz/useSubscriptionActions.tsx index fde634578..33bc8139e 100644 --- a/apps/desktop/layer/renderer/src/hooks/biz/useSubscriptionActions.tsx +++ b/apps/desktop/layer/renderer/src/hooks/biz/useSubscriptionActions.tsx @@ -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 ( diff --git a/apps/desktop/layer/renderer/src/hooks/common/index.ts b/apps/desktop/layer/renderer/src/hooks/common/index.ts index 87f4e3de6..f6ce2823c 100644 --- a/apps/desktop/layer/renderer/src/hooks/common/index.ts +++ b/apps/desktop/layer/renderer/src/hooks/common/index.ts @@ -2,5 +2,4 @@ export * from "./useBizQuery" export * from "./useContextMenu" export * from "./useI18n" export * from "./usePreventOverscrollBounce" -export * from "./useSwitchHotkeyScope" export * from "./useSyncTheme" diff --git a/apps/desktop/layer/renderer/src/hooks/common/useSwitchHotkeyScope.ts b/apps/desktop/layer/renderer/src/hooks/common/useSwitchHotkeyScope.ts deleted file mode 100644 index ea9e33a3f..000000000 --- a/apps/desktop/layer/renderer/src/hooks/common/useSwitchHotkeyScope.ts +++ /dev/null @@ -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]) -} diff --git a/apps/desktop/layer/renderer/src/modules/app-layout/entry-column/desktop.tsx b/apps/desktop/layer/renderer/src/modules/app-layout/entry-column/desktop.tsx index 2e6d11b6c..73ec308f9 100644 --- a/apps/desktop/layer/renderer/src/modules/app-layout/entry-column/desktop.tsx +++ b/apps/desktop/layer/renderer/src/modules/app-layout/entry-column/desktop.tsx @@ -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 ( -
+
)} -
+
) } diff --git a/apps/desktop/layer/renderer/src/modules/app-layout/entry-content/desktop.tsx b/apps/desktop/layer/renderer/src/modules/app-layout/entry-content/desktop.tsx index 5f5e2b9c8..30b7fc956 100644 --- a/apps/desktop/layer/renderer/src/modules/app-layout/entry-content/desktop.tsx +++ b/apps/desktop/layer/renderer/src/modules/app-layout/entry-content/desktop.tsx @@ -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 } diff --git a/apps/desktop/layer/renderer/src/modules/app-layout/feed-column/desktop.tsx b/apps/desktop/layer/renderer/src/modules/app-layout/feed-column/desktop.tsx index 61920945a..40928d971 100644 --- a/apps/desktop/layer/renderer/src/modules/app-layout/feed-column/desktop.tsx +++ b/apps/desktop/layer/renderer/src/modules/app-layout/feed-column/desktop.tsx @@ -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) diff --git a/apps/desktop/layer/renderer/src/modules/app-layout/feed-column/mobile.tsx b/apps/desktop/layer/renderer/src/modules/app-layout/feed-column/mobile.tsx index 78cd63715..ec66fb3ba 100644 --- a/apps/desktop/layer/renderer/src/modules/app-layout/feed-column/mobile.tsx +++ b/apps/desktop/layer/renderer/src/modules/app-layout/feed-column/mobile.tsx @@ -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 }) { {views.map((item, index) => (
- + + + ) +} +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 (
diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/entry-render.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/entry-render.tsx index 29682f14f..6249aca05 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/entry-render.tsx +++ b/apps/desktop/layer/renderer/src/modules/command/commands/entry-render.tsx @@ -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"), + }, }, ]) } diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/entry.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/entry.tsx index f0eb7d157..9e3a8ef72 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/entry.tsx +++ b/apps/desktop/layer/renderer/src/modules/command/commands/entry.tsx @@ -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: , - // 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) => ( { 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: , + 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: , + 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: , + 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: , + 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: , 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: , + 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: , + 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) => ( ), @@ -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: , + 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: , 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: , + 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: , + category, run: () => { if (role === UserRole.Trial) { presentActivationModal() diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/global.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/global.tsx index 60fd1a3d2..1e3c290f8 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/global.tsx +++ b/apps/desktop/layer/renderer/src/modules/command/commands/global.tsx @@ -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 diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/id.ts b/apps/desktop/layer/renderer/src/modules/command/commands/id.ts index 433366b2c..91582e450 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/id.ts +++ b/apps/desktop/layer/renderer/src/modules/command/commands/id.ts @@ -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", diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/integration.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/integration.tsx index 0a47ef042..b70135be4 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/integration.tsx +++ b/apps/desktop/layer/renderer/src/modules/command/commands/integration.tsx @@ -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: , + 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: , + 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: , + 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: , + 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: , + 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: , + 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: , + category, run: async ({ entryId }) => { const entry = useEntryStore.getState().flatMapEntries[entryId] if (!entry) { diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/layout.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/layout.tsx index ed8b1bdc7..1c4dd0b5c 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/layout.tsx +++ b/apps/desktop/layer/renderer/src/modules/command/commands/layout.tsx @@ -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 }> diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/list.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/list.tsx index f1f417243..39348f716 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/list.tsx +++ b/apps/desktop/layer/renderer/src/modules/command/commands/list.tsx @@ -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) diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/settings.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/settings.tsx index 6231b0992..e1d8aa7f7 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/settings.tsx +++ b/apps/desktop/layer/renderer/src/modules/command/commands/settings.tsx @@ -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: , run() { showModal() @@ -38,7 +39,7 @@ const useRegisterThemeCommands = () => { { id: COMMAND_ID.settings.changeThemeToAuto, label: `To ${t("appearance.theme.system")}`, - category: "follow:settings", + category, icon: , 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: , 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: , when: theme !== "light", run() { diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/subscription.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/subscription.tsx index f8088d19d..79985ec7f 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/subscription.tsx +++ b/apps/desktop/layer/renderer/src/modules/command/commands/subscription.tsx @@ -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) }, diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/timeline.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/timeline.tsx index eb157d318..6d2381d6a 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/timeline.tsx +++ b/apps/desktop/layer/renderer/src/modules/command/commands/timeline.tsx @@ -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) }, diff --git a/apps/desktop/layer/renderer/src/modules/command/hooks/use-command-binding.ts b/apps/desktop/layer/renderer/src/modules/command/hooks/use-command-binding.ts index a63b847a7..833cde889 100644 --- a/apps/desktop/layer/renderer/src/modules/command/hooks/use-command-binding.ts +++ b/apps/desktop/layer/renderer/src/modules/command/hooks/use-command-binding.ts @@ -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> +>(getStorageNS("command-shortcuts"), {}, undefined, { + getOnInit: true, +}) + +export const useCommandShortcutItems = () => { + const commandShortcuts = useCommandShortcuts() + + return useMemo(() => { + const groupedCommands = {} as Record + 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 ? U : never +export type AllowCustomizeCommandId = ExtractSetType 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 = ({ diff --git a/apps/desktop/layer/renderer/src/modules/command/registry/command.ts b/apps/desktop/layer/renderer/src/modules/command/registry/command.ts index ffadc8d08..dbbe4a512 100644 --- a/apps/desktop/layer/renderer/src/modules/command/registry/command.ts +++ b/apps/desktop/layer/renderer/src/modules/command/registry/command.ts @@ -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 diff --git a/apps/desktop/layer/renderer/src/modules/command/shortcuts/SettingShortcuts.tsx b/apps/desktop/layer/renderer/src/modules/command/shortcuts/SettingShortcuts.tsx index 0a4d74674..970ba3f8a 100644 --- a/apps/desktop/layer/renderer/src/modules/command/shortcuts/SettingShortcuts.tsx +++ b/apps/desktop/layer/renderer/src/modules/command/shortcuts/SettingShortcuts.tsx @@ -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 (
- {Object.keys(shortcuts).map((type) => ( + {Object.entries(commandShortcuts).map(([type, commands]) => (
- {t(shortcutsType[type])} + {t(type as CommandCategory)}
- {Object.keys(shortcuts[type]).map((action, index) => ( -
-
{t(shortcuts[type][action].name)}
-
- - {`${shortcuts[type][action].key}${shortcuts[type][action].extra ? `, ${shortcuts[type][action].extra}` : ""}`} - -
-
+ {commands.map((commandId) => ( + ))}
@@ -36,3 +43,406 @@ export const SettingShortcuts = () => {
) } + +export const ShortcutSetting = () => { + const { t } = useTranslation("shortcuts") + const commandShortcuts = useCommandShortcutItems() + + return ( +
+

{t("settings.shortcuts.description")}

+ {Object.entries(commandShortcuts).map(([type, commands]) => ( +
+
+ {t(type as CommandCategory)} +
+
+ {commands.map((commandId) => ( + + ))} +
+
+ ))} +
+ ) +} + +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 ( +
+
+
+ {command.label.title} + {isUserCustomize && ( + + +
+
+ Custom +
+ + This shortcut is customized by you + + )} +
+ {!!command.label.description && ( + {command.label.description} + )} +
+ { + setCustomCommandShortcut(commandId as AllowCustomizeCommandId, shortcut) + setIsEditing(false) + }} + /> +
+ ) +}) + +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 ( + + + + + {hasConflict && ( + + +
+
Shortcut Conflict
+
+ This shortcut conflicts with command: +

{conflictCommand?.label.title}

+
+
+
+
+ )} +
+ ) + }, +) + +const KeyRecorder: FC<{ + onChange: (keys: string[] | null) => void + onBlur: () => void +}> = ({ onChange, onBlur }) => { + const { currentKeys } = useShortcutRecorder() + const setGlobalScope = useReplaceGlobalFocusableScope() + + const ref = useRef(null) + useEffect(() => { + const { rollback } = setGlobalScope(HotkeyScope.Recording) + if (ref.current) { + ref.current.focus() + } + return () => { + rollback() + } + }, [setGlobalScope]) + useOnClickOutside(ref as RefObject, () => { + if (currentKeys.length > 0) { + onChange(currentKeys) + } + onBlur() + }) + return ( +
+ {currentKeys.length > 0 ? ( +
+ + {currentKeys.join("+")} + +
+ ) : ( + Press keys to record + )} + + + + + {currentKeys.length > 0 ? "Undo" : "Reset"} + +
+ ) +} + +function FamiconsArrowUndoCircle(props: SVGProps) { + return ( + + {/* Icon from Famicons by Family - https://github.com/familyjs/famicons/blob/main/LICENSE */} + + + ) +} + +const CommandShortcutItem = memo(({ commandId }: { commandId: FollowCommandId }) => { + const command = useCommand(commandId) + const commandShortcuts = useCommandShortcuts() + + if (!command) return null + return ( +
+
{command.label.title}
+
+ {commandShortcuts[commandId]} +
+
+ ) +}) + +/////// + +const MODIFIER_KEYS_MAP = { + Control: "Control", + Alt: "Alt", + Shift: "Shift", + Meta: "Meta", +} as const + +const MODIFIER_KEYS_SET = new Set(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([]) + + 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() + + // 添加修饰键 + 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 } +} diff --git a/apps/desktop/layer/renderer/src/modules/command/types.ts b/apps/desktop/layer/renderer/src/modules/command/types.ts index edb0121f5..2e21a8576 100644 --- a/apps/desktop/layer/renderer/src/modules/command/types.ts +++ b/apps/desktop/layer/renderer/src/modules/command/types.ts @@ -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 `category.${string}` ? T : never +export type CommandCategory = ExtractCategory[0]> export interface KeybindingOptions { binding: string diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx index 1eea878f6..95f0c0412 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx @@ -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 }) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/components/mark-all-button.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/components/mark-all-button.tsx index 2df70f61d..579d654e1 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/components/mark-all-button.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/components/mark-all-button.tsx @@ -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, }) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx index 744a0acfb..8ea36fb66 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx @@ -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 ( { 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], ) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/layouts/TimelineTabs.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/layouts/TimelineTabs.tsx index 8d2f74141..11d1cbfa8 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/layouts/TimelineTabs.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/layouts/TimelineTabs.tsx @@ -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 = () => { diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx index 67070ae17..1b5e013a4 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/index.electron.tsx @@ -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 = ({ const isInPeekModal = useInPeekModal() - const [isUserInteraction, setIsUserInteraction] = useState(false) const isZenMode = useIsZenMode() const [panelPortalElement, setPanelPortalElement] = useState(null) @@ -130,16 +127,11 @@ export const EntryContent: Component = ({
setIsUserInteraction(true)} > - + @@ -178,8 +170,6 @@ export const EntryContent: Component = ({ )}
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 - isUserInteraction: boolean - setIsUserInteraction: (isUserInteraction: boolean) => void + scrollAnimationRef: React.RefObject | 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 ( diff --git a/apps/desktop/layer/renderer/src/modules/modal/shortcuts.tsx b/apps/desktop/layer/renderer/src/modules/modal/shortcuts.tsx index 4bcdafcb5..5774aa6d0 100644 --- a/apps/desktop/layer/renderer/src/modules/modal/shortcuts.tsx +++ b/apps/desktop/layer/renderer/src/modules/modal/shortcuts.tsx @@ -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 ( { -
- {Object.keys(shortcuts).map((type) => ( -
-
- {t(shortcutsType[type])} -
-
- {Object.keys(shortcuts[type]).map((action, index) => ( -
-
{t(shortcuts[type][action].name)}
-
- - {`${shortcuts[type][action].key}${shortcuts[type][action].extra ? `, ${shortcuts[type][action].extra}` : ""}`} - -
-
- ))} -
-
- ))} +
+
diff --git a/apps/desktop/layer/renderer/src/modules/panel/cmdn.tsx b/apps/desktop/layer/renderer/src/modules/panel/cmdn.tsx index 6b6de0944..acc4931b7 100644 --- a/apps/desktop/layer/renderer/src/modules/panel/cmdn.tsx +++ b/apps/desktop/layer/renderer/src/modules/panel/cmdn.tsx @@ -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 } diff --git a/apps/desktop/layer/renderer/src/modules/player/corner-player.tsx b/apps/desktop/layer/renderer/src/modules/player/corner-player.tsx index 5d0c9d3b9..fbafb5b53 100644 --- a/apps/desktop/layer/renderer/src/modules/player/corner-player.tsx +++ b/apps/desktop/layer/renderer/src/modules/player/corner-player.tsx @@ -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, diff --git a/apps/desktop/layer/renderer/src/modules/settings/control.tsx b/apps/desktop/layer/renderer/src/modules/settings/control.tsx index 330b32337..f73836f99 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/control.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/control.tsx @@ -105,7 +105,9 @@ export const SettingTabbedSegment: Component<{ } export const SettingDescription: Component = ({ children, className }) => ( - {children} + + {children} + ) export const SettingActionItem = ({ diff --git a/apps/desktop/layer/renderer/src/modules/settings/modal/layout.tsx b/apps/desktop/layer/renderer/src/modules/settings/modal/layout.tsx index faca6fe28..4f4332cd1 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/modal/layout.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/modal/layout.tsx @@ -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" > diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/CategoryRemoveDialogContent.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/CategoryRemoveDialogContent.tsx similarity index 100% rename from apps/desktop/layer/renderer/src/modules/timeline-column/CategoryRemoveDialogContent.tsx rename to apps/desktop/layer/renderer/src/modules/subscription-column/CategoryRemoveDialogContent.tsx diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/CategoryRenameContent.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/CategoryRenameContent.tsx similarity index 100% rename from apps/desktop/layer/renderer/src/modules/timeline-column/CategoryRenameContent.tsx rename to apps/desktop/layer/renderer/src/modules/subscription-column/CategoryRenameContent.tsx diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/FeedCategory.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/FeedCategory.tsx similarity index 100% rename from apps/desktop/layer/renderer/src/modules/timeline-column/FeedCategory.tsx rename to apps/desktop/layer/renderer/src/modules/subscription-column/FeedCategory.tsx diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/FeedItem.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/FeedItem.tsx similarity index 100% rename from apps/desktop/layer/renderer/src/modules/timeline-column/FeedItem.tsx rename to apps/desktop/layer/renderer/src/modules/subscription-column/FeedItem.tsx diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/FeedList.electron.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionList.electron.tsx similarity index 95% rename from apps/desktop/layer/renderer/src/modules/timeline-column/FeedList.electron.tsx rename to apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionList.electron.tsx index fd3c6a4e9..210d3fe66 100644 --- a/apps/desktop/layer/renderer/src/modules/timeline-column/FeedList.electron.tsx +++ b/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionList.electron.tsx @@ -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) => { /> { 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(() => { diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/FeedList.mobile.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionList.mobile.tsx similarity index 93% rename from apps/desktop/layer/renderer/src/modules/timeline-column/FeedList.mobile.tsx rename to apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionList.mobile.tsx index 6c6b4248e..a6085fc3c 100644 --- a/apps/desktop/layer/renderer/src/modules/timeline-column/FeedList.mobile.tsx +++ b/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionList.mobile.tsx @@ -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) diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/FeedList.shared.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionList.shared.tsx similarity index 100% rename from apps/desktop/layer/renderer/src/modules/timeline-column/FeedList.shared.tsx rename to apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionList.shared.tsx diff --git a/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionList.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionList.tsx new file mode 100644 index 000000000..d49977d86 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionList.tsx @@ -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 | ((node: HTMLDivElement | null) => void) + } +> diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/TimelineColumnHeader.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/TimelineColumnHeader.tsx similarity index 100% rename from apps/desktop/layer/renderer/src/modules/timeline-column/TimelineColumnHeader.tsx rename to apps/desktop/layer/renderer/src/modules/subscription-column/TimelineColumnHeader.tsx diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/TimelineList.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/TimelineList.tsx similarity index 71% rename from apps/desktop/layer/renderer/src/modules/timeline-column/TimelineList.tsx rename to apps/desktop/layer/renderer/src/modules/subscription-column/TimelineList.tsx index 1306947fe..13524d48d 100644 --- a/apps/desktop/layer/renderer/src/modules/timeline-column/TimelineList.tsx +++ b/apps/desktop/layer/renderer/src/modules/subscription-column/TimelineList.tsx @@ -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 + return } } diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/TimelineSwitchButton.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/TimelineSwitchButton.tsx similarity index 100% rename from apps/desktop/layer/renderer/src/modules/timeline-column/TimelineSwitchButton.tsx rename to apps/desktop/layer/renderer/src/modules/subscription-column/TimelineSwitchButton.tsx diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/UnreadNumber.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/UnreadNumber.tsx similarity index 100% rename from apps/desktop/layer/renderer/src/modules/timeline-column/UnreadNumber.tsx rename to apps/desktop/layer/renderer/src/modules/subscription-column/UnreadNumber.tsx diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/atom.ts b/apps/desktop/layer/renderer/src/modules/subscription-column/atom.ts similarity index 100% rename from apps/desktop/layer/renderer/src/modules/timeline-column/atom.ts rename to apps/desktop/layer/renderer/src/modules/subscription-column/atom.ts diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/context.ts b/apps/desktop/layer/renderer/src/modules/subscription-column/context.ts similarity index 100% rename from apps/desktop/layer/renderer/src/modules/timeline-column/context.ts rename to apps/desktop/layer/renderer/src/modules/subscription-column/context.ts diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/hook.ts b/apps/desktop/layer/renderer/src/modules/subscription-column/hook.ts similarity index 100% rename from apps/desktop/layer/renderer/src/modules/timeline-column/hook.ts rename to apps/desktop/layer/renderer/src/modules/subscription-column/hook.ts diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/index.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/index.tsx similarity index 95% rename from apps/desktop/layer/renderer/src/modules/timeline-column/index.tsx rename to apps/desktop/layer/renderer/src/modules/subscription-column/index.tsx index 79cd26853..e3683b23b 100644 --- a/apps/desktop/layer/renderer/src/modules/timeline-column/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/subscription-column/index.tsx @@ -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 ( 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 } diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/sort-by/SortByAlphabeticalList.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/sort-by/SortByAlphabeticalList.tsx similarity index 100% rename from apps/desktop/layer/renderer/src/modules/timeline-column/sort-by/SortByAlphabeticalList.tsx rename to apps/desktop/layer/renderer/src/modules/subscription-column/sort-by/SortByAlphabeticalList.tsx diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/sort-by/SortByUnreadList.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/sort-by/SortByUnreadList.tsx similarity index 100% rename from apps/desktop/layer/renderer/src/modules/timeline-column/sort-by/SortByUnreadList.tsx rename to apps/desktop/layer/renderer/src/modules/subscription-column/sort-by/SortByUnreadList.tsx diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/sort-by/index.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/sort-by/index.tsx similarity index 100% rename from apps/desktop/layer/renderer/src/modules/timeline-column/sort-by/index.tsx rename to apps/desktop/layer/renderer/src/modules/subscription-column/sort-by/index.tsx diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/sort-by/types.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/sort-by/types.tsx similarity index 100% rename from apps/desktop/layer/renderer/src/modules/timeline-column/sort-by/types.tsx rename to apps/desktop/layer/renderer/src/modules/subscription-column/sort-by/types.tsx diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/styles.ts b/apps/desktop/layer/renderer/src/modules/subscription-column/styles.ts similarity index 100% rename from apps/desktop/layer/renderer/src/modules/timeline-column/styles.ts rename to apps/desktop/layer/renderer/src/modules/subscription-column/styles.ts diff --git a/apps/desktop/layer/renderer/src/modules/timeline-column/FeedList.tsx b/apps/desktop/layer/renderer/src/modules/timeline-column/FeedList.tsx deleted file mode 100644 index 282ce158a..000000000 --- a/apps/desktop/layer/renderer/src/modules/timeline-column/FeedList.tsx +++ /dev/null @@ -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 | ((node: HTMLDivElement | null) => void) - } -> diff --git a/apps/desktop/layer/renderer/src/pages/settings/(settings)/shortcuts.tsx b/apps/desktop/layer/renderer/src/pages/settings/(settings)/shortcuts.tsx index 0addb99fa..c6aa52ed8 100644 --- a/apps/desktop/layer/renderer/src/pages/settings/(settings)/shortcuts.tsx +++ b/apps/desktop/layer/renderer/src/pages/settings/(settings)/shortcuts.tsx @@ -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 ( <> - + ) } diff --git a/apps/desktop/layer/renderer/src/providers/context-menu-provider.tsx b/apps/desktop/layer/renderer/src/providers/context-menu-provider.tsx index f2871da12..78500e6ae 100644 --- a/apps/desktop/layer/renderer/src/providers/context-menu-provider.tsx +++ b/apps/desktop/layer/renderer/src/providers/context-menu-provider.tsx @@ -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(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(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, }) diff --git a/apps/desktop/layer/renderer/src/providers/global-hotkeys-provider.tsx b/apps/desktop/layer/renderer/src/providers/global-hotkeys-provider.tsx index f33621277..08ed1b94b 100644 --- a/apps/desktop/layer/renderer/src/providers/global-hotkeys-provider.tsx +++ b/apps/desktop/layer/renderer/src/providers/global-hotkeys-provider.tsx @@ -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() diff --git a/apps/desktop/layer/renderer/src/providers/hotkey-provider.tsx b/apps/desktop/layer/renderer/src/providers/hotkey-provider.tsx index 29d5c8cd2..80fdd3ed7 100644 --- a/apps/desktop/layer/renderer/src/providers/hotkey-provider.tsx +++ b/apps/desktop/layer/renderer/src/providers/hotkey-provider.tsx @@ -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(null!) export const HotkeyProvider: Component = ({ children }) => { return ( - - - {children} - - + + {children} + ) } - -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 {children} -} - -export const useHotkeyScope = () => { - const hotkeyScope = use(HotkeyScopeContext) - if (!hotkeyScope) { - throw new Error("HotkeyScopeContext not found") - } - return hotkeyScope -} diff --git a/apps/desktop/layer/renderer/src/providers/root-providers.tsx b/apps/desktop/layer/renderer/src/providers/root-providers.tsx index 89f2beaa6..1eb9e2d5e 100644 --- a/apps/desktop/layer/renderer/src/providers/root-providers.tsx +++ b/apps/desktop/layer/renderer/src/providers/root-providers.tsx @@ -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 = ({ children }) => ( - - - - - + + + + + + - - + + - - - + + + - {import.meta.env.DEV && } + {import.meta.env.DEV && } - {children} + {children} - - - - - - - {!IN_ELECTRON && } - - - - + + + + + + + {!IN_ELECTRON && } + + + + + diff --git a/apps/desktop/plugins/vite/ast.ts b/apps/desktop/plugins/vite/ast.ts index 080488b4c..052c12bbe 100644 --- a/apps/desktop/plugins/vite/ast.ts +++ b/apps/desktop/plugins/vite/ast.ts @@ -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", + ]), ], }) diff --git a/locales/app/en.json b/locales/app/en.json index af28a1241..ed73585ec 100644 --- a/locales/app/en.json +++ b/locales/app/en.json @@ -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", diff --git a/locales/shortcuts/en.json b/locales/shortcuts/en.json index 42ce0672a..202074cd9 100644 --- a/locales/shortcuts/en.json +++ b/locales/shortcuts/en.json @@ -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." } diff --git a/locales/shortcuts/ja.json b/locales/shortcuts/ja.json index eeb718c38..0967ef424 100644 --- a/locales/shortcuts/ja.json +++ b/locales/shortcuts/ja.json @@ -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": "ショートカット" -} +{} diff --git a/locales/shortcuts/zh-CN.json b/locales/shortcuts/zh-CN.json index 54d17bc36..0967ef424 100644 --- a/locales/shortcuts/zh-CN.json +++ b/locales/shortcuts/zh-CN.json @@ -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": "快捷键" -} +{} diff --git a/locales/shortcuts/zh-TW.json b/locales/shortcuts/zh-TW.json index 0a475b0b6..0967ef424 100644 --- a/locales/shortcuts/zh-TW.json +++ b/locales/shortcuts/zh-TW.json @@ -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": "快捷鍵" -} +{} diff --git a/packages/internal/components/src/common/Focusable/Focusable.tsx b/packages/internal/components/src/common/Focusable/Focusable.tsx index 6b90e749f..0ef99df2d 100644 --- a/packages/internal/components/src/common/Focusable/Focusable.tsx +++ b/packages/internal/components/src/common/Focusable/Focusable.tsx @@ -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, HTMLDivElement> -> = ({ ref, ...props }) => { + React.DetailedHTMLProps, HTMLDivElement> & FocusableProps +> = ({ ref, scope, asChild, ...props }) => { const { onBlur, onFocus, ...rest } = props + const [isFocusWithIn, setIsFocusWithIn] = useState(false) const focusTargetRef = useRef(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 ( ({ highlightBoundary }), [highlightBoundary])}> -
+ {asChild ? ( + cloneElement( + rest.children as React.ReactElement>, + { + tabIndex: -1, + role: "region", + ...rest, + }, + ) + ) : ( +
+ )} ) } + +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") + } +} diff --git a/packages/internal/components/src/common/Focusable/GlobalFocusableProvider.tsx b/packages/internal/components/src/common/Focusable/GlobalFocusableProvider.tsx new file mode 100644 index 000000000..df1e5b923 --- /dev/null +++ b/packages/internal/components/src/common/Focusable/GlobalFocusableProvider.tsx @@ -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()) + }, []) + + 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 {children} +} diff --git a/packages/internal/components/src/common/Focusable/context.ts b/packages/internal/components/src/common/Focusable/context.ts index 735349658..ad2074029 100644 --- a/packages/internal/components/src/common/Focusable/context.ts +++ b/packages/internal/components/src/common/Focusable/context.ts @@ -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 void }>(null!) + +export const GlobalFocusableContext = createContext>>(null!) diff --git a/packages/internal/components/src/common/Focusable/hooks.ts b/packages/internal/components/src/common/Focusable/hooks.ts index df8799b8e..a5e2a0e26 100644 --- a/packages/internal/components/src/common/Focusable/hooks.ts +++ b/packages/internal/components/src/common/Focusable/hooks.ts @@ -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) => 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() + for (const scope of scopes) { + newSet.add(scope) + } + return newSet + }) + return { + rollback: () => { + setter(snapshot) + }, + } + }, + [ctx, setter], + ) +} diff --git a/packages/internal/components/src/ui/kbd/Kbd.tsx b/packages/internal/components/src/ui/kbd/Kbd.tsx index 0a2a8e528..bd84f9765 100644 --- a/packages/internal/components/src/ui/kbd/Kbd.tsx +++ b/packages/internal/components/src/ui/kbd/Kbd.tsx @@ -51,24 +51,31 @@ export const KbdCombined: FC<{ children: string className?: string joint?: boolean -}> = ({ children, joint, className }) => { + kbdProps?: Partial> +}> = ({ children, joint, className, kbdProps }) => { const keys = children.split(",") return (
{keys.map((k, i) => ( {joint ? ( - {k} + + {k} + ) : (
{k.split("+").map((key) => ( - + {key} ))}
)} - {i !== keys.length - 1 && " / "} + {i !== keys.length - 1 && ( + + + + )}
))}
@@ -330,9 +337,9 @@ export const Kbd: FC<{ children: string; className?: string; wrapButton?: boolea const Kbd = ( @@ -383,7 +390,7 @@ export const Kbd: FC<{ children: string; className?: string; wrapButton?: boolea ) return wrapButton ? ( - ) : (