From befd15ea223099b158ede640ea4b6973f8f1730a Mon Sep 17 00:00:00 2001 From: Innei Date: Mon, 17 Feb 2025 17:31:35 +0800 Subject: [PATCH] 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 --- .../timeline-column/TimelineSelector.tsx | 259 +++++++++++++----- 1 file changed, 191 insertions(+), 68 deletions(-) diff --git a/apps/renderer/src/modules/timeline-column/TimelineSelector.tsx b/apps/renderer/src/modules/timeline-column/TimelineSelector.tsx index 572bfb732..c45bc4d83 100644 --- a/apps/renderer/src/modules/timeline-column/TimelineSelector.tsx +++ b/apps/renderer/src/modules/timeline-column/TimelineSelector.tsx @@ -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>(null!) - export const TimelineSelector = ({ timelineId }: { timelineId: string | undefined }) => { const timelineList = useTimelineList() const scrollRef = useRef(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(null) @@ -83,18 +77,25 @@ export const TimelineSelector = ({ timelineId }: { timelineId: string | undefine const activeTimelineId = useRouteParamsSelector((s) => s.timelineId) + const [panelRef, setPanelRef] = useState(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 ( -
{ - setOpenPanel(true) - }} - onMouseLeave={() => { - setOpenPanel(false) - }} - > +
- - - {openPanel && ( - setOpenPanel(true)} - onMouseLeave={() => setOpenPanel(false)} - > -
Quick Selector
- - Views - {timelineList.views.map((timelineId) => ( - - ))} - - Inboxes - {timelineList.inboxes.map((timelineId) => ( - - ))} - - Lists - {timelineList.lists.map((timelineId) => ( - - ))} - -
- )} -
-
+ + +
Quick Selector
+ + Views + {timelineList.views.map((timelineId) => ( + + ))} + + Inboxes + {timelineList.inboxes.map((timelineId) => ( + + ))} + + Lists + {timelineList.lists.map((timelineId) => ( + + ))} + +
+
) @@ -267,3 +263,130 @@ const TimelineInboxListItem = ({ timelineId, isActive }: TimelineItemProps) => { ) } + +function useTriangleMenu(triggerRef: React.RefObject, 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 + ) +}