refactor(timeline): extract quick selector panel into separate component
- Created new QuickSelectorPanel component - Moved panel rendering logic out of TimelineSelector - Extracted triangle menu interaction logic into a separate hook - Simplified TimelineSelector component structure - Exported useTriangleMenu hook from hooks package Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
parent
de6394898f
commit
ba02009c9a
|
|
@ -0,0 +1,258 @@
|
|||
import { Divider } from "@follow/components/ui/divider/Divider.js"
|
||||
import { RootPortal } from "@follow/components/ui/portal/index.js"
|
||||
import { ScrollArea } from "@follow/components/ui/scroll-area/ScrollArea.js"
|
||||
import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/EllipsisWithTooltip.js"
|
||||
import type { FeedViewType } from "@follow/constants"
|
||||
import { views } from "@follow/constants"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { AnimatePresence, m, useAnimationControls } from "framer-motion"
|
||||
import type { HTMLAttributes } from "react"
|
||||
import { forwardRef, useEffect, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { useShowContextMenu } from "~/atoms/context-menu"
|
||||
import { useUISettingKey } from "~/atoms/settings/ui"
|
||||
import {
|
||||
ROUTE_TIMELINE_OF_INBOX,
|
||||
ROUTE_TIMELINE_OF_LIST,
|
||||
ROUTE_TIMELINE_OF_VIEW,
|
||||
} from "~/constants"
|
||||
import { useListActions } from "~/hooks/biz/useFeedActions"
|
||||
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
|
||||
import { useContextMenu } from "~/hooks/common"
|
||||
import { useInboxById } from "~/store/inbox"
|
||||
import { useListById } from "~/store/list"
|
||||
|
||||
import { FeedIcon } from "../feed/feed-icon"
|
||||
import { feedColumnStyles } from "./styles"
|
||||
|
||||
interface QuickSelectorPanelProps {
|
||||
rootContainer: HTMLElement | null
|
||||
timelineList: {
|
||||
views: string[]
|
||||
lists: string[]
|
||||
inboxes: string[]
|
||||
all: string[]
|
||||
}
|
||||
activeTimelineId: string | undefined
|
||||
open: boolean
|
||||
}
|
||||
|
||||
export const QuickSelectorPanel = forwardRef<HTMLDivElement, QuickSelectorPanelProps>(
|
||||
({ rootContainer, timelineList, activeTimelineId, open: shouldOpen }, ref) => {
|
||||
const feedColWidth = useUISettingKey("feedColWidth")
|
||||
const animateControls = useAnimationControls()
|
||||
const [renderPanel, setRenderPanel] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancel = false
|
||||
if (shouldOpen) {
|
||||
setRenderPanel(true)
|
||||
animateControls.start({
|
||||
transform: "translateX(0%)",
|
||||
})
|
||||
} else {
|
||||
animateControls
|
||||
.start({
|
||||
transform: "translateX(-100%)",
|
||||
})
|
||||
.then(() => {
|
||||
if (cancel) return
|
||||
setRenderPanel(false)
|
||||
})
|
||||
}
|
||||
return () => {
|
||||
cancel = true
|
||||
}
|
||||
}, [animateControls, renderPanel, shouldOpen])
|
||||
|
||||
return (
|
||||
<RootPortal to={rootContainer}>
|
||||
<AnimatePresence>
|
||||
<div
|
||||
className="pointer-events-none fixed inset-y-0 z-[1] w-64 overflow-hidden"
|
||||
style={{ left: feedColWidth }}
|
||||
>
|
||||
<m.div
|
||||
ref={ref}
|
||||
className="pointer-events-auto flex h-full w-64 flex-col border-x bg-native"
|
||||
initial={{ transform: "translateX(-100%)" }}
|
||||
animate={animateControls}
|
||||
transition={{
|
||||
damping: 50,
|
||||
stiffness: 500,
|
||||
type: "spring",
|
||||
}}
|
||||
>
|
||||
{renderPanel && (
|
||||
<>
|
||||
<header className="flex items-center justify-between border-b p-3 text-lg font-medium">
|
||||
<span className="text-lg font-medium">Quick Selector</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center rounded p-1 transition-colors duration-200 hover:bg-theme-item-hover"
|
||||
onClick={() => {
|
||||
animateControls.start({
|
||||
transform: "translateX(-100%)",
|
||||
})
|
||||
}}
|
||||
>
|
||||
<i className="i-mgc-close-cute-re" />
|
||||
</button>
|
||||
</header>
|
||||
<ScrollArea viewportClassName="pt-2 px-3" rootClassName="w-full">
|
||||
<TimelineSection
|
||||
title="Views"
|
||||
items={timelineList.views}
|
||||
activeTimelineId={activeTimelineId}
|
||||
ItemComponent={TimelineListViewItem}
|
||||
/>
|
||||
<Divider className="mx-12 my-4" />
|
||||
<TimelineSection
|
||||
title="Inboxes"
|
||||
items={timelineList.inboxes}
|
||||
activeTimelineId={activeTimelineId}
|
||||
ItemComponent={TimelineInboxListItem}
|
||||
/>
|
||||
<Divider className="mx-12 my-4" />
|
||||
<TimelineSection
|
||||
title="Lists"
|
||||
items={timelineList.lists}
|
||||
activeTimelineId={activeTimelineId}
|
||||
ItemComponent={TimelineListListItem}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</m.div>
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
</RootPortal>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
interface TimelineItemProps {
|
||||
timelineId: string
|
||||
isActive: boolean
|
||||
}
|
||||
const TimelineListViewItem = ({ timelineId, isActive }: TimelineItemProps) => {
|
||||
const id = Number.parseInt(timelineId.slice(ROUTE_TIMELINE_OF_VIEW.length), 10) as FeedViewType
|
||||
const item = views.find((item) => item.view === id)!
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<ItemBase timelineId={timelineId} isActive={isActive}>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{item.icon}
|
||||
<EllipsisHorizontalTextWithTooltip className="truncate">
|
||||
{t(item.name as any)}
|
||||
</EllipsisHorizontalTextWithTooltip>
|
||||
</div>
|
||||
</ItemBase>
|
||||
)
|
||||
}
|
||||
|
||||
const ItemBase: Component<
|
||||
{ timelineId: string; isActive: boolean } & HTMLAttributes<HTMLButtonElement>
|
||||
> = ({ timelineId, isActive, children, className, ...props }) => {
|
||||
const navigate = useNavigateEntry()
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigate({
|
||||
timelineId,
|
||||
feedId: null,
|
||||
entryId: null,
|
||||
})
|
||||
}}
|
||||
className={cn(
|
||||
feedColumnStyles.item,
|
||||
"flex w-full cursor-button items-center justify-between rounded-md px-2.5 py-0.5 text-base font-medium leading-loose lg:text-sm",
|
||||
isActive && "bg-theme-item-active",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const TimelineListListItem = ({ timelineId, isActive }: TimelineItemProps) => {
|
||||
const id = timelineId.slice(ROUTE_TIMELINE_OF_LIST.length)
|
||||
const list = useListById(id)
|
||||
|
||||
const items = useListActions({ listId: id })
|
||||
const showContextMenu = useShowContextMenu()
|
||||
|
||||
const contextMenuProps = useContextMenu({
|
||||
onContextMenu: async (e) => {
|
||||
await showContextMenu(items, e)
|
||||
},
|
||||
})
|
||||
|
||||
if (!list) return null
|
||||
return (
|
||||
<ItemBase className="px-1" timelineId={timelineId} isActive={isActive} {...contextMenuProps}>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<FeedIcon fallback feed={list} size={22} noMargin />
|
||||
<EllipsisHorizontalTextWithTooltip className="truncate">
|
||||
{list.title}
|
||||
</EllipsisHorizontalTextWithTooltip>
|
||||
</div>
|
||||
</ItemBase>
|
||||
)
|
||||
}
|
||||
|
||||
const TimelineInboxListItem = ({ timelineId, isActive }: TimelineItemProps) => {
|
||||
const id = timelineId.slice(ROUTE_TIMELINE_OF_INBOX.length)
|
||||
const inbox = useInboxById(id)
|
||||
|
||||
if (!inbox) return null
|
||||
return (
|
||||
<ItemBase timelineId={timelineId} isActive={isActive}>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<FeedIcon fallback feed={inbox} size={16} noMargin />
|
||||
<EllipsisHorizontalTextWithTooltip className="truncate">
|
||||
{inbox.title}
|
||||
</EllipsisHorizontalTextWithTooltip>
|
||||
</div>
|
||||
</ItemBase>
|
||||
)
|
||||
}
|
||||
|
||||
interface TimelineSectionProps {
|
||||
title: string
|
||||
items: string[]
|
||||
ItemComponent: React.ComponentType<TimelineItemProps>
|
||||
activeTimelineId?: string | undefined
|
||||
}
|
||||
|
||||
const TimelineSection = ({
|
||||
title,
|
||||
items,
|
||||
activeTimelineId,
|
||||
ItemComponent,
|
||||
}: TimelineSectionProps) => {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-1 text-base font-bold">{title}</div>
|
||||
{items.length > 0 ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{items.map((timelineId) => (
|
||||
<ItemComponent
|
||||
key={timelineId}
|
||||
timelineId={timelineId}
|
||||
isActive={activeTimelineId === timelineId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-sm text-muted-foreground">Nothing in {title}</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,36 +1,16 @@
|
|||
import { Divider, DividerVertical } from "@follow/components/ui/divider/Divider.js"
|
||||
import { RootPortal } from "@follow/components/ui/portal/index.js"
|
||||
import { ScrollArea } from "@follow/components/ui/scroll-area/ScrollArea.js"
|
||||
import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/EllipsisWithTooltip.js"
|
||||
import type { FeedViewType } from "@follow/constants"
|
||||
import { views } from "@follow/constants"
|
||||
import { DividerVertical } from "@follow/components/ui/divider/Divider.js"
|
||||
import { useTriangleMenu } from "@follow/hooks"
|
||||
import { getNodeXInScroller, isNodeVisibleInScroller } from "@follow/utils/dom"
|
||||
import { clsx, cn } from "@follow/utils/utils"
|
||||
import { AnimatePresence, m, useAnimationControls } from "framer-motion"
|
||||
import type { HTMLAttributes } from "react"
|
||||
import { clsx } from "@follow/utils/utils"
|
||||
import { Fragment, useEffect, useRef, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { useShowContextMenu } from "~/atoms/context-menu"
|
||||
import { useUISettingKey } from "~/atoms/settings/ui"
|
||||
import { useTimelineColumnShow } from "~/atoms/sidebar"
|
||||
import {
|
||||
ROUTE_TIMELINE_OF_INBOX,
|
||||
ROUTE_TIMELINE_OF_LIST,
|
||||
ROUTE_TIMELINE_OF_VIEW,
|
||||
} from "~/constants/app"
|
||||
import { ROOT_CONTAINER_ID } from "~/constants/dom"
|
||||
import { useListActions } from "~/hooks/biz/useFeedActions"
|
||||
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
|
||||
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
||||
import { useTimelineList } from "~/hooks/biz/useTimelineList"
|
||||
import { useContextMenu } from "~/hooks/common"
|
||||
import { useInboxById } from "~/store/inbox"
|
||||
import { useListById } from "~/store/list"
|
||||
|
||||
import { FeedIcon } from "../feed/feed-icon"
|
||||
import styles from "./index.module.css"
|
||||
import { feedColumnStyles } from "./styles"
|
||||
import { QuickSelectorPanel } from "./QuickSelectorPanel"
|
||||
import { TimelineSwitchButton } from "./TimelineSwitchButton"
|
||||
|
||||
export const TimelineSelector = ({ timelineId }: { timelineId: string | undefined }) => {
|
||||
|
|
@ -64,7 +44,6 @@ export const TimelineSelector = ({ timelineId }: { timelineId: string | undefine
|
|||
handler()
|
||||
}, [timelineId])
|
||||
|
||||
const feedColWidth = useUISettingKey("feedColWidth")
|
||||
const [rootContainer, setRootContainer] = useState<HTMLElement | null>(null)
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
|
@ -82,30 +61,6 @@ export const TimelineSelector = ({ timelineId }: { timelineId: string | undefine
|
|||
const triggerRef = useRef<HTMLDivElement>(null)
|
||||
const shouldOpen = useTriangleMenu(triggerRef, panelRef!, 0)
|
||||
|
||||
const animateControls = useAnimationControls()
|
||||
|
||||
const [renderPanel, setRenderPanel] = useState(false)
|
||||
useEffect(() => {
|
||||
let cancel = false
|
||||
if (shouldOpen) {
|
||||
setRenderPanel(true)
|
||||
animateControls.start({
|
||||
transform: "translateX(0%)",
|
||||
})
|
||||
} else {
|
||||
animateControls
|
||||
.start({
|
||||
transform: "translateX(-100%)",
|
||||
})
|
||||
.then(() => {
|
||||
if (cancel) return
|
||||
setRenderPanel(false)
|
||||
})
|
||||
}
|
||||
return () => {
|
||||
cancel = true
|
||||
}
|
||||
}, [animateControls, shouldOpen])
|
||||
const timelineColumnShow = useTimelineColumnShow()
|
||||
|
||||
return (
|
||||
|
|
@ -140,330 +95,14 @@ export const TimelineSelector = ({ timelineId }: { timelineId: string | undefine
|
|||
</div>
|
||||
|
||||
{timelineColumnShow && (
|
||||
<RootPortal to={rootContainer}>
|
||||
<AnimatePresence>
|
||||
<div
|
||||
className="pointer-events-none fixed inset-y-0 z-[1] w-64 overflow-hidden"
|
||||
style={{ left: feedColWidth }}
|
||||
>
|
||||
<m.div
|
||||
ref={setPanelRef}
|
||||
className="pointer-events-auto flex h-full w-64 flex-col border-x bg-native"
|
||||
initial={{ transform: "translateX(-100%)" }}
|
||||
animate={animateControls}
|
||||
transition={{
|
||||
damping: 50,
|
||||
stiffness: 500,
|
||||
type: "spring",
|
||||
}}
|
||||
>
|
||||
{renderPanel && (
|
||||
<>
|
||||
<header className="flex items-center justify-between border-b p-3 text-lg font-medium">
|
||||
<span className="text-lg font-medium">Quick Selector</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center rounded p-1 transition-colors duration-200 hover:bg-theme-item-hover"
|
||||
onClick={() => {
|
||||
animateControls.start({
|
||||
transform: "translateX(-100%)",
|
||||
})
|
||||
}}
|
||||
>
|
||||
<i className="i-mgc-close-cute-re" />
|
||||
</button>
|
||||
</header>
|
||||
<ScrollArea viewportClassName="pt-2 px-3" rootClassName="w-full">
|
||||
<TimelineSection
|
||||
title="Views"
|
||||
items={timelineList.views}
|
||||
activeTimelineId={activeTimelineId}
|
||||
ItemComponent={TimelineListViewItem}
|
||||
/>
|
||||
<Divider className="mx-12 my-4" />
|
||||
<TimelineSection
|
||||
title="Inboxes"
|
||||
items={timelineList.inboxes}
|
||||
activeTimelineId={activeTimelineId}
|
||||
ItemComponent={TimelineInboxListItem}
|
||||
/>
|
||||
<Divider className="mx-12 my-4" />
|
||||
<TimelineSection
|
||||
title="Lists"
|
||||
items={timelineList.lists}
|
||||
activeTimelineId={activeTimelineId}
|
||||
ItemComponent={TimelineListListItem}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</m.div>
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
</RootPortal>
|
||||
<QuickSelectorPanel
|
||||
ref={setPanelRef}
|
||||
open={shouldOpen}
|
||||
rootContainer={rootContainer}
|
||||
timelineList={timelineList}
|
||||
activeTimelineId={activeTimelineId}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
interface TimelineItemProps {
|
||||
timelineId: string
|
||||
isActive: boolean
|
||||
}
|
||||
const TimelineListViewItem = ({ timelineId, isActive }: TimelineItemProps) => {
|
||||
const id = Number.parseInt(timelineId.slice(ROUTE_TIMELINE_OF_VIEW.length), 10) as FeedViewType
|
||||
const item = views.find((item) => item.view === id)!
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<ItemBase timelineId={timelineId} isActive={isActive}>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{item.icon}
|
||||
<EllipsisHorizontalTextWithTooltip className="truncate">
|
||||
{t(item.name as any)}
|
||||
</EllipsisHorizontalTextWithTooltip>
|
||||
</div>
|
||||
</ItemBase>
|
||||
)
|
||||
}
|
||||
|
||||
const ItemBase: Component<
|
||||
{ timelineId: string; isActive: boolean } & HTMLAttributes<HTMLButtonElement>
|
||||
> = ({ timelineId, isActive, children, className, ...props }) => {
|
||||
const navigate = useNavigateEntry()
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigate({
|
||||
timelineId,
|
||||
feedId: null,
|
||||
entryId: null,
|
||||
})
|
||||
}}
|
||||
className={cn(
|
||||
feedColumnStyles.item,
|
||||
"flex w-full cursor-pointer items-center justify-between rounded-md px-2.5 py-0.5 text-base font-medium leading-loose lg:text-sm",
|
||||
isActive && "bg-theme-item-active",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const TimelineListListItem = ({ timelineId, isActive }: TimelineItemProps) => {
|
||||
const id = timelineId.slice(ROUTE_TIMELINE_OF_LIST.length)
|
||||
const list = useListById(id)
|
||||
|
||||
const items = useListActions({ listId: id })
|
||||
const showContextMenu = useShowContextMenu()
|
||||
|
||||
const contextMenuProps = useContextMenu({
|
||||
onContextMenu: async (e) => {
|
||||
await showContextMenu(items, e)
|
||||
},
|
||||
})
|
||||
|
||||
if (!list) return null
|
||||
return (
|
||||
<ItemBase className="px-1" timelineId={timelineId} isActive={isActive} {...contextMenuProps}>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<FeedIcon fallback feed={list} size={22} noMargin />
|
||||
<EllipsisHorizontalTextWithTooltip className="truncate">
|
||||
{list.title}
|
||||
</EllipsisHorizontalTextWithTooltip>
|
||||
</div>
|
||||
</ItemBase>
|
||||
)
|
||||
}
|
||||
|
||||
const TimelineInboxListItem = ({ timelineId, isActive }: TimelineItemProps) => {
|
||||
const id = timelineId.slice(ROUTE_TIMELINE_OF_INBOX.length)
|
||||
const inbox = useInboxById(id)
|
||||
|
||||
if (!inbox) return null
|
||||
return (
|
||||
<ItemBase timelineId={timelineId} isActive={isActive}>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<FeedIcon fallback feed={inbox} size={16} noMargin />
|
||||
<EllipsisHorizontalTextWithTooltip className="truncate">
|
||||
{inbox.title}
|
||||
</EllipsisHorizontalTextWithTooltip>
|
||||
</div>
|
||||
</ItemBase>
|
||||
)
|
||||
}
|
||||
|
||||
interface TimelineSectionProps {
|
||||
title: string
|
||||
items: string[]
|
||||
ItemComponent: React.ComponentType<TimelineItemProps>
|
||||
activeTimelineId?: string | undefined
|
||||
}
|
||||
|
||||
const TimelineSection = ({
|
||||
title,
|
||||
items,
|
||||
activeTimelineId,
|
||||
ItemComponent,
|
||||
}: TimelineSectionProps) => {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-1 text-base font-bold">{title}</div>
|
||||
{items.length > 0 ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{items.map((timelineId) => (
|
||||
<ItemComponent
|
||||
key={timelineId}
|
||||
timelineId={timelineId}
|
||||
isActive={activeTimelineId === timelineId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-sm text-muted-foreground">Nothing in {title}</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function useTriangleMenu(
|
||||
triggerRef: React.RefObject<HTMLElement>,
|
||||
panelRef: HTMLElement,
|
||||
openDelay = 100,
|
||||
) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const $trigger = triggerRef.current
|
||||
const $panel = panelRef
|
||||
if (!$trigger || !$panel) return
|
||||
let timer: NodeJS.Timeout | null = null
|
||||
let inStartTrack = false
|
||||
const mousePath = [] as { x: number; y: number }[]
|
||||
function handleMouseMove(event: MouseEvent) {
|
||||
const { clientX, clientY } = event
|
||||
|
||||
if (!inStartTrack) return
|
||||
|
||||
mousePath.push({ x: clientX, y: clientY })
|
||||
if (mousePath.length > 5) {
|
||||
mousePath.shift()
|
||||
}
|
||||
|
||||
const triggerRect = $trigger?.getBoundingClientRect()
|
||||
|
||||
if (!triggerRect) return
|
||||
const panelRect = $panel.getBoundingClientRect()
|
||||
|
||||
const lastPoint = mousePath[0]
|
||||
|
||||
if (!lastPoint) return
|
||||
|
||||
const inTriangle = isPointInTriangle(
|
||||
{ x: clientX, y: clientY },
|
||||
{ x: lastPoint.x, y: lastPoint.y },
|
||||
{ x: panelRect.left, y: panelRect.top },
|
||||
{ x: panelRect.left, y: panelRect.bottom },
|
||||
)
|
||||
|
||||
if (inTriangle) {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => setOpen(true), openDelay)
|
||||
} else {
|
||||
const inRect =
|
||||
isPointInRect(
|
||||
{ x: clientX, y: clientY },
|
||||
{
|
||||
x: triggerRect.left,
|
||||
y: triggerRect.top,
|
||||
width: triggerRect.width,
|
||||
height: triggerRect.height,
|
||||
},
|
||||
) ||
|
||||
isPointInRect(
|
||||
{ x: clientX, y: clientY },
|
||||
{
|
||||
x: panelRect.left,
|
||||
y: panelRect.top,
|
||||
width: panelRect.width,
|
||||
height: panelRect.height,
|
||||
},
|
||||
)
|
||||
|
||||
if (inRect) {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => setOpen(true), openDelay)
|
||||
} else {
|
||||
if (timer) clearTimeout(timer)
|
||||
setOpen(false)
|
||||
inStartTrack = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseEnter() {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
inStartTrack = true
|
||||
setOpen(true)
|
||||
}, openDelay)
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
if (timer) clearTimeout(timer)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove)
|
||||
$trigger.addEventListener("mouseenter", handleMouseEnter)
|
||||
$panel.addEventListener("mouseleave", handleMouseLeave)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove)
|
||||
$trigger.removeEventListener("mouseenter", handleMouseEnter)
|
||||
$panel.removeEventListener("mouseleave", handleMouseLeave)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}, [triggerRef, panelRef, openDelay])
|
||||
|
||||
return open
|
||||
}
|
||||
|
||||
function isPointInTriangle(
|
||||
P: { x: number; y: number },
|
||||
A: { x: number; y: number },
|
||||
B: { x: number; y: number },
|
||||
C: { x: number; y: number },
|
||||
) {
|
||||
function sign(p1: any, p2: any, p3: any) {
|
||||
return (p1.x - p3.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p3.y)
|
||||
}
|
||||
|
||||
const d1 = sign(P, A, B)
|
||||
const d2 = sign(P, B, C)
|
||||
const d3 = sign(P, C, A)
|
||||
|
||||
const hasNeg = d1 < 0 || d2 < 0 || d3 < 0
|
||||
const hasPos = d1 > 0 || d2 > 0 || d3 > 0
|
||||
|
||||
return !(hasNeg && hasPos)
|
||||
}
|
||||
|
||||
function isPointInRect(
|
||||
point: { x: number; y: number },
|
||||
rect: { x: number; y: number; width: number; height: number },
|
||||
) {
|
||||
return (
|
||||
point.x >= rect.x &&
|
||||
point.x <= rect.x + rect.width &&
|
||||
point.y >= rect.y &&
|
||||
point.y <= rect.y + rect.height
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,5 +15,6 @@ export * from "./useRefValue"
|
|||
export * from "./useSetState"
|
||||
export * from "./useSyncTheme"
|
||||
export * from "./useTitle"
|
||||
export * from "./useTriangleMenu"
|
||||
export * from "./useTypescriptHappyCallback"
|
||||
export * from "./useVideo"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
import { useEffect, useState } from "react"
|
||||
|
||||
export function useTriangleMenu(
|
||||
triggerRef: React.RefObject<HTMLElement>,
|
||||
panelRef: HTMLElement,
|
||||
openDelay = 100,
|
||||
) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const $trigger = triggerRef.current
|
||||
const $panel = panelRef
|
||||
if (!$trigger || !$panel) return
|
||||
let timer: NodeJS.Timeout | null = null
|
||||
let inStartTrack = false
|
||||
const mousePath = [] as { x: number; y: number }[]
|
||||
function handleMouseMove(event: MouseEvent) {
|
||||
const { clientX, clientY } = event
|
||||
|
||||
if (!inStartTrack) return
|
||||
|
||||
mousePath.push({ x: clientX, y: clientY })
|
||||
if (mousePath.length > 5) {
|
||||
mousePath.shift()
|
||||
}
|
||||
|
||||
const triggerRect = $trigger?.getBoundingClientRect()
|
||||
|
||||
if (!triggerRect) return
|
||||
const panelRect = $panel.getBoundingClientRect()
|
||||
|
||||
const lastPoint = mousePath[0]
|
||||
|
||||
if (!lastPoint) return
|
||||
|
||||
const inTriangle = isPointInTriangle(
|
||||
{ x: clientX, y: clientY },
|
||||
{ x: lastPoint.x, y: lastPoint.y },
|
||||
{ x: panelRect.left, y: panelRect.top },
|
||||
{ x: panelRect.left, y: panelRect.bottom },
|
||||
)
|
||||
|
||||
if (inTriangle) {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => setOpen(true), openDelay)
|
||||
} else {
|
||||
const inRect =
|
||||
isPointInRect(
|
||||
{ x: clientX, y: clientY },
|
||||
{
|
||||
x: triggerRect.left,
|
||||
y: triggerRect.top,
|
||||
width: triggerRect.width,
|
||||
height: triggerRect.height,
|
||||
},
|
||||
) ||
|
||||
isPointInRect(
|
||||
{ x: clientX, y: clientY },
|
||||
{
|
||||
x: panelRect.left,
|
||||
y: panelRect.top,
|
||||
width: panelRect.width,
|
||||
height: panelRect.height,
|
||||
},
|
||||
)
|
||||
|
||||
if (inRect) {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => setOpen(true), openDelay)
|
||||
} else {
|
||||
if (timer) clearTimeout(timer)
|
||||
setOpen(false)
|
||||
inStartTrack = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseEnter() {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
inStartTrack = true
|
||||
setOpen(true)
|
||||
}, openDelay)
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
if (timer) clearTimeout(timer)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove)
|
||||
$trigger.addEventListener("mouseenter", handleMouseEnter)
|
||||
$panel.addEventListener("mouseleave", handleMouseLeave)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove)
|
||||
$trigger.removeEventListener("mouseenter", handleMouseEnter)
|
||||
$panel.removeEventListener("mouseleave", handleMouseLeave)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}, [triggerRef, panelRef, openDelay])
|
||||
|
||||
return open
|
||||
}
|
||||
|
||||
function isPointInTriangle(
|
||||
P: { x: number; y: number },
|
||||
A: { x: number; y: number },
|
||||
B: { x: number; y: number },
|
||||
C: { x: number; y: number },
|
||||
) {
|
||||
function sign(p1: any, p2: any, p3: any) {
|
||||
return (p1.x - p3.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p3.y)
|
||||
}
|
||||
|
||||
const d1 = sign(P, A, B)
|
||||
const d2 = sign(P, B, C)
|
||||
const d3 = sign(P, C, A)
|
||||
|
||||
const hasNeg = d1 < 0 || d2 < 0 || d3 < 0
|
||||
const hasPos = d1 > 0 || d2 > 0 || d3 > 0
|
||||
|
||||
return !(hasNeg && hasPos)
|
||||
}
|
||||
|
||||
function isPointInRect(
|
||||
point: { x: number; y: number },
|
||||
rect: { x: number; y: number; width: number; height: number },
|
||||
) {
|
||||
return (
|
||||
point.x >= rect.x &&
|
||||
point.x <= rect.x + rect.width &&
|
||||
point.y >= rect.y &&
|
||||
point.y <= rect.y + rect.height
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue