| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196 |
- 'use client'
- import type { FC } from 'react'
- import React, { useCallback, useEffect, useRef } from 'react'
- import {
- PortalToFollowElem,
- PortalToFollowElemContent,
- PortalToFollowElemTrigger,
- } from '@/app/components/base/portal-to-follow-elem'
- import type {
- OffsetOptions,
- Placement,
- } from '@floating-ui/react'
- import Input from '@/app/components/base/input'
- import AppIcon from '@/app/components/base/app-icon'
- import type { App } from '@/types/app'
- import { useTranslation } from 'react-i18next'
-
- type Props = {
- scope: string
- disabled: boolean
- trigger: React.ReactNode
- placement?: Placement
- offset?: OffsetOptions
- isShow: boolean
- onShowChange: (isShow: boolean) => void
- onSelect: (app: App) => void
- apps: App[]
- isLoading: boolean
- hasMore: boolean
- onLoadMore: () => void
- searchText: string
- onSearchChange: (text: string) => void
- }
-
- const AppPicker: FC<Props> = ({
- scope,
- disabled,
- trigger,
- placement = 'right-start',
- offset = 0,
- isShow,
- onShowChange,
- onSelect,
- apps,
- isLoading,
- hasMore,
- onLoadMore,
- searchText,
- onSearchChange,
- }) => {
- const { t } = useTranslation()
- const observerTarget = useRef<HTMLDivElement>(null)
- const observerRef = useRef<IntersectionObserver | null>(null)
- const loadingRef = useRef(false)
-
- const handleIntersection = useCallback((entries: IntersectionObserverEntry[]) => {
- const target = entries[0]
- if (!target.isIntersecting || loadingRef.current || !hasMore || isLoading) return
-
- loadingRef.current = true
- onLoadMore()
- // Reset loading state
- setTimeout(() => {
- loadingRef.current = false
- }, 500)
- }, [hasMore, isLoading, onLoadMore])
-
- useEffect(() => {
- if (!isShow) {
- if (observerRef.current) {
- observerRef.current.disconnect()
- observerRef.current = null
- }
- return
- }
-
- let mutationObserver: MutationObserver | null = null
-
- const setupIntersectionObserver = () => {
- if (!observerTarget.current) return
-
- // Create new observer
- observerRef.current = new IntersectionObserver(handleIntersection, {
- root: null,
- rootMargin: '100px',
- threshold: 0.1,
- })
-
- observerRef.current.observe(observerTarget.current)
- }
-
- // Set up MutationObserver to watch DOM changes
- mutationObserver = new MutationObserver((mutations) => {
- if (observerTarget.current) {
- setupIntersectionObserver()
- mutationObserver?.disconnect()
- }
- })
-
- // Watch body changes since Portal adds content to body
- mutationObserver.observe(document.body, {
- childList: true,
- subtree: true,
- })
-
- // If element exists, set up IntersectionObserver directly
- if (observerTarget.current)
- setupIntersectionObserver()
-
- return () => {
- if (observerRef.current) {
- observerRef.current.disconnect()
- observerRef.current = null
- }
- mutationObserver?.disconnect()
- }
- }, [isShow, handleIntersection])
-
- const getAppType = (app: App) => {
- switch (app.mode) {
- case 'advanced-chat':
- return 'chatflow'
- case 'agent-chat':
- return 'agent'
- case 'chat':
- return 'chat'
- case 'completion':
- return 'completion'
- case 'workflow':
- return 'workflow'
- }
- }
-
- const handleTriggerClick = () => {
- if (disabled) return
- onShowChange(true)
- }
-
- return (
- <PortalToFollowElem
- placement={placement}
- offset={offset}
- open={isShow}
- onOpenChange={onShowChange}
- >
- <PortalToFollowElemTrigger
- onClick={handleTriggerClick}
- >
- {trigger}
- </PortalToFollowElemTrigger>
-
- <PortalToFollowElemContent className='z-[1000]'>
- <div className="relative flex max-h-[400px] min-h-20 w-[356px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm">
- <div className='p-2 pb-1'>
- <Input
- showLeftIcon
- showClearIcon
- value={searchText}
- onChange={e => onSearchChange(e.target.value)}
- onClear={() => onSearchChange('')}
- />
- </div>
- <div className='min-h-0 flex-1 overflow-y-auto p-1'>
- {apps.map(app => (
- <div
- key={app.id}
- className='flex cursor-pointer items-center gap-3 rounded-lg py-1 pl-2 pr-3 hover:bg-state-base-hover'
- onClick={() => onSelect(app)}
- >
- <AppIcon
- className='shrink-0'
- size='xs'
- iconType={app.icon_type}
- icon={app.icon}
- background={app.icon_background}
- imageUrl={app.icon_url}
- />
- <div title={app.name} className='system-sm-medium grow text-components-input-text-filled'>{app.name}</div>
- <div className='system-2xs-medium-uppercase shrink-0 text-text-tertiary'>{getAppType(app)}</div>
- </div>
- ))}
- <div ref={observerTarget} className='h-4 w-full'>
- {isLoading && (
- <div className='flex justify-center py-2'>
- <div className='text-sm text-gray-500'>{t('common.loading')}</div>
- </div>
- )}
- </div>
- </div>
- </div>
- </PortalToFollowElemContent>
- </PortalToFollowElem>
- )
- }
-
- export default React.memo(AppPicker)
|