From c20350f1657dd5ec28811c7a01f34f48f229f902 Mon Sep 17 00:00:00 2001 From: Innei Date: Tue, 27 May 2025 17:59:01 +0800 Subject: [PATCH] feat: enhance focus management and context menu shortcuts - Added new focusable presets for timeline and entry render states. - Introduced a custom hook `useContextMenuActionShortCutTrigger` to manage context menu shortcuts based on focusable states. - Updated various components to utilize the new focusable presets and context menu shortcuts for improved user interaction. Signed-off-by: Innei --- .../src/components/common/Focusable.tsx | 4 ++- .../useContextMenuActionShortCutTrigger.ts | 33 +++++++++++++++++++ .../app-layout/feed-column/desktop.tsx | 2 +- .../EntryColumnShortcutHandler.tsx | 4 +-- .../components/mark-all-button.tsx | 4 +-- .../entry-column/layouts/EntryItemWrapper.tsx | 6 ++++ .../entry-content/actions/header-actions.tsx | 8 +++-- .../modules/entry-content/index.electron.tsx | 7 ++-- .../modules/subscription-column/FeedItem.tsx | 14 ++++++++ .../src/modules/subscription-column/index.tsx | 4 +-- .../Focusable/GlobalFocusableProvider.tsx | 3 +- .../src/common/Focusable/context.ts | 3 +- .../components/src/common/Focusable/hooks.ts | 9 ++--- .../utils/src/data-structure/index.ts | 1 + .../internal/utils/src/data-structure/set.ts | 21 ++++++++++++ packages/internal/utils/src/index.ts | 1 + 16 files changed, 102 insertions(+), 22 deletions(-) create mode 100644 apps/desktop/layer/renderer/src/hooks/biz/useContextMenuActionShortCutTrigger.ts create mode 100644 packages/internal/utils/src/data-structure/index.ts create mode 100644 packages/internal/utils/src/data-structure/set.ts diff --git a/apps/desktop/layer/renderer/src/components/common/Focusable.tsx b/apps/desktop/layer/renderer/src/components/common/Focusable.tsx index 78adf3ef1..259c14f97 100644 --- a/apps/desktop/layer/renderer/src/components/common/Focusable.tsx +++ b/apps/desktop/layer/renderer/src/components/common/Focusable.tsx @@ -24,4 +24,6 @@ export const FocusablePresets = { isSubscriptionOrTimeline: (v: Set) => { return v.has(HotkeyScope.SubscriptionList) || v.has(HotkeyScope.Timeline) || v.size === 0 }, -} + isTimeline: (v) => v.has(HotkeyScope.Timeline) && !v.has(HotkeyScope.EntryRender), + isEntryRender: (v) => v.has(HotkeyScope.EntryRender), +} satisfies Record) => boolean> diff --git a/apps/desktop/layer/renderer/src/hooks/biz/useContextMenuActionShortCutTrigger.ts b/apps/desktop/layer/renderer/src/hooks/biz/useContextMenuActionShortCutTrigger.ts new file mode 100644 index 000000000..e9298e373 --- /dev/null +++ b/apps/desktop/layer/renderer/src/hooks/biz/useContextMenuActionShortCutTrigger.ts @@ -0,0 +1,33 @@ +import { checkIsEditableElement } from "@follow/utils" +import { useEffect } from "react" +import { tinykeys } from "tinykeys" + +import type { MenuItemInput } from "~/atoms/context-menu" +import { MenuItemText } from "~/atoms/context-menu" + +export const useContextMenuActionShortCutTrigger = (items: MenuItemInput[], when: boolean) => { + useEffect(() => { + if (!when) return + + const actionMap = items.reduce( + (acc, item) => { + if (item instanceof MenuItemText) { + if (!item.shortcut) return acc + acc[item.shortcut] = (event: KeyboardEvent) => { + if (checkIsEditableElement(event.target as HTMLElement)) return + event.preventDefault() + event.stopPropagation() + if (item.disabled) return + if (item.hide) return + item.click() + } + } + return acc + }, + + {} as Record void>, + ) + + return tinykeys(window, actionMap) + }, [items, when]) +} diff --git a/apps/desktop/layer/renderer/src/modules/app-layout/feed-column/desktop.tsx b/apps/desktop/layer/renderer/src/modules/app-layout/feed-column/desktop.tsx index 431e5cfbb..8b28b5be5 100644 --- a/apps/desktop/layer/renderer/src/modules/app-layout/feed-column/desktop.tsx +++ b/apps/desktop/layer/renderer/src/modules/app-layout/feed-column/desktop.tsx @@ -242,7 +242,7 @@ const FeedResponsiveResizerContainer = ({ useCommandBinding({ commandId: COMMAND_ID.layout.toggleSubscriptionColumn, - when: !FloatingLayerScope.some((scope) => activeScopes.has(scope)), + when: !activeScopes.or(...FloatingLayerScope), }) const [delayShowSplitter, setDelayShowSplitter] = useState(feedColumnShow) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx index 95f0c0412..43f362836 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx @@ -9,7 +9,7 @@ import { EventBus } from "@follow/utils/event-bus" import type { FC } from "react" import { memo, useEffect } from "react" -import { HotkeyScope } from "~/constants" +import { FocusablePresets } from "~/components/common/Focusable" import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry" import { useRouteEntryId } from "~/hooks/biz/useRouteParams" @@ -26,7 +26,7 @@ export const EntryColumnShortcutHandler: FC<{ const activeScope = useGlobalFocusableScope() - const when = activeScope.has(HotkeyScope.Timeline) && !activeScope.has(HotkeyScope.EntryRender) + const when = FocusablePresets.isTimeline(activeScope) useCommandBinding({ commandId: COMMAND_ID.timeline.switchToNext, diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/components/mark-all-button.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/components/mark-all-button.tsx index 579d654e1..cbfa4bc50 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/components/mark-all-button.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/components/mark-all-button.tsx @@ -36,9 +36,7 @@ export const MarkAllReadButton = ({ const activeScope = useGlobalFocusableScope() useCommandBinding({ commandId: COMMAND_ID.subscription.markAllAsRead, - when: [HotkeyScope.Timeline, HotkeyScope.SubscriptionList].some((scope) => - activeScope.has(scope), - ), + when: activeScope.or(HotkeyScope.Timeline, HotkeyScope.SubscriptionList), }) useEffect(() => { diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryItemWrapper.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryItemWrapper.tsx index 29baaeb1f..d90288586 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryItemWrapper.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryItemWrapper.tsx @@ -1,3 +1,4 @@ +import { useGlobalFocusableScope } from "@follow/components/common/Focusable/hooks.js" import { useMobile } from "@follow/components/hooks/useMobile.js" import type { FeedViewType } from "@follow/constants" import { views } from "@follow/constants" @@ -15,7 +16,9 @@ import { useShowContextMenu, } from "~/atoms/context-menu" import { useGeneralSettingKey } from "~/atoms/settings/general" +import { FocusablePresets } from "~/components/common/Focusable" import { useEntryIsRead } from "~/hooks/biz/useAsRead" +import { useContextMenuActionShortCutTrigger } from "~/hooks/biz/useContextMenuActionShortCutTrigger" import { useEntryActions } from "~/hooks/biz/useEntryActions" import { useFeedActions } from "~/hooks/biz/useFeedActions" import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry" @@ -34,6 +37,7 @@ export const EntryItemWrapper: FC< } & PropsWithChildren > = ({ entry, view, children, itemClassName, style }) => { const actionConfigs = useEntryActions({ entryId: entry.entries.id }) + const feedItems = useFeedActions({ feedId: entry.feedId || entry.inboxId, view, @@ -47,6 +51,8 @@ export const EntryItemWrapper: FC< ({ entryId }) => entryId === entry.entries.id, [entry.entries.id], ) + const scope = useGlobalFocusableScope() + useContextMenuActionShortCutTrigger(actionConfigs, isActive && FocusablePresets.isTimeline(scope)) const asRead = useEntryIsRead(entry) const hoverMarkUnread = useGeneralSettingKey("hoverMarkUnread") diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/actions/header-actions.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/actions/header-actions.tsx index 0255e0ef1..434bc768b 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/actions/header-actions.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/actions/header-actions.tsx @@ -1,6 +1,8 @@ +import { useGlobalFocusableScopeSelector } from "@follow/components/common/Focusable/hooks.js" import type { FeedViewType } from "@follow/constants" import { MenuItemText } from "~/atoms/context-menu" +import { FocusablePresets } from "~/components/common/Focusable" import { CommandActionButton } from "~/components/ui/button/CommandActionButton" import { useHasModal } from "~/components/ui/modal/stacked/hooks" import { useSortedEntryActions } from "~/hooks/biz/useEntryActions" @@ -22,8 +24,10 @@ export const EntryHeaderActions = ({ const hasModal = useHasModal() + const when = useGlobalFocusableScopeSelector(FocusablePresets.isEntryRender) + useCommandBinding({ - when: !!entry?.entries.url && !hasModal, + when: !!entry?.entries.url && !hasModal && when, commandId: COMMAND_ID.entry.openInBrowser, args: [{ entryId }], }) @@ -35,7 +39,7 @@ export const EntryHeaderActions = ({ view, }) + const scope = useGlobalFocusableScope() + + const whenTrigger = FocusablePresets.isSubscriptionList(scope) && isActive + useContextMenuActionShortCutTrigger(items, whenTrigger) + const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) const showContextMenu = useShowContextMenu() const contextMenuProps = useContextMenu({ @@ -252,6 +260,9 @@ const ListItemImpl: Component = ({ const isActive = useRouteParamsSelector((routerParams) => routerParams.listId === listId) const items = useListActions({ listId, view }) + const scope = useGlobalFocusableScope() + useContextMenuActionShortCutTrigger(items, FocusablePresets.isSubscriptionList(scope) && isActive) + const listUnread = useUnreadByListId(listId) const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) @@ -358,6 +369,9 @@ const InboxItemImpl: Component = ({ view, inboxId, className, ic const isActive = useRouteParamsSelector((routerParams) => routerParams.inboxId === inboxId) const { items } = useInboxActions({ inboxId }) + const scope = useGlobalFocusableScope() + useContextMenuActionShortCutTrigger(items, FocusablePresets.isSubscriptionList(scope) && isActive) + const inboxUnread = useUnreadById(inboxId) const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) diff --git a/apps/desktop/layer/renderer/src/modules/subscription-column/index.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/index.tsx index e3683b23b..2ae22046f 100644 --- a/apps/desktop/layer/renderer/src/modules/subscription-column/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/subscription-column/index.tsx @@ -245,9 +245,7 @@ const CommandsHandler = ({ }) => { const activeScope = useGlobalFocusableScope() const when = - activeScope.has(HotkeyScope.SubscriptionList) || - activeScope.has(HotkeyScope.Timeline) || - activeScope.size === 0 + activeScope.or(HotkeyScope.SubscriptionList, HotkeyScope.Timeline) || activeScope.size === 0 useCommandBinding({ commandId: COMMAND_ID.subscription.switchTabToNext, diff --git a/packages/internal/components/src/common/Focusable/GlobalFocusableProvider.tsx b/packages/internal/components/src/common/Focusable/GlobalFocusableProvider.tsx index df1e5b923..8ebdb80a8 100644 --- a/packages/internal/components/src/common/Focusable/GlobalFocusableProvider.tsx +++ b/packages/internal/components/src/common/Focusable/GlobalFocusableProvider.tsx @@ -1,3 +1,4 @@ +import { EnhanceSet } from "@follow/utils" import { jotaiStore } from "@follow/utils/jotai" import { atom } from "jotai" import type { PropsWithChildren } from "react" @@ -7,7 +8,7 @@ import { GlobalFocusableContext } from "./context" export const GlobalFocusableProvider = ({ children }: PropsWithChildren) => { const ctxValue = useMemo(() => { - return atom(new Set()) + return atom(EnhanceSet.of()) }, []) if (import.meta.env.DEV) { diff --git a/packages/internal/components/src/common/Focusable/context.ts b/packages/internal/components/src/common/Focusable/context.ts index ad2074029..be4d1d8d5 100644 --- a/packages/internal/components/src/common/Focusable/context.ts +++ b/packages/internal/components/src/common/Focusable/context.ts @@ -1,3 +1,4 @@ +import type { EnhanceSet } from "@follow/utils" import type { PrimitiveAtom } from "jotai" import { createContext } from "react" @@ -10,4 +11,4 @@ export const FocusActionsContext = createContext<{ highlightBoundary: () => void }>(null!) -export const GlobalFocusableContext = createContext>>(null!) +export const GlobalFocusableContext = createContext>>(null!) diff --git a/packages/internal/components/src/common/Focusable/hooks.ts b/packages/internal/components/src/common/Focusable/hooks.ts index 426689538..4fbdc541c 100644 --- a/packages/internal/components/src/common/Focusable/hooks.ts +++ b/packages/internal/components/src/common/Focusable/hooks.ts @@ -1,3 +1,4 @@ +import { EnhanceSet } from "@follow/utils" import { jotaiStore } from "@follow/utils/jotai" import { useAtomValue, useSetAtom } from "jotai" import { selectAtom } from "jotai/utils" @@ -51,11 +52,11 @@ export const useSetGlobalFocusableScope = () => { if (v.has(scope)) { return v } - const newSet = new Set(v) + const newSet = v.clone() newSet.add(scope) return newSet } else if (mode === "switch") { - const newSet = new Set(v) + const newSet = v.clone() if (newSet.has(scope)) { newSet.delete(scope) @@ -65,7 +66,7 @@ export const useSetGlobalFocusableScope = () => { return newSet } else { if (!v.has(scope)) return v - const newSet = new Set(v) + const newSet = v.clone() newSet.delete(scope) return newSet } @@ -87,7 +88,7 @@ export const useReplaceGlobalFocusableScope = () => { (...scopes: string[]) => { const snapshot = jotaiStore.get(ctx) setter(() => { - const newSet = new Set() + const newSet = EnhanceSet.of() for (const scope of scopes) { newSet.add(scope) } diff --git a/packages/internal/utils/src/data-structure/index.ts b/packages/internal/utils/src/data-structure/index.ts new file mode 100644 index 000000000..cdc13a479 --- /dev/null +++ b/packages/internal/utils/src/data-structure/index.ts @@ -0,0 +1 @@ +export * from "./set" diff --git a/packages/internal/utils/src/data-structure/set.ts b/packages/internal/utils/src/data-structure/set.ts new file mode 100644 index 000000000..5258ddb78 --- /dev/null +++ b/packages/internal/utils/src/data-structure/set.ts @@ -0,0 +1,21 @@ +export class EnhanceSet extends Set { + only(value: T) { + return this.size === 1 && super.has(value) + } + + clone() { + return new EnhanceSet(this) + } + + static of(...values: T[]) { + return new EnhanceSet(values) + } + + override has(...value: T[]): boolean { + return value.every((v) => super.has(v)) + } + + or(...value: T[]): boolean { + return value.some((v) => super.has(v)) + } +} diff --git a/packages/internal/utils/src/index.ts b/packages/internal/utils/src/index.ts index 7c96e7fbd..88078a3ae 100644 --- a/packages/internal/utils/src/index.ts +++ b/packages/internal/utils/src/index.ts @@ -1,5 +1,6 @@ export * from "./cjk" export * from "./color" +export * from "./data-structure/set" export * from "./dom" export * from "./duration" export * from "./jotai"