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 ? ( - ) : (