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 <tukon479@gmail.com>
This commit is contained in:
Innei 2025-05-27 17:59:01 +08:00
parent 1f53a972b0
commit c20350f165
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
16 changed files with 102 additions and 22 deletions

View File

@ -24,4 +24,6 @@ export const FocusablePresets = {
isSubscriptionOrTimeline: (v: Set<string>) => {
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<string, (v: Set<string>) => boolean>

View File

@ -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<string, (e: KeyboardEvent) => void>,
)
return tinykeys(window, actionMap)
}, [items, when])
}

View File

@ -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)

View File

@ -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,

View File

@ -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(() => {

View File

@ -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")

View File

@ -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 = ({
<CommandActionButton
active={config.active}
key={config.id}
disableTriggerShortcut={hasModal}
disableTriggerShortcut={!when}
commandId={config.id}
onClick={config.onClick!}
shortcut={config.shortcut!}

View File

@ -1,6 +1,6 @@
import {
useFocusActions,
useGlobalFocusableScope,
useGlobalFocusableScopeSelector,
} from "@follow/components/common/Focusable/index.js"
import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDangerousHTMLStyle.js"
import { Spring } from "@follow/components/constants/spring.js"
@ -21,7 +21,7 @@ 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 { Focusable, FocusablePresets } 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"
@ -316,8 +316,7 @@ const RegisterCommands = ({
const isAlreadyScrolledBottomRef = useRef(false)
const [showKeepScrollingPanel, setShowKeepScrollingPanel] = useState(false)
const activeScope = useGlobalFocusableScope()
const when = activeScope.has(HotkeyScope.EntryRender)
const when = useGlobalFocusableScopeSelector(FocusablePresets.isEntryRender)
useCommandBinding({
commandId: COMMAND_ID.entryRender.scrollUp,

View File

@ -1,3 +1,4 @@
import { useGlobalFocusableScope } from "@follow/components/common/Focusable/hooks.js"
import { useMobile } from "@follow/components/hooks/useMobile.js"
import { OouiUserAnonymous } from "@follow/components/icons/OouiUserAnonymous.jsx"
import { Button } from "@follow/components/ui/button/index.js"
@ -16,6 +17,8 @@ import { useTranslation } from "react-i18next"
import { MenuItemSeparator, MenuItemText, useShowContextMenu } from "~/atoms/context-menu"
import { useHideAllReadSubscriptions } from "~/atoms/settings/general"
import { ErrorTooltip } from "~/components/common/ErrorTooltip"
import { FocusablePresets } from "~/components/common/Focusable"
import { useContextMenuActionShortCutTrigger } from "~/hooks/biz/useContextMenuActionShortCutTrigger"
import { useFeedActions, useInboxActions, useListActions } from "~/hooks/biz/useFeedActions"
import { useFollow } from "~/hooks/biz/useFollow"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
@ -117,6 +120,11 @@ const FeedItemImpl = ({ view, feedId, className, isPreview }: FeedItemProps) =>
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<ListItemProps> = ({
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<InboxItemProps> = ({ 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)

View File

@ -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,

View File

@ -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<string>())
return atom(EnhanceSet.of<string>())
}, [])
if (import.meta.env.DEV) {

View File

@ -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<PrimitiveAtom<Set<string>>>(null!)
export const GlobalFocusableContext = createContext<PrimitiveAtom<EnhanceSet<string>>>(null!)

View File

@ -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<string>()
const newSet = EnhanceSet.of<string>()
for (const scope of scopes) {
newSet.add(scope)
}

View File

@ -0,0 +1 @@
export * from "./set"

View File

@ -0,0 +1,21 @@
export class EnhanceSet<T> extends Set<T> {
only(value: T) {
return this.size === 1 && super.has(value)
}
clone() {
return new EnhanceSet(this)
}
static of<T>(...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))
}
}

View File

@ -1,5 +1,6 @@
export * from "./cjk"
export * from "./color"
export * from "./data-structure/set"
export * from "./dom"
export * from "./duration"
export * from "./jotai"