diff --git a/apps/renderer/src/modules/timeline-column/QuickSelectorPanel.tsx b/apps/renderer/src/modules/timeline-column/QuickSelectorPanel.tsx new file mode 100644 index 000000000..425133db0 --- /dev/null +++ b/apps/renderer/src/modules/timeline-column/QuickSelectorPanel.tsx @@ -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( + ({ 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 ( + + +
+ + {renderPanel && ( + <> +
+ Quick Selector + + +
+ + + + + + + + + )} +
+
+
+
+ ) + }, +) + +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 ( + +
+ {item.icon} + + {t(item.name as any)} + +
+
+ ) +} + +const ItemBase: Component< + { timelineId: string; isActive: boolean } & HTMLAttributes +> = ({ timelineId, isActive, children, className, ...props }) => { + const navigate = useNavigateEntry() + return ( + + ) +} + +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 ( + +
+ + + {list.title} + +
+
+ ) +} + +const TimelineInboxListItem = ({ timelineId, isActive }: TimelineItemProps) => { + const id = timelineId.slice(ROUTE_TIMELINE_OF_INBOX.length) + const inbox = useInboxById(id) + + if (!inbox) return null + return ( + +
+ + + {inbox.title} + +
+
+ ) +} + +interface TimelineSectionProps { + title: string + items: string[] + ItemComponent: React.ComponentType + activeTimelineId?: string | undefined +} + +const TimelineSection = ({ + title, + items, + activeTimelineId, + ItemComponent, +}: TimelineSectionProps) => { + return ( + <> +
{title}
+ {items.length > 0 ? ( +
+ {items.map((timelineId) => ( + + ))} +
+ ) : ( +
Nothing in {title}
+ )} + + ) +} diff --git a/apps/renderer/src/modules/timeline-column/TimelineSelector.tsx b/apps/renderer/src/modules/timeline-column/TimelineSelector.tsx index e8d4cada5..cc2b6d773 100644 --- a/apps/renderer/src/modules/timeline-column/TimelineSelector.tsx +++ b/apps/renderer/src/modules/timeline-column/TimelineSelector.tsx @@ -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(null) const containerRef = useRef(null) @@ -82,30 +61,6 @@ export const TimelineSelector = ({ timelineId }: { timelineId: string | undefine const triggerRef = useRef(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 {timelineColumnShow && ( - - -
- - {renderPanel && ( - <> -
- Quick Selector - - -
- - - - - - - - - )} -
-
-
-
+ )} ) } - -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 ( - -
- {item.icon} - - {t(item.name as any)} - -
-
- ) -} - -const ItemBase: Component< - { timelineId: string; isActive: boolean } & HTMLAttributes -> = ({ timelineId, isActive, children, className, ...props }) => { - const navigate = useNavigateEntry() - return ( - - ) -} - -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 ( - -
- - - {list.title} - -
-
- ) -} - -const TimelineInboxListItem = ({ timelineId, isActive }: TimelineItemProps) => { - const id = timelineId.slice(ROUTE_TIMELINE_OF_INBOX.length) - const inbox = useInboxById(id) - - if (!inbox) return null - return ( - -
- - - {inbox.title} - -
-
- ) -} - -interface TimelineSectionProps { - title: string - items: string[] - ItemComponent: React.ComponentType - activeTimelineId?: string | undefined -} - -const TimelineSection = ({ - title, - items, - activeTimelineId, - ItemComponent, -}: TimelineSectionProps) => { - return ( - <> -
{title}
- {items.length > 0 ? ( -
- {items.map((timelineId) => ( - - ))} -
- ) : ( -
Nothing in {title}
- )} - - ) -} - -function useTriangleMenu( - triggerRef: React.RefObject, - 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 - ) -} diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index cee30778a..3b715fa38 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -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" diff --git a/packages/hooks/src/useTriangleMenu.ts b/packages/hooks/src/useTriangleMenu.ts new file mode 100644 index 000000000..a209cbb9f --- /dev/null +++ b/packages/hooks/src/useTriangleMenu.ts @@ -0,0 +1,138 @@ +import { useEffect, useState } from "react" + +export function useTriangleMenu( + triggerRef: React.RefObject, + 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 + ) +}