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:
parent
1f53a972b0
commit
c20350f165
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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!}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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!)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export * from "./set"
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
export * from "./cjk"
|
||||
export * from "./color"
|
||||
export * from "./data-structure/set"
|
||||
export * from "./dom"
|
||||
export * from "./duration"
|
||||
export * from "./jotai"
|
||||
|
|
|
|||
Loading…
Reference in New Issue