feat: customize shortcuts (#3786)
* refactor: update command categories and labels for consistency - Replaced existing command categories with more descriptive names such as "Entry Render", "Global", "Integration", "Layout", "List", "Settings", "Timeline", and "Subscription". - Updated command labels to remove unnecessary prefixes, enhancing clarity and uniformity across the application. - Adjusted imports to include the new CommandCategory type for better type safety. This refactor improves the organization and readability of command definitions, contributing to a more intuitive user experience. Signed-off-by: Innei <tukon479@gmail.com> * update Signed-off-by: Innei <tukon479@gmail.com> * update Signed-off-by: Innei <tukon479@gmail.com> * update Signed-off-by: Innei <tukon479@gmail.com> * feat: enhance command shortcut customization and recording functionality - Added a new hotkey scope for recording commands. - Introduced a mechanism to override command shortcuts using local storage. - Implemented a key recorder for capturing user-defined shortcuts with validation rules. - Updated the UI for editing command shortcuts, allowing users to customize their key bindings more intuitively. This enhancement improves user experience by providing more flexibility in managing command shortcuts. Signed-off-by: Innei <tukon479@gmail.com> * feat: enhance command shortcut customization by adding new commands - Updated the default command shortcut for toggling the timeline column. - Expanded the set of customizable commands to include various subscription and entry actions, improving user flexibility in managing shortcuts. This update enhances the customization capabilities for command shortcuts, allowing users to tailor their experience more effectively. Signed-off-by: Innei <tukon479@gmail.com> * refactor: scope Signed-off-by: Innei <tukon479@gmail.com> * feat: add global commands for quick add and toggle corner play - Introduced new global commands: "Quick Add" and "Toggle Corner Play" with associated keyboard shortcuts. - Updated command IDs and types to accommodate the new functionalities. - Enhanced the event bus integration for better command handling. This update improves user interaction by providing quick access to essential features through keyboard shortcuts. Signed-off-by: Innei <tukon479@gmail.com> * refactor: remove unused hotkey scope and streamline command bindings - Deleted the `useSwitchHotkeyScope` hook and its references to simplify the hotkey management. - Updated command bindings to ensure they function correctly without the removed scope. - Enhanced the logic for determining when commands should be active based on the current focus and active scopes. This refactor improves the clarity and efficiency of the command handling system, reducing unnecessary complexity. Signed-off-by: Innei <tukon479@gmail.com> * feat: add shortcut conflict detection and enhance shortcut customization UI - Introduced a new hook `useIsShortcutConflict` to check for conflicts between user-defined shortcuts and existing ones. - Updated the `EditableCommandShortcutItem` component to visually indicate when a shortcut is customized by the user. - Enhanced the `ShortcutInputWrapper` to display conflict warnings and improve user interaction when editing shortcuts. This update improves the user experience by allowing users to easily identify and resolve shortcut conflicts, enhancing the overall customization capabilities of command shortcuts. Signed-off-by: Innei <tukon479@gmail.com> * feat: update command structure and enhance shortcut descriptions - Changed the command ID for toggling the timeline column to toggle the subscription column for better clarity. - Updated command labels to include descriptions for various commands, improving user understanding of their functionalities. - Enhanced localization files to reflect the new command descriptions, ensuring a consistent user experience across different languages. This update improves the usability of command shortcuts by providing clearer descriptions and a more intuitive command structure. Signed-off-by: Innei <tukon479@gmail.com> * feat: introduce new keyboard shortcuts system - Added a comprehensive command shortcut system that supports multiple categories including global operations, layout controls, timeline navigation, content rendering, subscription management, and entry actions. - Users can access and customize key bindings through `Settings` -> `Shortcuts`. This enhancement improves user efficiency by providing a structured way to manage keyboard shortcuts. Signed-off-by: Innei <tukon479@gmail.com> * refactor Signed-off-by: Innei <tukon479@gmail.com> * feat: enhance focusable component with presets for improved command handling - Introduced `FocusablePresets` to encapsulate common focus-related logic, improving code reusability. - Updated command bindings in various components to utilize the new presets, streamlining the focus management process. - Enhanced the `Focusable` component to support additional scopes, improving the overall functionality and user experience. This update simplifies the management of focusable elements and enhances the command handling system. Signed-off-by: Innei <tukon479@gmail.com> * refactor: enhance Focusable component with asChild prop for improved flexibility - Updated the Focusable component to support an `asChild` prop, allowing it to render a child element with focusable properties. - Modified the BizFocusableProps interface to align with the new FocusableProps structure. - Adjusted usage in the FeedColumn component to utilize the new `asChild` feature, enhancing the component's flexibility. This change improves the usability of the Focusable component by allowing it to wrap existing elements while maintaining focus management capabilities. Signed-off-by: Innei <tukon479@gmail.com> * refactor: update WindowUnderBlur and FeedColumn components for improved structure and functionality - Refactored the WindowUnderBlur component to accept a customizable `as` prop, enhancing its flexibility in rendering. - Updated the FeedColumn component to utilize the new `WindowUnderBlur` structure, allowing it to wrap the Focusable component directly. - Enhanced the Focusable component's logic to include additional conditions for the SubscriptionList scope, improving focus management. These changes streamline the component architecture and improve the overall user experience by allowing for more versatile component usage. Signed-off-by: Innei <tukon479@gmail.com> * refactor: update command bindings in SubscriptionList for improved focus management - Replaced the `isFocus` variable with `inSubscriptionScope` to streamline focus handling in the SubscriptionList component. - Updated command bindings and hotkey conditions to utilize the new `inSubscriptionScope` logic, enhancing the clarity and functionality of focus management. These changes improve the overall structure and usability of the SubscriptionList component, ensuring better focus handling within the subscription context. Signed-off-by: Innei <tukon479@gmail.com> --------- Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
parent
75e0c06b05
commit
62fc0a0cb1
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
import type { FocusableProps } from "@follow/components/common/Focusable/Focusable.js"
|
||||
import { Focusable as FocusableComponent } from "@follow/components/common/Focusable/Focusable.js"
|
||||
|
||||
import { FloatingLayerScope, HotkeyScope } from "~/constants"
|
||||
|
||||
interface BizFocusableProps extends Omit<FocusableProps, "scope"> {
|
||||
scope: HotkeyScope
|
||||
}
|
||||
export const Focusable = FocusableComponent as Component<
|
||||
Prettify<BizFocusableProps> &
|
||||
React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
|
||||
>
|
||||
|
||||
export const FocusablePresets = {
|
||||
isNotFloatingLayerScope: (v: Set<string>) => !FloatingLayerScope.some((s) => v.has(s)),
|
||||
isSubscriptionList: (scope: Set<string>) => {
|
||||
return (
|
||||
scope.size === 0 ||
|
||||
scope.has(HotkeyScope.SubscriptionList) ||
|
||||
(scope.has(HotkeyScope.Home) && scope.size === 1)
|
||||
)
|
||||
},
|
||||
|
||||
isSubscriptionOrTimeline: (v: Set<string>) => {
|
||||
return v.has(HotkeyScope.SubscriptionList) || v.has(HotkeyScope.Timeline) || v.size === 0
|
||||
},
|
||||
}
|
||||
|
|
@ -1,22 +1,31 @@
|
|||
import { Focusable } from "@follow/components/common/Focusable/index.js"
|
||||
import { SYSTEM_CAN_UNDER_BLUR_WINDOW } from "@follow/shared/constants"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import type * as React from "react"
|
||||
import type { ComponentPropsWithoutRef, ElementType } from "react"
|
||||
|
||||
import { useUISettingKey } from "~/atoms/settings/ui"
|
||||
|
||||
type Props = Component<
|
||||
React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
|
||||
>
|
||||
const MacOSVibrancy: Props = ({ children, ...rest }) => <Focusable {...rest}>{children}</Focusable>
|
||||
type Props<T extends ElementType = "div"> = {
|
||||
as?: T
|
||||
ref?: React.Ref<HTMLElement>
|
||||
} & ComponentPropsWithoutRef<T>
|
||||
|
||||
const Noop: Props = ({ children, className, ...rest }) => (
|
||||
<Focusable className={cn("bg-sidebar", className)} {...rest}>
|
||||
{children}
|
||||
</Focusable>
|
||||
)
|
||||
const MacOSVibrancy = <T extends ElementType = "div">({ children, as, ...rest }: Props<T>) => {
|
||||
const Component = as || "div"
|
||||
return <Component {...rest}>{children}</Component>
|
||||
}
|
||||
|
||||
export const WindowUnderBlur: Props = SYSTEM_CAN_UNDER_BLUR_WINDOW
|
||||
? (props) => {
|
||||
const Noop = <T extends ElementType = "div">({ children, className, as, ...rest }: Props<T>) => {
|
||||
const Component = as || "div"
|
||||
return (
|
||||
<Component className={cn("bg-sidebar", className)} {...rest}>
|
||||
{children}
|
||||
</Component>
|
||||
)
|
||||
}
|
||||
|
||||
export const WindowUnderBlur = SYSTEM_CAN_UNDER_BLUR_WINDOW
|
||||
? <T extends ElementType = "div">(props: Props<T>) => {
|
||||
const opaqueSidebar = useUISettingKey("opaqueSidebar")
|
||||
if (opaqueSidebar) {
|
||||
return <Noop {...props} />
|
||||
|
|
|
|||
|
|
@ -6,19 +6,15 @@ import { cn } from "@follow/utils/utils"
|
|||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import * as React from "react"
|
||||
|
||||
import { Focusable } from "~/components/common/Focusable"
|
||||
import { HotkeyScope } from "~/constants"
|
||||
import { useConditionalHotkeyScope } from "~/hooks/common"
|
||||
|
||||
const DropdownMenu: typeof DropdownMenuPrimitive.Root = (props) => {
|
||||
const [open, setOpen] = React.useState(!!props.open)
|
||||
useConditionalHotkeyScope(HotkeyScope.DropdownMenu, open)
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Root
|
||||
{...props}
|
||||
onOpenChange={useTypeScriptHappyCallback(
|
||||
(open) => {
|
||||
setOpen(open)
|
||||
props.onOpenChange?.(open)
|
||||
},
|
||||
[props.onOpenChange],
|
||||
|
|
@ -95,16 +91,18 @@ const DropdownMenuContent = ({
|
|||
}) => {
|
||||
return (
|
||||
<RootPortal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-material-medium backdrop-blur-background text-text shadow-context-menu z-[60] min-w-32 overflow-hidden rounded-[6px] border p-1",
|
||||
"motion-scale-in-75 motion-duration-150 text-body lg:animate-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<Focusable scope={HotkeyScope.DropdownMenu} className="contents">
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-material-medium backdrop-blur-background text-text shadow-context-menu z-[60] min-w-32 overflow-hidden rounded-[6px] border p-1",
|
||||
"motion-scale-in-75 motion-duration-150 text-body lg:animate-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</Focusable>
|
||||
</RootPortal>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Focusable } from "@follow/components/common/Focusable/index.js"
|
||||
"~/components/common/Focusable.js"
|
||||
import { Spring } from "@follow/components/constants/spring.js"
|
||||
import { ActionButton, MotionButtonBase } from "@follow/components/ui/button/index.js"
|
||||
import type { HTMLMediaState } from "@follow/hooks"
|
||||
|
|
@ -23,7 +23,9 @@ import { createContext, useContext, useContextSelector } from "use-context-selec
|
|||
import { useEventCallback } from "usehooks-ts"
|
||||
|
||||
import { AudioPlayer } from "~/atoms/player"
|
||||
import { Focusable } from "~/components/common/Focusable"
|
||||
import { IconScaleTransition } from "~/components/ux/transition/icon"
|
||||
import { HotkeyScope } from "~/constants"
|
||||
|
||||
import { VolumeSlider } from "./VolumeSlider"
|
||||
|
||||
|
|
@ -137,7 +139,11 @@ export const VideoPlayer = ({
|
|||
)
|
||||
|
||||
return (
|
||||
<Focusable className="center group relative size-full" ref={wrapperRef}>
|
||||
<Focusable
|
||||
className="center group relative size-full"
|
||||
ref={wrapperRef}
|
||||
scope={HotkeyScope.VideoPlayer}
|
||||
>
|
||||
{element}
|
||||
<div className="center pointer-events-none absolute inset-0">
|
||||
<m.div
|
||||
|
|
|
|||
|
|
@ -27,11 +27,11 @@ import { useEventCallback } from "usehooks-ts"
|
|||
|
||||
import { useUISettingKey } from "~/atoms/settings/ui"
|
||||
import { AppErrorBoundary } from "~/components/common/AppErrorBoundary"
|
||||
import { Focusable } from "~/components/common/Focusable"
|
||||
import { SafeFragment } from "~/components/common/Fragment"
|
||||
import { m } from "~/components/common/Motion"
|
||||
import { ErrorComponentType } from "~/components/errors/enum"
|
||||
import { ElECTRON_CUSTOM_TITLEBAR_HEIGHT, HotkeyScope } from "~/constants"
|
||||
import { useConditionalHotkeyScope } from "~/hooks/common"
|
||||
|
||||
import { modalStackAtom } from "./atom"
|
||||
import { MODAL_STACK_Z_INDEX, modalMontionConfig } from "./constants"
|
||||
|
|
@ -192,8 +192,6 @@ export const ModalInternal = memo(function Modal({
|
|||
}
|
||||
}, [currentIsClosing])
|
||||
|
||||
useConditionalHotkeyScope(HotkeyScope.Modal, true)
|
||||
|
||||
const modalStyle = resizeableStyle
|
||||
const { handleSelectStart, handleDetectSelectEnd, isSelectingRef } = useModalSelect()
|
||||
const handleClickOutsideToDismiss = useCallback(
|
||||
|
|
@ -253,7 +251,8 @@ export const ModalInternal = memo(function Modal({
|
|||
onPointerDownOutside={preventDefault}
|
||||
onOpenAutoFocus={openAutoFocus}
|
||||
>
|
||||
<div
|
||||
<Focusable
|
||||
scope={HotkeyScope.Modal}
|
||||
ref={setEdgeElementRef}
|
||||
className={cn(
|
||||
"no-drag-region fixed",
|
||||
|
|
@ -282,7 +281,7 @@ export const ModalInternal = memo(function Modal({
|
|||
<CustomModalComponent>{finalChildren}</CustomModalComponent>
|
||||
</ModalContext>
|
||||
</div>
|
||||
</div>
|
||||
</Focusable>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
|
|
@ -304,7 +303,8 @@ export const ModalInternal = memo(function Modal({
|
|||
onPointerDownOutside={preventDefault}
|
||||
onOpenAutoFocus={openAutoFocus}
|
||||
>
|
||||
<div
|
||||
<Focusable
|
||||
scope={HotkeyScope.Modal}
|
||||
ref={setEdgeElementRef}
|
||||
onContextMenu={preventDefault}
|
||||
className={cn(
|
||||
|
|
@ -395,7 +395,7 @@ export const ModalInternal = memo(function Modal({
|
|||
</div>
|
||||
</ResizeSwitch>
|
||||
</m.div>
|
||||
</div>
|
||||
</Focusable>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,178 +0,0 @@
|
|||
import { transformShortcut } from "@follow/utils/utils"
|
||||
|
||||
import { COPY_MAP } from "~/constants"
|
||||
|
||||
type Shortcuts = Record<
|
||||
string,
|
||||
Record<string, { name: I18nKeysForShortcuts; key: string; extra?: string }>
|
||||
>
|
||||
|
||||
const shortcutConfigs = {
|
||||
subscriptions: {
|
||||
add: {
|
||||
name: tShortcuts("keys.subscriptions.add"),
|
||||
key: "$mod+T",
|
||||
},
|
||||
switchToView: {
|
||||
name: tShortcuts("keys.subscriptions.switchToView"),
|
||||
key: "1, 2, 3, 4, 5, 6",
|
||||
},
|
||||
switchNextView: {
|
||||
name: tShortcuts("keys.subscriptions.switchNextView"),
|
||||
key: "Tab",
|
||||
},
|
||||
switchPreviousView: {
|
||||
name: tShortcuts("keys.subscriptions.switchPreviousView"),
|
||||
key: "Shift+Tab",
|
||||
},
|
||||
nextSubscription: {
|
||||
name: tShortcuts("keys.subscriptions.nextSubscription"),
|
||||
key: "J, ArrowDown",
|
||||
},
|
||||
previousSubscription: {
|
||||
name: tShortcuts("keys.subscriptions.previousSubscription"),
|
||||
key: "K, ArrowUp",
|
||||
},
|
||||
toggleFolderCollapse: {
|
||||
name: tShortcuts("keys.subscriptions.toggleFolderCollapse"),
|
||||
key: "Z",
|
||||
},
|
||||
openInBrowser: {
|
||||
name: tShortcuts("keys.subscriptions.openInBrowser"),
|
||||
key: "O",
|
||||
},
|
||||
openSiteInBrowser: {
|
||||
name: tShortcuts("keys.subscriptions.openSiteInBrowser"),
|
||||
key: "$mod+O",
|
||||
},
|
||||
markAllAsRead: {
|
||||
name: tShortcuts("keys.entries.markAllAsRead"),
|
||||
key: "Shift+$mod+A",
|
||||
},
|
||||
},
|
||||
layout: {
|
||||
toggleSidebar: {
|
||||
name: tShortcuts("keys.layout.toggleSidebar"),
|
||||
key: "$mod+B, [",
|
||||
},
|
||||
|
||||
toggleWideMode: {
|
||||
name: tShortcuts("keys.layout.toggleWideMode"),
|
||||
key: "$mod+[",
|
||||
},
|
||||
toggleZenMode: {
|
||||
name: tShortcuts("keys.layout.zenMode"),
|
||||
key: "$mod+Shift+Z",
|
||||
},
|
||||
},
|
||||
entries: {
|
||||
refetch: {
|
||||
name: tShortcuts("keys.entries.refetch"),
|
||||
key: "R",
|
||||
},
|
||||
previous: {
|
||||
name: tShortcuts("keys.entries.previous"),
|
||||
key: "K, ArrowUp",
|
||||
},
|
||||
next: {
|
||||
name: tShortcuts("keys.entries.next"),
|
||||
key: "J, ArrowDown",
|
||||
},
|
||||
|
||||
toggleUnreadOnly: {
|
||||
name: tShortcuts("keys.entries.toggleUnreadOnly"),
|
||||
key: "U",
|
||||
},
|
||||
},
|
||||
entry: {
|
||||
toggleRead: {
|
||||
name: tShortcuts("keys.entry.toggleRead"),
|
||||
key: "M",
|
||||
},
|
||||
toggleStarred: {
|
||||
name: tShortcuts("keys.entry.toggleStarred"),
|
||||
key: "S",
|
||||
},
|
||||
openInBrowser: {
|
||||
name: COPY_MAP.OpenInBrowser(),
|
||||
key: "B",
|
||||
extra: "Double Click",
|
||||
},
|
||||
tts: {
|
||||
name: tShortcuts("keys.entry.tts"),
|
||||
key: "Shift+$mod+V",
|
||||
},
|
||||
copyLink: {
|
||||
name: tShortcuts("keys.entry.copyLink"),
|
||||
key: "Shift+$mod+C",
|
||||
},
|
||||
copyTitle: {
|
||||
name: tShortcuts("keys.entry.copyTitle"),
|
||||
key: "Shift+$mod+B",
|
||||
},
|
||||
tip: {
|
||||
name: tShortcuts("keys.entry.tip"),
|
||||
key: "Shift+$mod+T",
|
||||
},
|
||||
share: {
|
||||
name: tShortcuts("keys.entry.share"),
|
||||
key: "$mod+Alt+S",
|
||||
},
|
||||
scrollUp: {
|
||||
name: tShortcuts("keys.entry.scrollUp"),
|
||||
key: "K, ArrowUp",
|
||||
},
|
||||
scrollDown: {
|
||||
name: tShortcuts("keys.entry.scrollDown"),
|
||||
key: "J, ArrowDown",
|
||||
},
|
||||
nextEntry: {
|
||||
name: tShortcuts("keys.entries.next"),
|
||||
key: "L, ArrowRight",
|
||||
},
|
||||
previousEntry: {
|
||||
name: tShortcuts("keys.entries.previous"),
|
||||
key: "H, ArrowLeft",
|
||||
},
|
||||
},
|
||||
audio: {
|
||||
"play/pause": {
|
||||
name: tShortcuts("keys.audio.playPause"),
|
||||
key: "Space",
|
||||
},
|
||||
},
|
||||
misc: {
|
||||
quickSearch: {
|
||||
name: tShortcuts("keys.misc.quickSearch"),
|
||||
key: "$mod+K",
|
||||
},
|
||||
showShortcuts: {
|
||||
name: tShortcuts("keys.misc.showShortcuts"),
|
||||
key: "?",
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
function transformShortcuts<T extends Shortcuts>(configs: T) {
|
||||
const result = configs
|
||||
|
||||
for (const category in configs) {
|
||||
for (const shortcutKey in configs[category]) {
|
||||
const config = configs[category][shortcutKey]
|
||||
result[category]![shortcutKey]!.key = transformShortcut(config!.key)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export const shortcuts = transformShortcuts(shortcutConfigs) satisfies Shortcuts
|
||||
|
||||
export const shortcutsType: { [key in keyof typeof shortcuts]: I18nKeysForShortcuts } = {
|
||||
subscriptions: "keys.type.subscriptions",
|
||||
layout: "keys.type.layout",
|
||||
entries: "keys.type.entries",
|
||||
entry: "keys.type.entry",
|
||||
audio: "keys.type.audio",
|
||||
misc: "keys.type.misc",
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -2,5 +2,4 @@ export * from "./useBizQuery"
|
|||
export * from "./useContextMenu"
|
||||
export * from "./useI18n"
|
||||
export * from "./usePreventOverscrollBounce"
|
||||
export * from "./useSwitchHotkeyScope"
|
||||
export * from "./useSyncTheme"
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -11,6 +11,8 @@ import {
|
|||
useRealInWideMode,
|
||||
useUISettingKey,
|
||||
} from "~/atoms/settings/ui"
|
||||
import { Focusable } from "~/components/common/Focusable"
|
||||
import { HotkeyScope } from "~/constants"
|
||||
import { useRouteParams } from "~/hooks/biz/useRouteParams"
|
||||
import { EntryColumn } from "~/modules/entry-column"
|
||||
import { AppLayoutGridContainerProvider } from "~/providers/app-grid-layout-container-provider"
|
||||
|
|
@ -40,7 +42,7 @@ export function CenterColumnDesktop() {
|
|||
})
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative flex min-w-0 grow">
|
||||
<Focusable scope={HotkeyScope.Home} ref={containerRef} className="relative flex min-w-0 grow">
|
||||
<div
|
||||
className={cn("h-full shrink-0", inWideMode ? "flex-1" : "border-r", "will-change-[width]")}
|
||||
data-hide-in-print
|
||||
|
|
@ -56,6 +58,6 @@ export function CenterColumnDesktop() {
|
|||
<PanelSplitter {...separatorProps} cursor={separatorCursor} isDragging={isDragging} />
|
||||
)}
|
||||
<Outlet />
|
||||
</div>
|
||||
</Focusable>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { Link } from "react-router"
|
|||
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
|
||||
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
||||
|
||||
import { FeedList } from "../../timeline-column/FeedList"
|
||||
import { SubscriptionList } from "../../subscription-column/SubscriptionList"
|
||||
import { MobileFloatBar } from "./float-bar.mobile"
|
||||
|
||||
export function FeedColumnMobile({ asWidget }: { asWidget?: boolean }) {
|
||||
|
|
@ -47,7 +47,7 @@ export function FeedColumnMobile({ asWidget }: { asWidget?: boolean }) {
|
|||
<SwipeWrapper active={view}>
|
||||
{views.map((item, index) => (
|
||||
<section key={item.name} className="size-full flex-none shrink-0 snap-center">
|
||||
<FeedList
|
||||
<SubscriptionList
|
||||
ref={setFeedListScrollRef}
|
||||
className="flex size-full flex-col text-sm"
|
||||
view={index}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { getReadonlyRoute } from "@follow/components/atoms/route.js"
|
||||
import { useGlobalFocusableScope } from "@follow/components/common/Focusable/hooks.js"
|
||||
import { MotionButtonBase } from "@follow/components/ui/button/index.js"
|
||||
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
|
||||
import { Routes } from "@follow/constants"
|
||||
|
|
@ -10,14 +11,20 @@ import { useHotkeys } from "react-hotkeys-hook"
|
|||
import { useTranslation } from "react-i18next"
|
||||
import { NavigationType, Outlet, useLocation, useNavigate, useNavigationType } from "react-router"
|
||||
|
||||
import { Focusable } from "~/components/common/Focusable"
|
||||
import { FABContainer, FABPortable } from "~/components/ui/fab"
|
||||
import { HotkeyScope } from "~/constants"
|
||||
import { useConditionalHotkeyScope } from "~/hooks/common"
|
||||
import { useHotkeyScope } from "~/providers/hotkey-provider"
|
||||
|
||||
import { useSubViewTitleValue } from "./hooks"
|
||||
|
||||
export function SubviewLayout() {
|
||||
return (
|
||||
<Focusable className="contents" scope={HotkeyScope.SubLayer}>
|
||||
<SubviewLayoutInner />
|
||||
</Focusable>
|
||||
)
|
||||
}
|
||||
function SubviewLayoutInner() {
|
||||
const navigate = useNavigate()
|
||||
const prevLocation = useRef(getReadonlyRoute().location).current
|
||||
const title = useSubViewTitleValue()
|
||||
|
|
@ -57,8 +64,6 @@ export function SubviewLayout() {
|
|||
// electron window has pt-[calc(var(--fo-window-padding-top)_-10px)]
|
||||
const isElectronWindows = ELECTRON_BUILD && getOS() === "Windows"
|
||||
|
||||
useConditionalHotkeyScope(HotkeyScope.SubLayer, true)
|
||||
|
||||
const backHandler = () => {
|
||||
if (prevLocation.pathname === location.pathname) {
|
||||
navigate({ pathname: "" })
|
||||
|
|
@ -66,9 +71,9 @@ export function SubviewLayout() {
|
|||
navigate(-1)
|
||||
}
|
||||
}
|
||||
const activeScope = useHotkeyScope()
|
||||
const activeScope = useGlobalFocusableScope()
|
||||
useHotkeys("Escape", backHandler, {
|
||||
enabled: activeScope.includes(HotkeyScope.SubLayer),
|
||||
enabled: activeScope.has(HotkeyScope.SubLayer),
|
||||
})
|
||||
return (
|
||||
<div className="relative flex size-full">
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,9 +28,11 @@ import { useTipModal } from "~/modules/wallet/hooks"
|
|||
import { entryActions, useEntryStore } from "~/store/entry"
|
||||
|
||||
import { useRegisterFollowCommand } from "../hooks/use-register-command"
|
||||
import type { Command } from "../types"
|
||||
import type { Command, CommandCategory } from "../types"
|
||||
import { COMMAND_ID } from "./id"
|
||||
|
||||
const category: CommandCategory = "category.entry"
|
||||
|
||||
const useCollect = () => {
|
||||
const { t } = useTranslation()
|
||||
return useMutation({
|
||||
|
|
@ -120,8 +122,7 @@ export const useRegisterEntryCommands = () => {
|
|||
id: COMMAND_ID.entry.tip,
|
||||
label: t("entry_actions.tip"),
|
||||
icon: <i className="i-mgc-power-outline" />,
|
||||
// keyBinding: shortcuts.entry.tip.key,
|
||||
// when: !isInbox && feed?.ownerUserId !== whoami()?.id && !!populatedEntry,
|
||||
category,
|
||||
run: ({ userId, feedId, entryId }) => {
|
||||
openTipModal({
|
||||
userId,
|
||||
|
|
@ -134,6 +135,7 @@ export const useRegisterEntryCommands = () => {
|
|||
{
|
||||
id: COMMAND_ID.entry.star,
|
||||
label: t("entry_actions.star"),
|
||||
category,
|
||||
icon: (props) => (
|
||||
<i
|
||||
className={cn(
|
||||
|
|
@ -147,15 +149,7 @@ export const useRegisterEntryCommands = () => {
|
|||
toast.error("Failed to star: entry is not available", { duration: 3000 })
|
||||
return
|
||||
}
|
||||
// if (type === "toolbar") {
|
||||
// const absoluteStarAnimationUri = new URL(StarAnimationUri, import.meta.url).href
|
||||
// mountLottie(absoluteStarAnimationUri, {
|
||||
// x: e.clientX - 90,
|
||||
// y: e.clientY - 70,
|
||||
// height: 126,
|
||||
// width: 252,
|
||||
// })
|
||||
// }
|
||||
|
||||
if (entry.collections) {
|
||||
uncollect.mutate(entry.entries.id)
|
||||
} else {
|
||||
|
|
@ -167,6 +161,7 @@ export const useRegisterEntryCommands = () => {
|
|||
id: COMMAND_ID.entry.delete,
|
||||
label: t("entry_actions.delete"),
|
||||
icon: <i className="i-mgc-delete-2-cute-re" />,
|
||||
category,
|
||||
run: ({ entryId }) => {
|
||||
const entry = useEntryStore.getState().flatMapEntries[entryId]
|
||||
if (!entry) {
|
||||
|
|
@ -180,6 +175,7 @@ export const useRegisterEntryCommands = () => {
|
|||
id: COMMAND_ID.entry.copyLink,
|
||||
label: t("entry_actions.copy_link"),
|
||||
icon: <i className="i-mgc-link-cute-re" />,
|
||||
category,
|
||||
run: ({ entryId }) => {
|
||||
const entry = useEntryStore.getState().flatMapEntries[entryId]
|
||||
if (!entry) {
|
||||
|
|
@ -197,6 +193,7 @@ export const useRegisterEntryCommands = () => {
|
|||
id: COMMAND_ID.entry.exportAsPDF,
|
||||
label: t("entry_actions.export_as_pdf"),
|
||||
icon: <i className="i-mgc-pdf-cute-re" />,
|
||||
category,
|
||||
run: ({ entryId }) => {
|
||||
const entry = useEntryStore.getState().flatMapEntries[entryId]
|
||||
|
||||
|
|
@ -212,6 +209,7 @@ export const useRegisterEntryCommands = () => {
|
|||
id: COMMAND_ID.entry.copyTitle,
|
||||
label: t("entry_actions.copy_title"),
|
||||
icon: <i className="i-mgc-copy-cute-re" />,
|
||||
category,
|
||||
run: ({ entryId }) => {
|
||||
const entry = useEntryStore.getState().flatMapEntries[entryId]
|
||||
if (!entry) {
|
||||
|
|
@ -230,6 +228,7 @@ export const useRegisterEntryCommands = () => {
|
|||
label: t("entry_actions.open_in_browser", {
|
||||
which: t(IN_ELECTRON ? "words.browser" : "words.newTab"),
|
||||
}),
|
||||
category,
|
||||
icon: <i className="i-mgc-world-2-cute-re" />,
|
||||
run: ({ entryId }) => {
|
||||
const entry = useEntryStore.getState().flatMapEntries[entryId]
|
||||
|
|
@ -244,6 +243,7 @@ export const useRegisterEntryCommands = () => {
|
|||
id: COMMAND_ID.entry.viewSourceContent,
|
||||
label: t("entry_actions.view_source_content"),
|
||||
icon: <i className="i-mgc-web-cute-re" />,
|
||||
category,
|
||||
run: ({ entryId, siteUrl }) => {
|
||||
if (!getShowSourceContent()) {
|
||||
const entry = useEntryStore.getState().flatMapEntries[entryId]
|
||||
|
|
@ -276,6 +276,7 @@ export const useRegisterEntryCommands = () => {
|
|||
id: COMMAND_ID.entry.share,
|
||||
label: t("entry_actions.share"),
|
||||
icon: <i className="i-mgc-share-forward-cute-re" />,
|
||||
category,
|
||||
run: ({ entryId }) => {
|
||||
const entry = useEntryStore.getState().flatMapEntries[entryId]
|
||||
if (!entry || !entry.entries.url) {
|
||||
|
|
@ -300,6 +301,7 @@ export const useRegisterEntryCommands = () => {
|
|||
{
|
||||
id: COMMAND_ID.entry.readAbove,
|
||||
label: t("entry_actions.mark_above_as_read"),
|
||||
category,
|
||||
run: ({ publishedAt }: { publishedAt: string }) => {
|
||||
return markAllByRoute({
|
||||
startTime: new Date(publishedAt).getTime() + 1,
|
||||
|
|
@ -310,6 +312,7 @@ export const useRegisterEntryCommands = () => {
|
|||
{
|
||||
id: COMMAND_ID.entry.read,
|
||||
label: t("entry_actions.mark_as_read"),
|
||||
category,
|
||||
icon: (props) => (
|
||||
<i className={cn(props?.isActive ? "i-mgc-round-cute-re" : "i-mgc-round-cute-fi")} />
|
||||
),
|
||||
|
|
@ -329,6 +332,7 @@ export const useRegisterEntryCommands = () => {
|
|||
{
|
||||
id: COMMAND_ID.entry.readBelow,
|
||||
label: t("entry_actions.mark_below_as_read"),
|
||||
category,
|
||||
run: ({ publishedAt }: { publishedAt: string }) => {
|
||||
return markAllByRoute({
|
||||
startTime: 1,
|
||||
|
|
@ -340,6 +344,7 @@ export const useRegisterEntryCommands = () => {
|
|||
id: COMMAND_ID.entry.imageGallery,
|
||||
label: t("entry_actions.image_gallery"),
|
||||
icon: <i className="i-mgc-pic-cute-fi" />,
|
||||
category,
|
||||
run: ({ entryId }) => {
|
||||
openGalleryModal(entryId)
|
||||
},
|
||||
|
|
@ -347,6 +352,7 @@ export const useRegisterEntryCommands = () => {
|
|||
{
|
||||
id: COMMAND_ID.entry.tts,
|
||||
label: t("entry_content.header.play_tts"),
|
||||
category,
|
||||
icon: <i className="i-mgc-voice-cute-re" />,
|
||||
run: async ({ entryId, entryContent }) => {
|
||||
if (getAudioPlayerAtomValue().entryId === entryId) {
|
||||
|
|
@ -370,6 +376,7 @@ export const useRegisterEntryCommands = () => {
|
|||
},
|
||||
{
|
||||
id: COMMAND_ID.entry.readability,
|
||||
category,
|
||||
label: {
|
||||
title: t("entry_content.header.readability"),
|
||||
description: t("entry_content.header.readability_description"),
|
||||
|
|
@ -396,6 +403,7 @@ export const useRegisterEntryCommands = () => {
|
|||
id: COMMAND_ID.entry.toggleAISummary,
|
||||
label: t("entry_actions.toggle_ai_summary"),
|
||||
icon: <i className="i-mgc-ai-cute-re" />,
|
||||
category,
|
||||
run: () => {
|
||||
if (role === UserRole.Trial) {
|
||||
presentActivationModal()
|
||||
|
|
@ -408,6 +416,7 @@ export const useRegisterEntryCommands = () => {
|
|||
id: COMMAND_ID.entry.toggleAITranslation,
|
||||
label: t("entry_actions.toggle_ai_translation"),
|
||||
icon: <i className="i-mgc-translate-2-ai-cute-re" />,
|
||||
category,
|
||||
run: () => {
|
||||
if (role === UserRole.Trial) {
|
||||
presentActivationModal()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import { useEntryStore } from "~/store/entry"
|
|||
|
||||
import { useRegisterCommandEffect } from "../hooks/use-register-command"
|
||||
import { defineFollowCommand } from "../registry/command"
|
||||
import type { Command } from "../types"
|
||||
import type { Command, CommandCategory } from "../types"
|
||||
import { COMMAND_ID } from "./id"
|
||||
|
||||
export const useRegisterIntegrationCommands = () => {
|
||||
|
|
@ -43,6 +43,7 @@ export const useRegisterIntegrationCommands = () => {
|
|||
useRegisterZoteroCommands()
|
||||
}
|
||||
|
||||
const category: CommandCategory = "category.integration"
|
||||
const useRegisterEagleCommands = () => {
|
||||
const { t } = useTranslation()
|
||||
const { view } = useRouteParams()
|
||||
|
|
@ -125,6 +126,7 @@ const useRegisterReadwiseCommands = () => {
|
|||
id: COMMAND_ID.integration.saveToReadwise,
|
||||
label: t("entry_actions.save_to_readwise"),
|
||||
icon: <SimpleIconsReadwise />,
|
||||
category,
|
||||
run: async ({ entryId }) => {
|
||||
const entry = useEntryStore.getState().flatMapEntries[entryId]
|
||||
if (!entry) {
|
||||
|
|
@ -193,6 +195,7 @@ const useRegisterInstapaperCommands = () => {
|
|||
id: COMMAND_ID.integration.saveToInstapaper,
|
||||
label: t("entry_actions.save_to_instapaper"),
|
||||
icon: <SimpleIconsInstapaper />,
|
||||
category,
|
||||
run: async ({ entryId }) => {
|
||||
const entry = useEntryStore.getState().flatMapEntries[entryId]
|
||||
if (!entry) {
|
||||
|
|
@ -302,6 +305,7 @@ const useRegisterObsidianCommands = () => {
|
|||
id: COMMAND_ID.integration.saveToObsidian,
|
||||
label: t("entry_actions.save_to_obsidian"),
|
||||
icon: <SimpleIconsObsidian />,
|
||||
category,
|
||||
run: async ({ entryId }) => {
|
||||
const entry = useEntryStore.getState().flatMapEntries[entryId]
|
||||
if (!entry) {
|
||||
|
|
@ -346,6 +350,7 @@ const useRegisterOutlineCommands = () => {
|
|||
id: COMMAND_ID.integration.saveToOutline,
|
||||
label: t("entry_actions.save_to_outline"),
|
||||
icon: <SimpleIconsOutline />,
|
||||
category,
|
||||
run: async ({ entryId }) => {
|
||||
const entry = useEntryStore.getState().flatMapEntries[entryId]
|
||||
if (!entry) {
|
||||
|
|
@ -409,6 +414,7 @@ const useRegisterReadeckCommands = () => {
|
|||
id: COMMAND_ID.integration.saveToReadeck,
|
||||
label: t("entry_actions.save_to_readeck"),
|
||||
icon: <SimpleIconsReadeck />,
|
||||
category,
|
||||
run: async ({ entryId }) => {
|
||||
const entry = useEntryStore.getState().flatMapEntries[entryId]
|
||||
if (!entry) {
|
||||
|
|
@ -477,6 +483,7 @@ const useRegisterCuboxCommands = () => {
|
|||
id: COMMAND_ID.integration.saveToCubox,
|
||||
label: t("entry_actions.save_to_cubox"),
|
||||
icon: <SimpleIconsCubox />,
|
||||
category,
|
||||
run: async ({ entryId }) => {
|
||||
const entry = useEntryStore.getState().flatMapEntries[entryId]
|
||||
if (!entry) {
|
||||
|
|
@ -571,6 +578,7 @@ const useRegisterZoteroCommands = () => {
|
|||
id: COMMAND_ID.integration.saveToZotero,
|
||||
label: t("entry_actions.save_to_zotero"),
|
||||
icon: <SimpleIconsZotero />,
|
||||
category,
|
||||
run: async ({ entryId }) => {
|
||||
const entry = useEntryStore.getState().flatMapEntries[entryId]
|
||||
if (!entry) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}>
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { useSetTheme } from "~/hooks/common"
|
|||
import { useShowCustomizeToolbarModal } from "~/modules/customize-toolbar/modal"
|
||||
|
||||
import { useRegisterCommandEffect } from "../hooks/use-register-command"
|
||||
import type { Command } from "../types"
|
||||
import type { Command, CommandCategory } from "../types"
|
||||
import { COMMAND_ID } from "./id"
|
||||
|
||||
export const useRegisterSettingsCommands = () => {
|
||||
|
|
@ -13,6 +13,7 @@ export const useRegisterSettingsCommands = () => {
|
|||
useRegisterThemeCommands()
|
||||
}
|
||||
|
||||
const category: CommandCategory = "category.settings"
|
||||
const useCustomizeToolbarCommand = () => {
|
||||
const [t] = useTranslation("settings")
|
||||
const showModal = useShowCustomizeToolbarModal()
|
||||
|
|
@ -20,7 +21,7 @@ const useCustomizeToolbarCommand = () => {
|
|||
{
|
||||
id: COMMAND_ID.settings.customizeToolbar,
|
||||
label: t("customizeToolbar.title"),
|
||||
category: "follow:settings",
|
||||
category,
|
||||
icon: <i className="i-mgc-settings-7-cute-re" />,
|
||||
run() {
|
||||
showModal()
|
||||
|
|
@ -38,7 +39,7 @@ const useRegisterThemeCommands = () => {
|
|||
{
|
||||
id: COMMAND_ID.settings.changeThemeToAuto,
|
||||
label: `To ${t("appearance.theme.system")}`,
|
||||
category: "follow:settings",
|
||||
category,
|
||||
icon: <i className="i-mgc-settings-7-cute-re" />,
|
||||
when: theme !== "system",
|
||||
run() {
|
||||
|
|
@ -48,7 +49,7 @@ const useRegisterThemeCommands = () => {
|
|||
{
|
||||
id: COMMAND_ID.settings.changeThemeToDark,
|
||||
label: `To ${t("appearance.theme.dark")}`,
|
||||
category: "follow:settings",
|
||||
category,
|
||||
icon: <i className="i-mingcute-moon-line" />,
|
||||
when: theme !== "dark",
|
||||
run() {
|
||||
|
|
@ -58,7 +59,7 @@ const useRegisterThemeCommands = () => {
|
|||
{
|
||||
id: COMMAND_ID.settings.changeThemeToLight,
|
||||
label: `To ${t("appearance.theme.light")}`,
|
||||
category: "follow:settings",
|
||||
category,
|
||||
icon: <i className="i-mingcute-sun-line" />,
|
||||
when: theme !== "light",
|
||||
run() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,62 +1,201 @@
|
|||
import { shortcuts } from "~/constants/shortcuts"
|
||||
import { getStorageNS } from "@follow/utils/ns"
|
||||
import { transformShortcut } from "@follow/utils/utils"
|
||||
import { useAtomValue, useSetAtom } from "jotai"
|
||||
import { atomWithStorage, selectAtom } from "jotai/utils"
|
||||
import { useCallback, useMemo } from "react"
|
||||
|
||||
import { COMMAND_ID } from "../commands/id"
|
||||
import type { CommandCategory, FollowCommandId } from "../types"
|
||||
import { getCommand } from "./use-command"
|
||||
import type { RegisterHotkeyOptions } from "./use-register-hotkey"
|
||||
import { useCommandHotkey } from "./use-register-hotkey"
|
||||
|
||||
const defaultCommandShortcuts = {
|
||||
// Entry commands
|
||||
[COMMAND_ID.entry.copyLink]: shortcuts.entry.copyLink.key,
|
||||
[COMMAND_ID.entry.copyTitle]: shortcuts.entry.copyTitle.key,
|
||||
[COMMAND_ID.entry.openInBrowser]: shortcuts.entry.openInBrowser.key,
|
||||
[COMMAND_ID.entry.read]: shortcuts.entry.toggleRead.key,
|
||||
[COMMAND_ID.entry.share]: shortcuts.entry.share.key,
|
||||
[COMMAND_ID.entry.star]: shortcuts.entry.toggleStarred.key,
|
||||
[COMMAND_ID.entry.tip]: shortcuts.entry.tip.key,
|
||||
[COMMAND_ID.entry.tts]: shortcuts.entry.tts.key,
|
||||
|
||||
// Entry render commands
|
||||
[COMMAND_ID.entryRender.nextEntry]: shortcuts.entry.nextEntry.key,
|
||||
[COMMAND_ID.entryRender.previousEntry]: shortcuts.entry.previousEntry.key,
|
||||
[COMMAND_ID.entryRender.scrollDown]: shortcuts.entry.scrollDown.key,
|
||||
[COMMAND_ID.entryRender.scrollUp]: shortcuts.entry.scrollUp.key,
|
||||
|
||||
// Global commands
|
||||
[COMMAND_ID.global.showShortcuts]: shortcuts.misc.showShortcuts.key,
|
||||
|
||||
export const defaultCommandShortcuts = {
|
||||
// Layout commands
|
||||
[COMMAND_ID.layout.toggleTimelineColumn]: shortcuts.layout.toggleSidebar.key,
|
||||
[COMMAND_ID.layout.toggleWideMode]: shortcuts.layout.toggleWideMode.key,
|
||||
[COMMAND_ID.layout.toggleZenMode]: shortcuts.layout.toggleZenMode.key,
|
||||
[COMMAND_ID.layout.toggleSubscriptionColumn]: transformShortcut("$mod+B"),
|
||||
[COMMAND_ID.layout.toggleWideMode]: transformShortcut("$mod+["),
|
||||
[COMMAND_ID.layout.toggleZenMode]: transformShortcut("$mod+Shift+Z"),
|
||||
|
||||
// Subscription commands
|
||||
[COMMAND_ID.subscription.markAllAsRead]: shortcuts.subscriptions.markAllAsRead.key,
|
||||
[COMMAND_ID.subscription.nextSubscription]: shortcuts.subscriptions.nextSubscription.key,
|
||||
[COMMAND_ID.subscription.openInBrowser]: shortcuts.subscriptions.openInBrowser.key,
|
||||
[COMMAND_ID.subscription.openSiteInBrowser]: shortcuts.subscriptions.openSiteInBrowser.key,
|
||||
[COMMAND_ID.subscription.previousSubscription]: shortcuts.subscriptions.previousSubscription.key,
|
||||
[COMMAND_ID.subscription.switchTabToNext]: shortcuts.subscriptions.switchNextView.key,
|
||||
[COMMAND_ID.subscription.switchTabToPrevious]: shortcuts.subscriptions.switchPreviousView.key,
|
||||
[COMMAND_ID.subscription.toggleFolderCollapse]: shortcuts.subscriptions.toggleFolderCollapse.key,
|
||||
[COMMAND_ID.subscription.markAllAsRead]: transformShortcut("Shift+$mod+A"),
|
||||
[COMMAND_ID.subscription.openInBrowser]: "O",
|
||||
[COMMAND_ID.subscription.openSiteInBrowser]: transformShortcut("$mod+O"),
|
||||
[COMMAND_ID.subscription.previousSubscription]: "K, ArrowUp",
|
||||
[COMMAND_ID.subscription.nextSubscription]: "J, ArrowDown",
|
||||
[COMMAND_ID.subscription.switchTabToNext]: "Tab",
|
||||
[COMMAND_ID.subscription.switchTabToPrevious]: transformShortcut("Shift+Tab"),
|
||||
[COMMAND_ID.subscription.toggleFolderCollapse]: "Z",
|
||||
|
||||
// Timeline commands
|
||||
[COMMAND_ID.timeline.refetch]: shortcuts.entries.refetch.key,
|
||||
[COMMAND_ID.timeline.switchToNext]: shortcuts.entries.next.key,
|
||||
[COMMAND_ID.timeline.switchToPrevious]: shortcuts.entries.previous.key,
|
||||
[COMMAND_ID.timeline.unreadOnly]: shortcuts.entries.toggleUnreadOnly.key,
|
||||
[COMMAND_ID.timeline.refetch]: "R",
|
||||
[COMMAND_ID.timeline.unreadOnly]: "U",
|
||||
[COMMAND_ID.timeline.switchToPrevious]: "K, ArrowUp",
|
||||
[COMMAND_ID.timeline.switchToNext]: "J, ArrowDown",
|
||||
|
||||
// Entry commands
|
||||
[COMMAND_ID.entry.copyLink]: transformShortcut("Shift+$mod+C"),
|
||||
[COMMAND_ID.entry.copyTitle]: transformShortcut("Shift+$mod+B"),
|
||||
[COMMAND_ID.entry.openInBrowser]: "B",
|
||||
[COMMAND_ID.entry.read]: "M",
|
||||
[COMMAND_ID.entry.share]: transformShortcut("$mod+Alt+S"),
|
||||
[COMMAND_ID.entry.star]: "S",
|
||||
[COMMAND_ID.entry.tip]: transformShortcut("Shift+$mod+T"),
|
||||
[COMMAND_ID.entry.tts]: transformShortcut("Shift+$mod+V"),
|
||||
|
||||
// Entry render commands
|
||||
[COMMAND_ID.entryRender.nextEntry]: "L, ArrowRight",
|
||||
[COMMAND_ID.entryRender.previousEntry]: "H, ArrowLeft",
|
||||
[COMMAND_ID.entryRender.scrollUp]: "K, ArrowUp",
|
||||
[COMMAND_ID.entryRender.scrollDown]: "J, ArrowDown",
|
||||
|
||||
// Global commands
|
||||
[COMMAND_ID.global.toggleCornerPlay]: "Space",
|
||||
[COMMAND_ID.global.quickAdd]: transformShortcut("$mod+N"),
|
||||
[COMMAND_ID.global.showShortcuts]: "?",
|
||||
} as const
|
||||
|
||||
const overrideCommandShortcutsAtom = atomWithStorage<
|
||||
Partial<Record<AllowCustomizeCommandId, string>>
|
||||
>(getStorageNS("command-shortcuts"), {}, undefined, {
|
||||
getOnInit: true,
|
||||
})
|
||||
|
||||
export const useCommandShortcutItems = () => {
|
||||
const commandShortcuts = useCommandShortcuts()
|
||||
|
||||
return useMemo(() => {
|
||||
const groupedCommands = {} as Record<CommandCategory, FollowCommandId[]>
|
||||
for (const commandKey in commandShortcuts) {
|
||||
const command = getCommand(commandKey as FollowCommandId)
|
||||
|
||||
if (!command) {
|
||||
continue
|
||||
}
|
||||
|
||||
groupedCommands[command.category] ??= []
|
||||
groupedCommands[command.category].push(commandKey as FollowCommandId)
|
||||
}
|
||||
|
||||
return groupedCommands
|
||||
}, [commandShortcuts])
|
||||
}
|
||||
export const allowCustomizeCommands = new Set([
|
||||
COMMAND_ID.layout.toggleSubscriptionColumn,
|
||||
COMMAND_ID.layout.toggleWideMode,
|
||||
COMMAND_ID.layout.toggleZenMode,
|
||||
|
||||
COMMAND_ID.subscription.markAllAsRead,
|
||||
|
||||
COMMAND_ID.subscription.openInBrowser,
|
||||
COMMAND_ID.subscription.openSiteInBrowser,
|
||||
|
||||
COMMAND_ID.subscription.switchTabToNext,
|
||||
COMMAND_ID.subscription.switchTabToPrevious,
|
||||
COMMAND_ID.subscription.toggleFolderCollapse,
|
||||
|
||||
COMMAND_ID.timeline.refetch,
|
||||
COMMAND_ID.timeline.unreadOnly,
|
||||
|
||||
COMMAND_ID.entry.copyLink,
|
||||
COMMAND_ID.entry.copyTitle,
|
||||
COMMAND_ID.entry.openInBrowser,
|
||||
COMMAND_ID.entry.read,
|
||||
COMMAND_ID.entry.share,
|
||||
COMMAND_ID.entry.star,
|
||||
COMMAND_ID.entry.tip,
|
||||
COMMAND_ID.entry.tts,
|
||||
] as const)
|
||||
type ExtractSetType<T extends Set<unknown>> = T extends Set<infer U> ? U : never
|
||||
export type AllowCustomizeCommandId = ExtractSetType<typeof allowCustomizeCommands>
|
||||
export type BindingCommandId = keyof typeof defaultCommandShortcuts
|
||||
|
||||
// eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-prefix, @eslint-react/hooks-extra/ensure-custom-hooks-using-other-hooks
|
||||
const useCommandShortcut = (commandId: BindingCommandId): string => {
|
||||
const commandShortcut = defaultCommandShortcuts[commandId]
|
||||
|
||||
return commandShortcut
|
||||
export const useCommandShortcut = (commandId: BindingCommandId): string => {
|
||||
return useAtomValue(
|
||||
useMemo(
|
||||
() =>
|
||||
selectAtom(overrideCommandShortcutsAtom, (v) => {
|
||||
return v[commandId] ?? defaultCommandShortcuts[commandId]
|
||||
}),
|
||||
[commandId],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export const useSetCustomCommandShortcut = () => {
|
||||
const setOverrideCommandShortcuts = useSetAtom(overrideCommandShortcutsAtom)
|
||||
|
||||
return useCallback(
|
||||
(commandId: AllowCustomizeCommandId, shortcut: string | null) => {
|
||||
setOverrideCommandShortcuts((prev) => {
|
||||
if (shortcut === null) {
|
||||
const { [commandId]: _, ...rest } = prev
|
||||
|
||||
return rest
|
||||
}
|
||||
return { ...prev, [commandId]: shortcut }
|
||||
})
|
||||
},
|
||||
[setOverrideCommandShortcuts],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @deprecated Use `useCommandShortcut` for more granular control
|
||||
*/
|
||||
export const useCommandShortcuts = () => {
|
||||
return defaultCommandShortcuts
|
||||
const overrideCommandShortcuts = useAtomValue(overrideCommandShortcutsAtom)
|
||||
|
||||
return {
|
||||
...defaultCommandShortcuts,
|
||||
...overrideCommandShortcuts,
|
||||
}
|
||||
}
|
||||
|
||||
export const useIsShortcutConflict = (
|
||||
shortcut: string,
|
||||
excludeCommandId?: AllowCustomizeCommandId,
|
||||
) => {
|
||||
const overrideCommandShortcuts = useAtomValue(overrideCommandShortcutsAtom)
|
||||
|
||||
return useMemo(() => {
|
||||
const allShortcuts = {
|
||||
...defaultCommandShortcuts,
|
||||
...overrideCommandShortcuts,
|
||||
}
|
||||
|
||||
// Check if the shortcut conflicts with any existing shortcuts
|
||||
for (const [commandId, existingShortcut] of Object.entries(allShortcuts)) {
|
||||
// Skip the command we're excluding (useful when editing an existing shortcut)
|
||||
if (excludeCommandId && commandId === excludeCommandId) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Normalize shortcuts for comparison (handle multiple shortcuts separated by comma)
|
||||
const normalizedShortcut = shortcut.trim().toLowerCase()
|
||||
const normalizedExisting = existingShortcut.toLowerCase()
|
||||
|
||||
// Check if shortcuts match exactly or if one is contained in the other's alternatives
|
||||
const shortcutAlternatives = normalizedShortcut.split(",").map((s) => s.trim())
|
||||
const existingAlternatives = normalizedExisting.split(",").map((s) => s.trim())
|
||||
|
||||
for (const shortcutAlt of shortcutAlternatives) {
|
||||
for (const existingAlt of existingAlternatives) {
|
||||
if (shortcutAlt === existingAlt) {
|
||||
return {
|
||||
hasConflict: true,
|
||||
conflictingCommandId: commandId as FollowCommandId,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasConflict: false,
|
||||
conflictingCommandId: null,
|
||||
}
|
||||
}, [shortcut, excludeCommandId, overrideCommandShortcuts])
|
||||
}
|
||||
|
||||
export const useCommandBinding = <T extends BindingCommandId>({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,34 +1,41 @@
|
|||
import { useReplaceGlobalFocusableScope } from "@follow/components/common/Focusable/hooks.js"
|
||||
import { KbdCombined } from "@follow/components/ui/kbd/Kbd.js"
|
||||
import { RootPortal } from "@follow/components/ui/portal/index.js"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@follow/components/ui/tooltip/index.js"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import type { FC, RefObject, SVGProps } from "react"
|
||||
import { memo, useEffect, useRef, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useOnClickOutside } from "usehooks-ts"
|
||||
|
||||
import { shortcuts, shortcutsType } from "~/constants/shortcuts"
|
||||
import { HotkeyScope } from "~/constants"
|
||||
|
||||
export const SettingShortcuts = () => {
|
||||
import { useCommand } from "../hooks/use-command"
|
||||
import type { AllowCustomizeCommandId } from "../hooks/use-command-binding"
|
||||
import {
|
||||
allowCustomizeCommands,
|
||||
defaultCommandShortcuts,
|
||||
useCommandShortcutItems,
|
||||
useCommandShortcuts,
|
||||
useIsShortcutConflict,
|
||||
useSetCustomCommandShortcut,
|
||||
} from "../hooks/use-command-binding"
|
||||
import type { CommandCategory, FollowCommandId } from "../types"
|
||||
|
||||
export const ShortcutsGuideline = () => {
|
||||
const { t } = useTranslation("shortcuts")
|
||||
const commandShortcuts = useCommandShortcutItems()
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-6">
|
||||
{Object.keys(shortcuts).map((type) => (
|
||||
{Object.entries(commandShortcuts).map(([type, commands]) => (
|
||||
<section key={type}>
|
||||
<div className="text-text-secondary mb-2 pl-3 text-sm font-medium capitalize">
|
||||
{t(shortcutsType[type])}
|
||||
{t(type as CommandCategory)}
|
||||
</div>
|
||||
<div className="text-text rounded-md border text-[13px]">
|
||||
{Object.keys(shortcuts[type]).map((action, index) => (
|
||||
<div
|
||||
key={`${type}-${action}`}
|
||||
className={cn(
|
||||
"flex h-9 items-center justify-between px-3 py-1.5",
|
||||
index % 2 && "bg-fill-quinary",
|
||||
)}
|
||||
>
|
||||
<div>{t(shortcuts[type][action].name)}</div>
|
||||
<div>
|
||||
<KbdCombined joint>
|
||||
{`${shortcuts[type][action].key}${shortcuts[type][action].extra ? `, ${shortcuts[type][action].extra}` : ""}`}
|
||||
</KbdCombined>
|
||||
</div>
|
||||
</div>
|
||||
{commands.map((commandId) => (
|
||||
<CommandShortcutItem key={commandId} commandId={commandId} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -36,3 +43,406 @@ export const SettingShortcuts = () => {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ShortcutSetting = () => {
|
||||
const { t } = useTranslation("shortcuts")
|
||||
const commandShortcuts = useCommandShortcutItems()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-6 mt-4 space-y-2 text-sm">{t("settings.shortcuts.description")}</p>
|
||||
{Object.entries(commandShortcuts).map(([type, commands]) => (
|
||||
<section key={type} className="mb-8">
|
||||
<div className="text-text border-border mb-4 border-b pb-2 text-base font-medium">
|
||||
{t(type as CommandCategory)}
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{commands.map((commandId) => (
|
||||
<EditableCommandShortcutItem key={commandId} commandId={commandId} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const EditableCommandShortcutItem = memo(({ commandId }: { commandId: FollowCommandId }) => {
|
||||
const command = useCommand(commandId)
|
||||
const commandShortcuts = useCommandShortcuts()
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
|
||||
const setCustomCommandShortcut = useSetCustomCommandShortcut()
|
||||
const allowCustomize = allowCustomizeCommands.has(commandId as AllowCustomizeCommandId)
|
||||
|
||||
if (!command) return null
|
||||
|
||||
const isUserCustomize = commandShortcuts[commandId] !== defaultCommandShortcuts[commandId]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"relative box-content grid h-8 grid-cols-[auto_200px] items-center justify-between gap-x-8 py-1.5"
|
||||
}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="text-text flex items-center gap-2 text-sm">
|
||||
{command.label.title}
|
||||
{isUserCustomize && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="bg-accent/10 text-accent hover:bg-accent/20 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium transition-all duration-200"
|
||||
title="User customized shortcut"
|
||||
>
|
||||
<div className="bg-accent mr-1 size-2 rounded-full" />
|
||||
Custom
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>This shortcut is customized by you</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{!!command.label.description && (
|
||||
<small className="text-text-secondary text-xs">{command.label.description}</small>
|
||||
)}
|
||||
</div>
|
||||
<ShortcutInputWrapper
|
||||
commandId={commandId}
|
||||
shortcut={commandShortcuts[commandId]}
|
||||
isEditing={isEditing}
|
||||
isUserCustomize={isUserCustomize}
|
||||
allowCustomize={allowCustomize}
|
||||
onEditingChange={setIsEditing}
|
||||
onShortcutChange={(shortcut) => {
|
||||
setCustomCommandShortcut(commandId as AllowCustomizeCommandId, shortcut)
|
||||
setIsEditing(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
interface ShortcutInputWrapperProps {
|
||||
commandId: FollowCommandId
|
||||
shortcut: string
|
||||
isEditing: boolean
|
||||
isUserCustomize: boolean
|
||||
allowCustomize: boolean
|
||||
onEditingChange: (editing: boolean) => void
|
||||
onShortcutChange: (shortcut: string | null) => void
|
||||
}
|
||||
|
||||
const ShortcutInputWrapper = memo(
|
||||
({
|
||||
commandId,
|
||||
shortcut,
|
||||
isEditing,
|
||||
isUserCustomize,
|
||||
allowCustomize,
|
||||
onEditingChange,
|
||||
onShortcutChange,
|
||||
}: ShortcutInputWrapperProps) => {
|
||||
const conflictResult = useIsShortcutConflict(shortcut, commandId as AllowCustomizeCommandId)
|
||||
|
||||
const hasConflict = allowCustomize && conflictResult.hasConflict
|
||||
const conflictingCommandId = allowCustomize ? conflictResult.conflictingCommandId : null
|
||||
|
||||
const conflictCommand = useCommand(conflictingCommandId as FollowCommandId)
|
||||
|
||||
const getBorderColor = () => {
|
||||
if (hasConflict) {
|
||||
return "border-red/70 hover:!border-red"
|
||||
}
|
||||
if (isEditing) {
|
||||
return "border-border bg-material-ultra-thick"
|
||||
}
|
||||
if (allowCustomize) {
|
||||
return "border-border/50 bg-material-ultra-thin data-[customized=true]:bg-accent/10 data-[customized=true]:border-accent/50"
|
||||
}
|
||||
return "border-transparent"
|
||||
}
|
||||
|
||||
const getBackgroundColor = () => {
|
||||
if (hasConflict && !isEditing) {
|
||||
return "bg-red/5"
|
||||
}
|
||||
if (isEditing) {
|
||||
return "bg-material-ultra-thick"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-customized={isUserCustomize}
|
||||
className={cn(
|
||||
"flex h-full cursor-text justify-end rounded-md border px-1 duration-200",
|
||||
allowCustomize && "hover:!border-border hover:!bg-material-medium",
|
||||
getBorderColor(),
|
||||
getBackgroundColor(),
|
||||
!allowCustomize && "pointer-events-none",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (allowCustomize) {
|
||||
onEditingChange(!isEditing)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isEditing ? (
|
||||
<KeyRecorder
|
||||
onBlur={() => {
|
||||
onEditingChange(false)
|
||||
}}
|
||||
onChange={(keys) => {
|
||||
onShortcutChange(Array.isArray(keys) ? keys.join("+") : null)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<KbdCombined kbdProps={{ wrapButton: false }} joint={false}>
|
||||
{shortcut}
|
||||
</KbdCombined>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
{hasConflict && (
|
||||
<RootPortal>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium text-red-400">Shortcut Conflict</div>
|
||||
<div className="text-xs">
|
||||
This shortcut conflicts with command:
|
||||
<p className="text-sm font-medium">{conflictCommand?.label.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</RootPortal>
|
||||
)}
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const KeyRecorder: FC<{
|
||||
onChange: (keys: string[] | null) => void
|
||||
onBlur: () => void
|
||||
}> = ({ onChange, onBlur }) => {
|
||||
const { currentKeys } = useShortcutRecorder()
|
||||
const setGlobalScope = useReplaceGlobalFocusableScope()
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
const { rollback } = setGlobalScope(HotkeyScope.Recording)
|
||||
if (ref.current) {
|
||||
ref.current.focus()
|
||||
}
|
||||
return () => {
|
||||
rollback()
|
||||
}
|
||||
}, [setGlobalScope])
|
||||
useOnClickOutside(ref as RefObject<HTMLElement>, () => {
|
||||
if (currentKeys.length > 0) {
|
||||
onChange(currentKeys)
|
||||
}
|
||||
onBlur()
|
||||
})
|
||||
return (
|
||||
<div
|
||||
className="text-text-secondary flex h-full items-center justify-center px-1 text-xs"
|
||||
tabIndex={-1}
|
||||
role="textbox"
|
||||
ref={ref}
|
||||
>
|
||||
{currentKeys.length > 0 ? (
|
||||
<div className="pr-4">
|
||||
<KbdCombined kbdProps={{ wrapButton: false }} joint={false}>
|
||||
{currentKeys.join("+")}
|
||||
</KbdCombined>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-text-secondary pr-4">Press keys to record</span>
|
||||
)}
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-text absolute inset-y-0 right-0 z-[1] flex items-center justify-center px-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (currentKeys.length === 0) {
|
||||
onChange(null)
|
||||
} else {
|
||||
onBlur()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{currentKeys.length > 0 ? (
|
||||
<i className="i-mingcute-close-circle-fill size-4" />
|
||||
) : (
|
||||
<FamiconsArrowUndoCircle className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{currentKeys.length > 0 ? "Undo" : "Reset"}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FamiconsArrowUndoCircle(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 512 512"
|
||||
{...props}
|
||||
>
|
||||
{/* Icon from Famicons by Family - https://github.com/familyjs/famicons/blob/main/LICENSE */}
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M256 48C141.13 48 48 141.13 48 256s93.13 208 208 208s208-93.13 208-208S370.87 48 256 48m97.67 281.1c-24.07-25.21-51.51-38.68-108.58-38.68v37.32a8.32 8.32 0 0 1-14.05 6L146.58 254a8.2 8.2 0 0 1 0-11.94L231 162.29a8.32 8.32 0 0 1 14.05 6v37.32c88.73 0 117.42 55.64 122.87 117.09c.73 7.72-8.85 12.05-14.25 6.4"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandShortcutItem = memo(({ commandId }: { commandId: FollowCommandId }) => {
|
||||
const command = useCommand(commandId)
|
||||
const commandShortcuts = useCommandShortcuts()
|
||||
|
||||
if (!command) return null
|
||||
return (
|
||||
<div className={"odd:bg-fill-quinary flex h-9 items-center justify-between px-3 py-1.5"}>
|
||||
<div>{command.label.title}</div>
|
||||
<div>
|
||||
<KbdCombined joint>{commandShortcuts[commandId]}</KbdCombined>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
///////
|
||||
|
||||
const MODIFIER_KEYS_MAP = {
|
||||
Control: "Control",
|
||||
Alt: "Alt",
|
||||
Shift: "Shift",
|
||||
Meta: "Meta",
|
||||
} as const
|
||||
|
||||
const MODIFIER_KEYS_SET = new Set<string>(Object.values(MODIFIER_KEYS_MAP))
|
||||
|
||||
const F_KEY_REGEX = /^F(?:[1-9]|1[0-2])$/
|
||||
|
||||
function getKeySortValue(key: string): number {
|
||||
if (key === MODIFIER_KEYS_MAP.Meta) return 0
|
||||
if (key === MODIFIER_KEYS_MAP.Control) return 1
|
||||
if (key === MODIFIER_KEYS_MAP.Alt) return 2
|
||||
if (key === MODIFIER_KEYS_MAP.Shift) return 3
|
||||
if (F_KEY_REGEX.test(key)) return 4
|
||||
return 5
|
||||
}
|
||||
|
||||
function sortShortcutKeys(keys: string[]): string[] {
|
||||
return [...keys].sort((a, b) => {
|
||||
const sortValueA = getKeySortValue(a)
|
||||
const sortValueB = getKeySortValue(b)
|
||||
if (sortValueA !== sortValueB) {
|
||||
return sortValueA - sortValueB
|
||||
}
|
||||
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
}
|
||||
|
||||
const useShortcutRecorder = () => {
|
||||
const [currentKeys, setCurrentKeys] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.stopImmediatePropagation()
|
||||
|
||||
const { altKey, ctrlKey, metaKey, shiftKey, key: eventKey } = event
|
||||
|
||||
let mainKeyPressed = eventKey
|
||||
|
||||
if (mainKeyPressed.length === 1 && mainKeyPressed >= "a" && mainKeyPressed <= "z") {
|
||||
mainKeyPressed = mainKeyPressed.toUpperCase()
|
||||
} else if (mainKeyPressed === " ") {
|
||||
mainKeyPressed = "Space"
|
||||
}
|
||||
|
||||
const pressedKeysSet = new Set<string>()
|
||||
|
||||
// 添加修饰键
|
||||
if (metaKey) pressedKeysSet.add(MODIFIER_KEYS_MAP.Meta)
|
||||
if (ctrlKey) pressedKeysSet.add(MODIFIER_KEYS_MAP.Control)
|
||||
if (altKey) pressedKeysSet.add(MODIFIER_KEYS_MAP.Alt)
|
||||
if (shiftKey) pressedKeysSet.add(MODIFIER_KEYS_MAP.Shift)
|
||||
|
||||
// If mainKeyPressed (from event.key) is not a modifier key, add it as the main key.
|
||||
// If mainKeyPressed is a modifier key (e.g., user only pressed Shift key, event.key is "Shift"),
|
||||
// it has already been handled and added to pressedKeysSet by the above if (shiftKey) logic,
|
||||
// so we don't need to add it again here.
|
||||
if (!MODIFIER_KEYS_SET.has(mainKeyPressed)) {
|
||||
pressedKeysSet.add(mainKeyPressed)
|
||||
}
|
||||
|
||||
const currentCombination = Array.from(pressedKeysSet)
|
||||
|
||||
// --- Start validation rules ---
|
||||
const nonModifierKeysInCombo = currentCombination.filter((key) => !MODIFIER_KEYS_SET.has(key))
|
||||
|
||||
// Rule 2: Pure modifier key combinations are not allowed (e.g., just Shift, or Ctrl+Alt)
|
||||
if (nonModifierKeysInCombo.length === 0) {
|
||||
// When only modifier keys are pressed, currentCombination will still contain these modifiers.
|
||||
// For example, pressing only Shift, currentCombination is ["Shift"]
|
||||
// Here we don't update the state, indicating this is an invalid recording.
|
||||
// You can provide temporary UI feedback here, e.g.: "Recording: Shift"
|
||||
console.info(
|
||||
"Recording (invalid - modifiers only):",
|
||||
sortShortcutKeys(currentCombination).join(" + "),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Typically shortcuts have only one "main" function key (e.g., Ctrl+A, Shift+F1)
|
||||
// If multiple non-modifier keys are detected (e.g., theoretically user pressing A and B simultaneously),
|
||||
// this is usually not a standard shortcut recording scenario
|
||||
// This check is mainly for code robustness, as `keydown` events typically focus on one main key at a time.
|
||||
if (nonModifierKeysInCombo.length > 1) {
|
||||
console.warn(
|
||||
"Recording (invalid - multiple main keys, this shouldn't normally happen):",
|
||||
sortShortcutKeys(currentCombination).join(" + "),
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const primaryKey = nonModifierKeysInCombo[0]
|
||||
|
||||
// Rule 3: Fn keys (F1-F12) can be single keys or modifier+Fn key combinations
|
||||
if (F_KEY_REGEX.test(primaryKey ?? "")) {
|
||||
setCurrentKeys(sortShortcutKeys(currentCombination))
|
||||
return
|
||||
}
|
||||
|
||||
// Rule 1: Single "ASCII" main keys are allowed (here referring to all non-modifier, non-F keys)
|
||||
// Examples: A, 1, Space, Enter, ArrowUp, etc. They can be used alone or with modifiers.
|
||||
// For these keys, as long as they're not pure modifier combinations, they're considered valid.
|
||||
setCurrentKeys(sortShortcutKeys(currentCombination))
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown)
|
||||
}
|
||||
}, [setCurrentKeys])
|
||||
return { currentKeys }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,8 @@ import type { ReactNode } from "react"
|
|||
|
||||
import type { BasicCommand } from "./commands/types"
|
||||
|
||||
export type CommandCategory =
|
||||
| "follow:settings"
|
||||
| "follow:layout"
|
||||
| "follow:updates"
|
||||
| "follow:help"
|
||||
| "follow:general"
|
||||
| "follow:entry-render"
|
||||
type ExtractCategory<T extends string> = T extends `category.${string}` ? T : never
|
||||
export type CommandCategory = ExtractCategory<Parameters<typeof tShortcuts>[0]>
|
||||
|
||||
export interface KeybindingOptions {
|
||||
binding: string
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { Focusable } from "@follow/components/common/Focusable/index.js"
|
||||
import { useMobile } from "@follow/components/hooks/useMobile.js"
|
||||
import { FeedViewType, views } from "@follow/constants"
|
||||
import { useTitle } from "@follow/hooks"
|
||||
|
|
@ -7,8 +6,9 @@ import type { Range, Virtualizer } from "@tanstack/react-virtual"
|
|||
import { memo, useCallback, useEffect, useRef } from "react"
|
||||
|
||||
import { useGeneralSettingKey } from "~/atoms/settings/general"
|
||||
import { Focusable } from "~/components/common/Focusable"
|
||||
import { FeedNotFound } from "~/components/errors/FeedNotFound"
|
||||
import { FEED_COLLECTION_LIST, ROUTE_FEED_PENDING } from "~/constants"
|
||||
import { FEED_COLLECTION_LIST, HotkeyScope, ROUTE_FEED_PENDING } from "~/constants"
|
||||
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
|
||||
import { useRouteParams, useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
||||
import { useFeedQuery } from "~/queries/feed"
|
||||
|
|
@ -118,6 +118,7 @@ function EntryColumnImpl() {
|
|||
const ListComponent = views[view]!.gridMode ? EntryColumnGrid : EntryList
|
||||
return (
|
||||
<Focusable
|
||||
scope={HotkeyScope.Timeline}
|
||||
data-hide-in-print
|
||||
className="@container relative flex h-full flex-1 flex-col"
|
||||
onClick={
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { useEntryIsRead } from "~/hooks/biz/useAsRead"
|
|||
import { useEntryActions } from "~/hooks/biz/useEntryActions"
|
||||
import { useFeedActions } from "~/hooks/biz/useFeedActions"
|
||||
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
|
||||
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
||||
import { getRouteParams, useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
||||
import { useContextMenu } from "~/hooks/common/useContextMenu"
|
||||
import { COMMAND_ID } from "~/modules/command/commands/id"
|
||||
import type { FlatEntryModel } from "~/store/entry"
|
||||
|
|
@ -70,17 +70,20 @@ export const EntryItemWrapper: FC<
|
|||
(e) => {
|
||||
e.stopPropagation()
|
||||
|
||||
const shouldNavigate = getRouteParams().entryId !== entry.entries.id
|
||||
if (!shouldNavigate) return
|
||||
if (!asRead) {
|
||||
entryActions.markRead({ feedId: entry.feedId, entryId: entry.entries.id, read: true })
|
||||
}
|
||||
navigate({
|
||||
entryId: entry.entries.id,
|
||||
})
|
||||
|
||||
setTimeout(
|
||||
() => EventBus.dispatch(COMMAND_ID.layout.focusToEntryRender, { highlightBoundary: false }),
|
||||
60,
|
||||
)
|
||||
|
||||
navigate({
|
||||
entryId: entry.entries.id,
|
||||
})
|
||||
},
|
||||
[asRead, entry.entries.id, entry.feedId, navigate],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import {
|
||||
Focusable,
|
||||
useFocusable,
|
||||
useFocusActions,
|
||||
useGlobalFocusableScope,
|
||||
} from "@follow/components/common/Focusable/index.js"
|
||||
import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDangerousHTMLStyle.js"
|
||||
import { Spring } from "@follow/components/constants/spring.js"
|
||||
|
|
@ -23,15 +22,14 @@ import { useEffect, useMemo, useRef, useState } from "react"
|
|||
|
||||
import { useEntryIsInReadability } from "~/atoms/readability"
|
||||
import { useIsZenMode, useUISettingKey } from "~/atoms/settings/ui"
|
||||
import { Focusable } from "~/components/common/Focusable"
|
||||
import { ShadowDOM } from "~/components/common/ShadowDOM"
|
||||
import type { TocRef } from "~/components/ui/markdown/components/Toc"
|
||||
import { useInPeekModal } from "~/components/ui/modal/inspire/InPeekModal"
|
||||
import { HotkeyScope } from "~/constants"
|
||||
import { useRenderStyle } from "~/hooks/biz/useRenderStyle"
|
||||
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
||||
import { useConditionalHotkeyScope } from "~/hooks/common"
|
||||
import { useFeedSafeUrl } from "~/hooks/common/useFeedSafeUrl"
|
||||
import { useHotkeyScope } from "~/providers/hotkey-provider"
|
||||
import { WrappedElementProvider } from "~/providers/wrapped-element-provider"
|
||||
import { useEntry } from "~/store/entry"
|
||||
import { useFeedById } from "~/store/feed"
|
||||
|
|
@ -94,7 +92,6 @@ export const EntryContent: Component<EntryContentProps> = ({
|
|||
|
||||
const isInPeekModal = useInPeekModal()
|
||||
|
||||
const [isUserInteraction, setIsUserInteraction] = useState(false)
|
||||
const isZenMode = useIsZenMode()
|
||||
|
||||
const [panelPortalElement, setPanelPortalElement] = useState<HTMLDivElement | null>(null)
|
||||
|
|
@ -130,16 +127,11 @@ export const EntryContent: Component<EntryContentProps> = ({
|
|||
<div className="w-full" ref={setPanelPortalElement} />
|
||||
|
||||
<Focusable
|
||||
scope={HotkeyScope.EntryRender}
|
||||
className="@container relative flex size-full flex-col overflow-hidden print:size-auto print:overflow-visible"
|
||||
onFocus={() => setIsUserInteraction(true)}
|
||||
>
|
||||
<RootPortal to={panelPortalElement}>
|
||||
<RegisterCommands
|
||||
scrollAnimationRef={scrollAnimationRef}
|
||||
scrollerRef={scrollerRef}
|
||||
isUserInteraction={isUserInteraction}
|
||||
setIsUserInteraction={setIsUserInteraction}
|
||||
/>
|
||||
<RegisterCommands scrollAnimationRef={scrollAnimationRef} scrollerRef={scrollerRef} />
|
||||
</RootPortal>
|
||||
<EntryTimelineSidebar entryId={entry.entries.id} />
|
||||
<EntryScrollArea className={className} scrollerRef={scrollerRef}>
|
||||
|
|
@ -178,8 +170,6 @@ export const EntryContent: Component<EntryContentProps> = ({
|
|||
)}
|
||||
|
||||
<article
|
||||
tabIndex={-1}
|
||||
onFocus={() => setIsUserInteraction(true)}
|
||||
data-testid="entry-render"
|
||||
onContextMenu={stopPropagation}
|
||||
className="@[950px]:max-w-[70ch] @7xl:max-w-[80ch] relative m-auto min-w-0 max-w-[550px]"
|
||||
|
|
@ -318,23 +308,17 @@ const Renderer: React.FC<{
|
|||
|
||||
const RegisterCommands = ({
|
||||
scrollerRef,
|
||||
isUserInteraction,
|
||||
setIsUserInteraction,
|
||||
scrollAnimationRef,
|
||||
}: {
|
||||
scrollerRef: React.RefObject<HTMLDivElement | null>
|
||||
isUserInteraction: boolean
|
||||
setIsUserInteraction: (isUserInteraction: boolean) => void
|
||||
|
||||
scrollAnimationRef: React.RefObject<JSAnimation<any> | null>
|
||||
}) => {
|
||||
const isAlreadyScrolledBottomRef = useRef(false)
|
||||
const [showKeepScrollingPanel, setShowKeepScrollingPanel] = useState(false)
|
||||
|
||||
const containerFocused = useFocusable()
|
||||
useConditionalHotkeyScope(HotkeyScope.EntryRender, isUserInteraction && containerFocused, true)
|
||||
|
||||
const activeScope = useHotkeyScope()
|
||||
const when = activeScope.includes(HotkeyScope.EntryRender)
|
||||
const activeScope = useGlobalFocusableScope()
|
||||
const when = activeScope.has(HotkeyScope.EntryRender)
|
||||
|
||||
useCommandBinding({
|
||||
commandId: COMMAND_ID.entryRender.scrollUp,
|
||||
|
|
@ -437,11 +421,10 @@ const RegisterCommands = ({
|
|||
if (highlight) {
|
||||
nextFrame(highlightBoundary)
|
||||
}
|
||||
setIsUserInteraction(true)
|
||||
},
|
||||
),
|
||||
)
|
||||
}, [highlightBoundary, scrollAnimationRef, scrollerRef, setIsUserInteraction])
|
||||
}, [highlightBoundary, scrollAnimationRef, scrollerRef])
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
|
|
|
|||
|
|
@ -1,26 +1,21 @@
|
|||
import { MotionButtonBase } from "@follow/components/ui/button/index.js"
|
||||
import { KbdCombined } from "@follow/components/ui/kbd/Kbd.js"
|
||||
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
|
||||
import { clsx, cn } from "@follow/utils/utils"
|
||||
import { clsx } from "@follow/utils/utils"
|
||||
import { m, useDragControls } from "motion/react"
|
||||
import { useCallback, useEffect } from "react"
|
||||
import { useCallback } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { useUISettingKey } from "~/atoms/settings/ui"
|
||||
import { PlainModal } from "~/components/ui/modal/stacked/custom-modal"
|
||||
import { useCurrentModal, useModalStack } from "~/components/ui/modal/stacked/hooks"
|
||||
import { shortcuts, shortcutsType } from "~/constants/shortcuts"
|
||||
import { useSwitchHotKeyScope } from "~/hooks/common"
|
||||
|
||||
import { ShortcutsGuideline } from "../command/shortcuts/SettingShortcuts"
|
||||
|
||||
const ShortcutModalContent = () => {
|
||||
const { dismiss } = useCurrentModal()
|
||||
const modalOverlay = useUISettingKey("modalOverlay")
|
||||
const dragControls = useDragControls()
|
||||
|
||||
const switchScope = useSwitchHotKeyScope()
|
||||
useEffect(() => {
|
||||
switchScope("Home")
|
||||
}, [])
|
||||
const { t } = useTranslation("shortcuts")
|
||||
return (
|
||||
<m.div
|
||||
|
|
@ -52,32 +47,8 @@ const ShortcutModalContent = () => {
|
|||
<i className="i-mgc-close-cute-re" />
|
||||
</MotionButtonBase>
|
||||
<ScrollArea.ScrollArea scrollbarClassName="w-2" rootClassName="w-full h-full">
|
||||
<div className="w-full space-y-6 px-4 pb-5 pt-6">
|
||||
{Object.keys(shortcuts).map((type) => (
|
||||
<section key={type}>
|
||||
<div className="text-text-secondary mb-2 pl-3 text-xs font-medium capitalize">
|
||||
{t(shortcutsType[type])}
|
||||
</div>
|
||||
<div className="text-text rounded-md border text-[13px]">
|
||||
{Object.keys(shortcuts[type]).map((action, index) => (
|
||||
<div
|
||||
key={`${type}-${action}`}
|
||||
className={cn(
|
||||
"flex h-9 items-center justify-between px-3 py-1.5",
|
||||
index % 2 && "bg-fill-quinary",
|
||||
)}
|
||||
>
|
||||
<div>{t(shortcuts[type][action].name)}</div>
|
||||
<div>
|
||||
<KbdCombined joint>
|
||||
{`${shortcuts[type][action].key}${shortcuts[type][action].extra ? `, ${shortcuts[type][action].extra}` : ""}`}
|
||||
</KbdCombined>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
<div className="w-full space-y-6 px-4 pb-5">
|
||||
<ShortcutsGuideline />
|
||||
</div>
|
||||
</ScrollArea.ScrollArea>
|
||||
</m.div>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -105,7 +105,9 @@ export const SettingTabbedSegment: Component<{
|
|||
}
|
||||
|
||||
export const SettingDescription: Component = ({ children, className }) => (
|
||||
<small className={cn("text-text-secondary text-body block w-4/5", className)}>{children}</small>
|
||||
<small className={cn("text-text-secondary text-body block w-4/5 leading-snug", className)}>
|
||||
{children}
|
||||
</small>
|
||||
)
|
||||
|
||||
export const SettingActionItem = ({
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useDraggable } from "@dnd-kit/core"
|
||||
import {
|
||||
useFocusable,
|
||||
useFocusableContainerRef,
|
||||
useFocusActions,
|
||||
useGlobalFocusableScopeSelector,
|
||||
} from "@follow/components/common/Focusable/hooks.js"
|
||||
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
|
||||
import { nextFrame } from "@follow/utils/dom"
|
||||
|
|
@ -13,6 +13,7 @@ import { useTranslation } from "react-i18next"
|
|||
import Selecto from "react-selecto"
|
||||
import { useEventCallback, useEventListener } from "usehooks-ts"
|
||||
|
||||
import { FocusablePresets } from "~/components/common/Focusable"
|
||||
import { useRouteParams } from "~/hooks/biz/useRouteParams"
|
||||
import { useAuthQuery } from "~/hooks/common"
|
||||
import { Queries } from "~/queries"
|
||||
|
|
@ -37,12 +38,12 @@ import {
|
|||
} from "./atom"
|
||||
import { DraggableContext } from "./context"
|
||||
import { FeedItem, ListItemAutoHideUnread } from "./FeedItem"
|
||||
import type { FeedListProps } from "./FeedList"
|
||||
import { EmptyFeedList, ListHeader, StarredItem } from "./FeedList.shared"
|
||||
import { useShouldFreeUpSpace } from "./hook"
|
||||
import { SortableFeedList, SortByAlphabeticalInbox, SortByAlphabeticalList } from "./sort-by"
|
||||
import type { SubscriptionProps } from "./SubscriptionList"
|
||||
import { EmptyFeedList, ListHeader, StarredItem } from "./SubscriptionList.shared"
|
||||
|
||||
const FeedListImpl = ({ ref, className, view }: FeedListProps) => {
|
||||
const SubscriptionImpl = ({ ref, className, view }: SubscriptionProps) => {
|
||||
const feedsData = useFeedsGroupedData(view)
|
||||
const listsData = useListsGroupedData(view)
|
||||
const inboxesData = useInboxesGroupedData(view)
|
||||
|
|
@ -229,6 +230,7 @@ const FeedListImpl = ({ ref, className, view }: FeedListProps) => {
|
|||
/>
|
||||
|
||||
<ScrollArea.ScrollArea
|
||||
focusable={false}
|
||||
ref={scrollerRef}
|
||||
onScroll={() => {
|
||||
selectoRef.current?.checkScroll()
|
||||
|
|
@ -295,36 +297,38 @@ const FeedListImpl = ({ ref, className, view }: FeedListProps) => {
|
|||
)
|
||||
}
|
||||
|
||||
FeedListImpl.displayName = "FeedListImpl"
|
||||
SubscriptionImpl.displayName = "FeedListImpl"
|
||||
|
||||
export const FeedList = memo(FeedListImpl)
|
||||
export const SubscriptionList = memo(SubscriptionImpl)
|
||||
|
||||
const FeedCategoryPrefix = "feed-category-"
|
||||
|
||||
const useRegisterCommand = () => {
|
||||
const isFocus = useFocusable()
|
||||
const focusableContainerRef = useFocusableContainerRef()
|
||||
|
||||
const focusActions = useFocusActions()
|
||||
|
||||
const inSubscriptionScope = useGlobalFocusableScopeSelector(FocusablePresets.isSubscriptionList)
|
||||
|
||||
useCommandBinding({
|
||||
commandId: COMMAND_ID.subscription.nextSubscription,
|
||||
when: isFocus,
|
||||
when: inSubscriptionScope,
|
||||
})
|
||||
|
||||
useCommandBinding({
|
||||
commandId: COMMAND_ID.subscription.previousSubscription,
|
||||
when: isFocus,
|
||||
when: inSubscriptionScope,
|
||||
})
|
||||
|
||||
useCommandHotkey({
|
||||
commandId: COMMAND_ID.layout.focusToTimeline,
|
||||
when: isFocus,
|
||||
when: inSubscriptionScope,
|
||||
shortcut: "Enter, L, ArrowRight",
|
||||
})
|
||||
|
||||
useCommandBinding({
|
||||
commandId: COMMAND_ID.subscription.toggleFolderCollapse,
|
||||
when: isFocus,
|
||||
when: inSubscriptionScope,
|
||||
})
|
||||
|
||||
const getCurrentActiveSubscriptionElement = useEventCallback(() => {
|
||||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { withResponsiveSyncComponent } from "@follow/components/utils/selector.js"
|
||||
|
||||
import { SubscriptionList as FeedListDesktop } from "./SubscriptionList.electron"
|
||||
import { SubscriptionList as FeedListMobile } from "./SubscriptionList.mobile"
|
||||
|
||||
export const SubscriptionList = withResponsiveSyncComponent(FeedListDesktop, FeedListMobile)
|
||||
|
||||
export type SubscriptionProps = ComponentType<
|
||||
{ className?: string; view: number } & {
|
||||
ref?: React.Ref<HTMLDivElement | null> | ((node: HTMLDivElement | null) => void)
|
||||
}
|
||||
>
|
||||
|
|
@ -2,11 +2,11 @@ import type { FeedViewType } from "@follow/constants"
|
|||
|
||||
import { ROUTE_TIMELINE_OF_VIEW } from "~/constants"
|
||||
|
||||
import { FeedList } from "./FeedList"
|
||||
import { SubscriptionList } from "./SubscriptionList"
|
||||
|
||||
export default function TimelineList({ timelineId }: { timelineId: string }) {
|
||||
if (timelineId.startsWith(ROUTE_TIMELINE_OF_VIEW)) {
|
||||
const id = Number.parseInt(timelineId.slice(ROUTE_TIMELINE_OF_VIEW.length), 10) as FeedViewType
|
||||
return <FeedList className="flex size-full flex-col text-sm" view={id} />
|
||||
return <SubscriptionList className="flex size-full flex-col text-sm" view={id} />
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useFocusable } from "@follow/components/common/Focusable/hooks.js"
|
||||
import { useGlobalFocusableScope } from "@follow/components/common/Focusable/hooks.js"
|
||||
import { ActionButton } from "@follow/components/ui/button/index.js"
|
||||
import { RootPortal } from "@follow/components/ui/portal/index.js"
|
||||
import { Routes } from "@follow/constants"
|
||||
|
|
@ -17,13 +17,12 @@ import { useLocation } from "react-router"
|
|||
import { useRootContainerElement } from "~/atoms/dom"
|
||||
import { useUISettingKey } from "~/atoms/settings/ui"
|
||||
import { setTimelineColumnShow, useTimelineColumnShow } from "~/atoms/sidebar"
|
||||
import { Focusable } from "~/components/common/Focusable"
|
||||
import { HotkeyScope } from "~/constants"
|
||||
import { navigateEntry, useBackHome } from "~/hooks/biz/useNavigateEntry"
|
||||
import { useReduceMotion } from "~/hooks/biz/useReduceMotion"
|
||||
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
||||
import { useTimelineList } from "~/hooks/biz/useTimelineList"
|
||||
import { useConditionalHotkeyScope } from "~/hooks/common"
|
||||
import { useHotkeyScope } from "~/providers/hotkey-provider"
|
||||
|
||||
import { WindowUnderBlur } from "../../components/ui/background"
|
||||
import { COMMAND_ID } from "../command/commands/id"
|
||||
|
|
@ -115,11 +114,12 @@ export function FeedColumn({ children, className }: PropsWithChildren<{ classNam
|
|||
|
||||
return (
|
||||
<WindowUnderBlur
|
||||
as={Focusable}
|
||||
scope={HotkeyScope.SubscriptionList}
|
||||
data-hide-in-print
|
||||
className={cn(
|
||||
"relative flex h-full flex-col pt-2.5",
|
||||
|
||||
!feedColumnShow && ELECTRON_BUILD && "bg-material-opaque",
|
||||
"relative flex h-full flex-col pt-2.5",
|
||||
className,
|
||||
)}
|
||||
ref={focusableContainerRef}
|
||||
|
|
@ -243,9 +243,12 @@ const CommandsHandler = ({
|
|||
setActive: (args: string | ((prev: string | undefined, index: number) => string)) => void
|
||||
timelineList: string[]
|
||||
}) => {
|
||||
const activeScope = useHotkeyScope()
|
||||
const activeScope = useGlobalFocusableScope()
|
||||
const when =
|
||||
activeScope.includes(HotkeyScope.SubscriptionList) || activeScope.includes(HotkeyScope.Timeline)
|
||||
activeScope.has(HotkeyScope.SubscriptionList) ||
|
||||
activeScope.has(HotkeyScope.Timeline) ||
|
||||
activeScope.size === 0
|
||||
|
||||
useCommandBinding({
|
||||
commandId: COMMAND_ID.subscription.switchTabToNext,
|
||||
when,
|
||||
|
|
@ -268,8 +271,5 @@ const CommandsHandler = ({
|
|||
})
|
||||
}, [activeScope, setActive, timelineList])
|
||||
|
||||
const focus = useFocusable()
|
||||
|
||||
useConditionalHotkeyScope(HotkeyScope.SubscriptionList, focus, true)
|
||||
return null
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { withResponsiveSyncComponent } from "@follow/components/utils/selector.js"
|
||||
|
||||
import { FeedList as FeedListDesktop } from "./FeedList.electron"
|
||||
import { FeedList as FeedListMobile } from "./FeedList.mobile"
|
||||
|
||||
export const FeedList = withResponsiveSyncComponent(FeedListDesktop, FeedListMobile)
|
||||
|
||||
export type FeedListProps = ComponentType<
|
||||
{ className?: string; view: number } & {
|
||||
ref?: React.Ref<HTMLDivElement | null> | ((node: HTMLDivElement | null) => void)
|
||||
}
|
||||
>
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import { SettingShortcuts } from "~/modules/command/shortcuts/SettingShortcuts"
|
||||
import { isMobile } from "@follow/components/hooks/useMobile.js"
|
||||
|
||||
import { ShortcutSetting } from "~/modules/command/shortcuts/SettingShortcuts"
|
||||
import { SettingsTitle } from "~/modules/settings/title"
|
||||
import { defineSettingPageData } from "~/modules/settings/utils"
|
||||
|
||||
|
|
@ -9,12 +11,13 @@ export const loader = defineSettingPageData({
|
|||
icon: iconName,
|
||||
name: "titles.shortcuts",
|
||||
priority,
|
||||
hideIf: () => isMobile(),
|
||||
})
|
||||
export function Component() {
|
||||
return (
|
||||
<>
|
||||
<SettingsTitle />
|
||||
<SettingShortcuts />
|
||||
<ShortcutSetting />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useGlobalFocusableHasScope } from "@follow/components/common/Focusable/hooks.js"
|
||||
import { useMobile } from "@follow/components/hooks/useMobile.js"
|
||||
import { KbdCombined } from "@follow/components/ui/kbd/Kbd.js"
|
||||
import { nextFrame, preventDefault } from "@follow/utils/dom"
|
||||
|
|
@ -25,7 +26,6 @@ import {
|
|||
ContextMenuTrigger,
|
||||
} from "~/components/ui/context-menu"
|
||||
import { HotkeyScope } from "~/constants"
|
||||
import { useSwitchHotKeyScope } from "~/hooks/common"
|
||||
|
||||
export const ContextMenuProvider: Component = ({ children }) => (
|
||||
<>
|
||||
|
|
@ -38,16 +38,6 @@ const Handler = () => {
|
|||
const ref = useRef<HTMLSpanElement>(null)
|
||||
const [contextMenuState, setContextMenuState] = useContextMenuState()
|
||||
|
||||
const switchHotkeyScope = useSwitchHotKeyScope()
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenuState.open) return
|
||||
switchHotkeyScope("Menu")
|
||||
return () => {
|
||||
switchHotkeyScope("Home")
|
||||
}
|
||||
}, [contextMenuState.open, switchHotkeyScope])
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenuState.open) return
|
||||
const triggerElement = ref.current
|
||||
|
|
@ -111,10 +101,13 @@ const Item = memo(({ item }: { item: FollowMenuItem }) => {
|
|||
}
|
||||
}, [item])
|
||||
const itemRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useHotkeys((item as any as MenuItemText).shortcut!, () => itemRef.current?.click(), {
|
||||
// enabled: item.enabled !== false && item.shortcut !== undefined,
|
||||
enabled: item instanceof MenuItemText && !!item.shortcut,
|
||||
scopes: HotkeyScope.Menu,
|
||||
enabled:
|
||||
useGlobalFocusableHasScope(HotkeyScope.Menu) &&
|
||||
item instanceof MenuItemText &&
|
||||
!!item.shortcut,
|
||||
|
||||
preventDefault: true,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -1,42 +1,12 @@
|
|||
import type { PropsWithChildren } from "react"
|
||||
import { createContext, use, useEffect } from "react"
|
||||
import { HotkeysProvider, useHotkeysContext } from "react-hotkeys-hook"
|
||||
|
||||
import { HotkeyScope } from "~/constants"
|
||||
import { appLog } from "~/lib/log"
|
||||
import { HotkeysProvider } from "react-hotkeys-hook"
|
||||
|
||||
import { GlobalHotkeysProvider } from "./global-hotkeys-provider"
|
||||
|
||||
const initialActiveScopes = [HotkeyScope.Home]
|
||||
|
||||
const HotkeyScopeContext = createContext<HotkeyScope[]>(null!)
|
||||
export const HotkeyProvider: Component = ({ children }) => {
|
||||
return (
|
||||
<HotkeysProvider initiallyActiveScopes={initialActiveScopes}>
|
||||
<HotkeyScopeProvider>
|
||||
{children}
|
||||
<GlobalHotkeysProvider />
|
||||
</HotkeyScopeProvider>
|
||||
<HotkeysProvider>
|
||||
{children}
|
||||
<GlobalHotkeysProvider />
|
||||
</HotkeysProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const HotkeyScopeProvider = ({ children }: PropsWithChildren) => {
|
||||
const { activeScopes } = useHotkeysContext()
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useEffect(() => {
|
||||
appLog("activeScopes change to:", activeScopes)
|
||||
}, [JSON.stringify(activeScopes)])
|
||||
}
|
||||
return <HotkeyScopeContext value={activeScopes as HotkeyScope[]}>{children}</HotkeyScopeContext>
|
||||
}
|
||||
|
||||
export const useHotkeyScope = () => {
|
||||
const hotkeyScope = use(HotkeyScopeContext)
|
||||
if (!hotkeyScope) {
|
||||
throw new Error("HotkeyScopeContext not found")
|
||||
}
|
||||
return hotkeyScope
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { GlobalFocusableProvider } from "@follow/components/common/Focusable/GlobalFocusableProvider.js"
|
||||
import { MotionProvider } from "@follow/components/common/MotionProvider.jsx"
|
||||
import { EventProvider } from "@follow/components/providers/event-provider.js"
|
||||
import { StableRouterProvider } from "@follow/components/providers/stable-router-provider.js"
|
||||
|
|
@ -40,34 +41,36 @@ export const RootProviders: FC<PropsWithChildren> = ({ children }) => (
|
|||
<Provider store={jotaiStore}>
|
||||
<MotionProvider>
|
||||
<PersistQueryClientProvider persistOptions={persistConfig} client={queryClient}>
|
||||
<HotkeyProvider>
|
||||
<I18nProvider>
|
||||
<ModalStackProvider>
|
||||
<Toaster />
|
||||
<EventProvider />
|
||||
<GlobalFocusableProvider>
|
||||
<HotkeyProvider>
|
||||
<I18nProvider>
|
||||
<ModalStackProvider>
|
||||
<Toaster />
|
||||
<EventProvider />
|
||||
|
||||
<UserProvider />
|
||||
<ServerConfigsProvider />
|
||||
<UserProvider />
|
||||
<ServerConfigsProvider />
|
||||
|
||||
<StableRouterProvider />
|
||||
<SettingSync />
|
||||
<FollowCommandManager />
|
||||
<StableRouterProvider />
|
||||
<SettingSync />
|
||||
<FollowCommandManager />
|
||||
|
||||
{import.meta.env.DEV && <Devtools />}
|
||||
{import.meta.env.DEV && <Devtools />}
|
||||
|
||||
{children}
|
||||
{children}
|
||||
|
||||
<Suspense>
|
||||
<LazyExtensionExposeProvider />
|
||||
<LazyContextMenuProvider />
|
||||
<LazyLottieRenderContainer />
|
||||
<LazyExternalJumpInProvider />
|
||||
<LazyReloadPrompt />
|
||||
{!IN_ELECTRON && <LazyPWAPrompt />}
|
||||
</Suspense>
|
||||
</ModalStackProvider>
|
||||
</I18nProvider>
|
||||
</HotkeyProvider>
|
||||
<Suspense>
|
||||
<LazyExtensionExposeProvider />
|
||||
<LazyContextMenuProvider />
|
||||
<LazyLottieRenderContainer />
|
||||
<LazyExternalJumpInProvider />
|
||||
<LazyReloadPrompt />
|
||||
{!IN_ELECTRON && <LazyPWAPrompt />}
|
||||
</Suspense>
|
||||
</ModalStackProvider>
|
||||
</I18nProvider>
|
||||
</HotkeyProvider>
|
||||
</GlobalFocusableProvider>
|
||||
|
||||
<InvalidateQueryProvider />
|
||||
</PersistQueryClientProvider>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]),
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "ショートカット"
|
||||
}
|
||||
{}
|
||||
|
|
|
|||
|
|
@ -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": "快捷键"
|
||||
}
|
||||
{}
|
||||
|
|
|
|||
|
|
@ -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": "快捷鍵"
|
||||
}
|
||||
{}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,13 @@
|
|||
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"
|
||||
import * as React from "react"
|
||||
import {
|
||||
cloneElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useEventListener } from "usehooks-ts"
|
||||
|
||||
import {
|
||||
|
|
@ -7,12 +16,18 @@ import {
|
|||
FocusActionsContext,
|
||||
FocusTargetRefContext,
|
||||
} from "./context"
|
||||
import { useSetGlobalFocusableScope } from "./hooks"
|
||||
import { highlightElement } from "./utils"
|
||||
|
||||
export interface FocusableProps {
|
||||
scope?: string
|
||||
asChild?: boolean
|
||||
}
|
||||
export const Focusable: Component<
|
||||
React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
|
||||
> = ({ ref, ...props }) => {
|
||||
React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & FocusableProps
|
||||
> = ({ ref, scope, asChild, ...props }) => {
|
||||
const { onBlur, onFocus, ...rest } = props
|
||||
|
||||
const [isFocusWithIn, setIsFocusWithIn] = useState(false)
|
||||
const focusTargetRef = useRef<HTMLElement | undefined>(void 0)
|
||||
|
||||
|
|
@ -30,12 +45,38 @@ export const Focusable: Component<
|
|||
highlightElement(element)
|
||||
}, [])
|
||||
|
||||
const setGlobalFocusableScope = useSetGlobalFocusableScope()
|
||||
useEffect(() => {
|
||||
if (!scope) {
|
||||
return
|
||||
}
|
||||
|
||||
const $container = containerRef.current
|
||||
if (!$container) return
|
||||
|
||||
const focusIn = () => {
|
||||
setGlobalFocusableScope(scope, "append")
|
||||
}
|
||||
$container.addEventListener("focusin", focusIn)
|
||||
const focusOut = () => {
|
||||
setGlobalFocusableScope(scope, "remove")
|
||||
}
|
||||
$container.addEventListener("focusout", focusOut)
|
||||
|
||||
return () => {
|
||||
$container.removeEventListener("focusin", focusIn)
|
||||
$container.removeEventListener("focusout", focusOut)
|
||||
}
|
||||
}, [scope, setGlobalFocusableScope])
|
||||
|
||||
// highlight boundary
|
||||
useEventListener("focusin", (e) => {
|
||||
if (containerRef.current?.contains(e.target as Node)) {
|
||||
setIsFocusWithIn(true)
|
||||
focusTargetRef.current = e.target as HTMLElement
|
||||
if (import.meta.env.DEV) {
|
||||
highlightElement(containerRef.current!, "14, 165, 233")
|
||||
console.info("[Focusable] focusin", containerRef.current)
|
||||
}
|
||||
} else {
|
||||
setIsFocusWithIn(false)
|
||||
|
|
@ -47,15 +88,39 @@ export const Focusable: Component<
|
|||
setIsFocusWithIn(containerRef.current.contains(document.activeElement as Node))
|
||||
}, [containerRef])
|
||||
|
||||
if (asChild) {
|
||||
assertChildren(rest.children)
|
||||
}
|
||||
return (
|
||||
<FocusableContext value={isFocusWithIn}>
|
||||
<FocusTargetRefContext value={focusTargetRef}>
|
||||
<FocusActionsContext value={useMemo(() => ({ highlightBoundary }), [highlightBoundary])}>
|
||||
<FocusableContainerRefContext value={containerRef}>
|
||||
<div tabIndex={-1} role="region" ref={containerRef} {...rest} />
|
||||
{asChild ? (
|
||||
cloneElement(
|
||||
rest.children as React.ReactElement<React.HTMLAttributes<HTMLDivElement>>,
|
||||
{
|
||||
tabIndex: -1,
|
||||
role: "region",
|
||||
...rest,
|
||||
},
|
||||
)
|
||||
) : (
|
||||
<div tabIndex={-1} role="region" ref={containerRef} {...rest} />
|
||||
)}
|
||||
</FocusableContainerRefContext>
|
||||
</FocusActionsContext>
|
||||
</FocusTargetRefContext>
|
||||
</FocusableContext>
|
||||
)
|
||||
}
|
||||
|
||||
const assertChildren = (children: React.ReactNode) => {
|
||||
if (!children) {
|
||||
throw new Error("[Focusable] `asChild` must have a child")
|
||||
}
|
||||
const child = React.Children.count(children)
|
||||
if (child !== 1) {
|
||||
throw new Error("[Focusable] `asChild` must have exactly one child")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
import { jotaiStore } from "@follow/utils/jotai"
|
||||
import { atom } from "jotai"
|
||||
import type { PropsWithChildren } from "react"
|
||||
import { useEffect, useMemo } from "react"
|
||||
|
||||
import { GlobalFocusableContext } from "./context"
|
||||
|
||||
export const GlobalFocusableProvider = ({ children }: PropsWithChildren) => {
|
||||
const ctxValue = useMemo(() => {
|
||||
return atom(new Set<string>())
|
||||
}, [])
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useEffect(() => {
|
||||
return jotaiStore.sub(ctxValue, () => {
|
||||
const v = jotaiStore.get(ctxValue)
|
||||
console.info("[GlobalFocusableProvider] scope changed to:", v)
|
||||
})
|
||||
}, [ctxValue])
|
||||
}
|
||||
|
||||
return <GlobalFocusableContext value={ctxValue}>{children}</GlobalFocusableContext>
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import type { PrimitiveAtom } from "jotai"
|
||||
import { createContext } from "react"
|
||||
|
||||
export const FocusableContext = createContext(false)
|
||||
|
|
@ -8,3 +9,5 @@ export const FocusableContainerRefContext = createContext<React.RefObject<HTMLDi
|
|||
export const FocusActionsContext = createContext<{
|
||||
highlightBoundary: () => void
|
||||
}>(null!)
|
||||
|
||||
export const GlobalFocusableContext = createContext<PrimitiveAtom<Set<string>>>(null!)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
import { use } from "react"
|
||||
import { jotaiStore } from "@follow/utils/jotai"
|
||||
import { useAtomValue, useSetAtom } from "jotai"
|
||||
import { selectAtom } from "jotai/utils"
|
||||
import { use, useCallback, useMemo } from "react"
|
||||
|
||||
import {
|
||||
FocusableContainerRefContext,
|
||||
FocusableContext,
|
||||
FocusActionsContext,
|
||||
FocusTargetRefContext,
|
||||
GlobalFocusableContext,
|
||||
} from "./context"
|
||||
|
||||
export const useFocusable = () => {
|
||||
|
|
@ -22,3 +26,78 @@ export const useFocusActions = () => {
|
|||
export const useFocusableContainerRef = () => {
|
||||
return use(FocusableContainerRefContext)
|
||||
}
|
||||
|
||||
export const useGlobalFocusableScope = () => {
|
||||
return useAtomValue(use(GlobalFocusableContext))
|
||||
}
|
||||
|
||||
export const useGlobalFocusableHasScope = (scope: string) => {
|
||||
return useGlobalFocusableScopeSelector((v) => v.has(scope))
|
||||
}
|
||||
export const useGlobalFocusableScopeSelector = (selector: (scope: Set<string>) => boolean) => {
|
||||
const ctx = use(GlobalFocusableContext)
|
||||
return useAtomValue(useMemo(() => selectAtom(ctx, selector), [ctx, selector]))
|
||||
}
|
||||
|
||||
export const useSetGlobalFocusableScope = () => {
|
||||
const ctx = use(GlobalFocusableContext)
|
||||
const setter = useSetAtom(ctx)
|
||||
return useCallback(
|
||||
(scope: string, mode: "append" | "switch" | "remove") => {
|
||||
const snapshot = jotaiStore.get(ctx)
|
||||
setter((v) => {
|
||||
if (mode === "append") {
|
||||
if (v.has(scope)) {
|
||||
return v
|
||||
}
|
||||
const newSet = new Set(v)
|
||||
newSet.add(scope)
|
||||
return newSet
|
||||
} else if (mode === "switch") {
|
||||
const newSet = new Set(v)
|
||||
|
||||
if (newSet.has(scope)) {
|
||||
newSet.delete(scope)
|
||||
} else {
|
||||
newSet.add(scope)
|
||||
}
|
||||
return newSet
|
||||
} else {
|
||||
if (!v.has(scope)) return v
|
||||
const newSet = new Set(v)
|
||||
newSet.delete(scope)
|
||||
return newSet
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
original: snapshot,
|
||||
new: jotaiStore.get(ctx),
|
||||
}
|
||||
},
|
||||
[ctx, setter],
|
||||
)
|
||||
}
|
||||
|
||||
export const useReplaceGlobalFocusableScope = () => {
|
||||
const ctx = use(GlobalFocusableContext)
|
||||
const setter = useSetAtom(ctx)
|
||||
return useCallback(
|
||||
(...scopes: string[]) => {
|
||||
const snapshot = jotaiStore.get(ctx)
|
||||
setter(() => {
|
||||
const newSet = new Set<string>()
|
||||
for (const scope of scopes) {
|
||||
newSet.add(scope)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
return {
|
||||
rollback: () => {
|
||||
setter(snapshot)
|
||||
},
|
||||
}
|
||||
},
|
||||
[ctx, setter],
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,24 +51,31 @@ export const KbdCombined: FC<{
|
|||
children: string
|
||||
className?: string
|
||||
joint?: boolean
|
||||
}> = ({ children, joint, className }) => {
|
||||
kbdProps?: Partial<React.ComponentProps<typeof Kbd>>
|
||||
}> = ({ children, joint, className, kbdProps }) => {
|
||||
const keys = children.split(",")
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{keys.map((k, i) => (
|
||||
<Fragment key={k}>
|
||||
{joint ? (
|
||||
<Kbd className={className}>{k}</Kbd>
|
||||
<Kbd className={className} {...kbdProps}>
|
||||
{k}
|
||||
</Kbd>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
{k.split("+").map((key) => (
|
||||
<Kbd key={key} className={className}>
|
||||
<Kbd key={key} className={className} {...kbdProps}>
|
||||
{key}
|
||||
</Kbd>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{i !== keys.length - 1 && " / "}
|
||||
{i !== keys.length - 1 && (
|
||||
<span>
|
||||
<i className="i-mgc-line-cute-re text-text-secondary size-[0.75em] shrink-0 origin-center translate-y-[0.15em] rotate-[-25deg]" />
|
||||
</span>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -330,9 +337,9 @@ export const Kbd: FC<{ children: string; className?: string; wrapButton?: boolea
|
|||
const Kbd = (
|
||||
<kbd
|
||||
className={cn(
|
||||
"kbd text-text box-border h-5 space-x-1 font-sans text-[0.7rem]",
|
||||
"kbd text-text box-border h-5 space-x-1 font-sans text-[0.7rem] tabular-nums transition-[border] duration-200",
|
||||
|
||||
isKeyPressed ? "" : "border-b-2",
|
||||
wrapButton && (isKeyPressed ? "" : "border-b-2 hover:border-b"),
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
|
@ -383,7 +390,7 @@ export const Kbd: FC<{ children: string; className?: string; wrapButton?: boolea
|
|||
</kbd>
|
||||
)
|
||||
return wrapButton ? (
|
||||
<button type="button" onClick={handleClick}>
|
||||
<button type="button" className="contents" onClick={handleClick}>
|
||||
{Kbd}
|
||||
</button>
|
||||
) : (
|
||||
|
|
|
|||
Loading…
Reference in New Issue