refactor(timeline): improve timeline selector panel interaction with triangle menu

Implement a more sophisticated mouse tracking mechanism for the timeline selector panel, using a triangle-based approach to determine when to open and close the panel. Replace previous Jotai-based state management with a custom hook that tracks mouse movement and provides a more intuitive panel interaction.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-02-17 17:31:35 +08:00
parent e0292fa44d
commit befd15ea22
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
1 changed files with 191 additions and 68 deletions

View File

@ -6,11 +6,9 @@ import type { FeedViewType } from "@follow/constants"
import { views } from "@follow/constants"
import { getNodeXInScroller, isNodeVisibleInScroller } from "@follow/utils/dom"
import { clsx, cn } from "@follow/utils/utils"
import { AnimatePresence, m } from "framer-motion"
import type { PrimitiveAtom } from "jotai"
import { atom, useAtom } from "jotai"
import { AnimatePresence, m, useAnimationControls } from "framer-motion"
import type { HTMLAttributes } from "react"
import { createContext, Fragment, useEffect, useMemo, useRef, useState } from "react"
import { Fragment, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { useShowContextMenu } from "~/atoms/context-menu"
@ -34,8 +32,6 @@ import styles from "./index.module.css"
import { feedColumnStyles } from "./styles"
import { TimelineSwitchButton } from "./TimelineSwitchButton"
const TimelineSelectorPanelStatusContext = createContext<PrimitiveAtom<boolean>>(null!)
export const TimelineSelector = ({ timelineId }: { timelineId: string | undefined }) => {
const timelineList = useTimelineList()
const scrollRef = useRef<HTMLDivElement>(null)
@ -67,8 +63,6 @@ export const TimelineSelector = ({ timelineId }: { timelineId: string | undefine
handler()
}, [timelineId])
const ctxValue = useMemo(() => atom(false), [])
const [openPanel, setOpenPanel] = useAtom(ctxValue)
const feedColWidth = useUISettingKey("feedColWidth")
const [rootContainer, setRootContainer] = useState<HTMLElement | null>(null)
@ -83,18 +77,25 @@ export const TimelineSelector = ({ timelineId }: { timelineId: string | undefine
const activeTimelineId = useRouteParamsSelector((s) => s.timelineId)
const [panelRef, setPanelRef] = useState<HTMLDivElement | null>(null)
const shouldOpen = useTriangleMenu(containerRef, panelRef!)
const animateControls = useAnimationControls()
useEffect(() => {
if (shouldOpen) {
animateControls.start({
transform: "translateX(0%)",
})
} else {
animateControls.start({
transform: "translateX(-100%)",
})
}
}, [animateControls, shouldOpen])
return (
<Fragment>
<div
ref={containerRef}
className="mt-3 pb-4"
onMouseEnter={() => {
setOpenPanel(true)
}}
onMouseLeave={() => {
setOpenPanel(false)
}}
>
<div ref={containerRef} className="mt-3 pb-4">
<div
ref={scrollRef}
className={clsx(
@ -122,56 +123,51 @@ export const TimelineSelector = ({ timelineId }: { timelineId: string | undefine
</div>
<RootPortal to={rootContainer}>
<TimelineSelectorPanelStatusContext.Provider value={ctxValue}>
<AnimatePresence>
{openPanel && (
<m.div
className="fixed inset-y-0 z-[1] flex h-full w-64 flex-col border-x bg-native"
style={{ left: feedColWidth }}
initial={{ transform: "translateX(-100%)" }}
animate={{ transform: "translateX(0%)" }}
exit={{ transform: "translateX(-100%)" }}
transition={{
damping: 50,
stiffness: 500,
type: "spring",
}}
onMouseEnter={() => setOpenPanel(true)}
onMouseLeave={() => setOpenPanel(false)}
>
<div className="border-b p-3 text-lg font-medium">Quick Selector</div>
<ScrollArea viewportClassName="pt-2 px-3" rootClassName="w-full">
<span className="mb-3 text-base font-bold">Views</span>
{timelineList.views.map((timelineId) => (
<TimelineListViewItem
key={timelineId}
timelineId={timelineId}
isActive={activeTimelineId === timelineId}
/>
))}
<Divider className="mx-12 my-4" />
<span className="mb-3 text-base font-bold">Inboxes</span>
{timelineList.inboxes.map((timelineId) => (
<TimelineInboxListItem
key={timelineId}
timelineId={timelineId}
isActive={activeTimelineId === timelineId}
/>
))}
<Divider className="mx-12 my-4" />
<span className="mb-3 text-base font-bold">Lists</span>
{timelineList.lists.map((timelineId) => (
<TimelineListListItem
key={timelineId}
timelineId={timelineId}
isActive={activeTimelineId === timelineId}
/>
))}
</ScrollArea>
</m.div>
)}
</AnimatePresence>
</TimelineSelectorPanelStatusContext.Provider>
<AnimatePresence>
<m.div
ref={setPanelRef}
className="fixed inset-y-0 z-[1] flex h-full w-64 flex-col border-x bg-native"
style={{ left: feedColWidth }}
initial={{ transform: "translateX(-100%)" }}
animate={animateControls}
// animate={{ transform: "translateX(0%)" }}
transition={{
damping: 50,
stiffness: 500,
type: "spring",
}}
>
<div className="border-b p-3 text-lg font-medium">Quick Selector</div>
<ScrollArea viewportClassName="pt-2 px-3" rootClassName="w-full">
<span className="mb-3 text-base font-bold">Views</span>
{timelineList.views.map((timelineId) => (
<TimelineListViewItem
key={timelineId}
timelineId={timelineId}
isActive={activeTimelineId === timelineId}
/>
))}
<Divider className="mx-12 my-4" />
<span className="mb-3 text-base font-bold">Inboxes</span>
{timelineList.inboxes.map((timelineId) => (
<TimelineInboxListItem
key={timelineId}
timelineId={timelineId}
isActive={activeTimelineId === timelineId}
/>
))}
<Divider className="mx-12 my-4" />
<span className="mb-3 text-base font-bold">Lists</span>
{timelineList.lists.map((timelineId) => (
<TimelineListListItem
key={timelineId}
timelineId={timelineId}
isActive={activeTimelineId === timelineId}
/>
))}
</ScrollArea>
</m.div>
</AnimatePresence>
</RootPortal>
</Fragment>
)
@ -267,3 +263,130 @@ const TimelineInboxListItem = ({ timelineId, isActive }: TimelineItemProps) => {
</ItemBase>
)
}
function useTriangleMenu(triggerRef: React.RefObject<HTMLElement>, panelRef: HTMLElement) {
const [open, setOpen] = useState(false)
useEffect(() => {
const $trigger = triggerRef.current
const $panel = panelRef
if (!$trigger || !$panel) return
const 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: lastPoint.x, y: lastPoint.y },
{ x: triggerRect.right - triggerRect.width / 2, y: triggerRect.top },
{ x: panelRect.left, y: panelRect.top },
{ x: panelRect.left, y: panelRect.bottom },
)
if (inTriangle) {
setOpen(true)
} else {
const inRect =
isPointInRect(
{ x: clientX, y: clientY },
{
x: triggerRect.left,
y: triggerRect.bottom,
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) {
setOpen(true)
} else {
setOpen(false)
inStartTrack = false
}
}
}
function handleMouseEnter() {
inStartTrack = true
setOpen(true)
}
function handleMouseLeave() {
// 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])
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
)
}