Browse Source

feat: siderbar operation support portal (#1061)

Joel 1 year ago
parent
commit
9458b8978f

+ 1 - 1
web/app/components/base/confirm/index.tsx

@@ -34,7 +34,7 @@ export default function Confirm({
   const cancelTxt = cancelText || `${t('common.operation.cancel')}`
   return (
     <Transition appear show={isShow} as={Fragment}>
-      <Dialog as="div" className="relative z-10" onClose={onClose} onClick={e => e.preventDefault()}>
+      <Dialog as="div" className="relative z-[100]" onClose={onClose} onClick={e => e.preventDefault()}>
         <Transition.Child
           as={Fragment}
           enter="ease-out duration-300"

+ 156 - 73
web/app/components/base/portal-to-follow-elem/index.tsx

@@ -1,84 +1,167 @@
 'use client'
-import { useBoolean } from 'ahooks'
-import React, { useEffect, useRef, useState } from 'react'
-import type { FC } from 'react'
-import { createRoot } from 'react-dom/client'
-
-type IPortalToFollowElementProps = {
-  portalElem: React.ReactNode
-  children: React.ReactNode
-  controlShow?: number
-  controlHide?: number
+import React from 'react'
+import {
+  FloatingPortal,
+  autoUpdate,
+  flip,
+  offset,
+  shift,
+  useDismiss,
+  useFloating,
+  useFocus,
+  useHover,
+  useInteractions,
+  useMergeRefs,
+  useRole,
+} from '@floating-ui/react'
+
+import type { Placement } from '@floating-ui/react'
+
+type PortalToFollowElemOptions = {
+  /*
+  * top, bottom, left, right
+  * start, end. Default is middle
+  * combine: top-start, top-end
+  */
+  placement?: Placement
+  open?: boolean
+  offset?: number
+  onOpenChange?: (open: boolean) => void
 }
 
-const PortalToFollowElement: FC<IPortalToFollowElementProps> = ({
-  portalElem,
+export function usePortalToFollowElem({
+  placement = 'bottom',
+  open,
+  offset: offsetValue = 0,
+  onOpenChange: setControlledOpen,
+}: PortalToFollowElemOptions = {}) {
+  const setOpen = setControlledOpen
+
+  const data = useFloating({
+    placement,
+    open,
+    onOpenChange: setOpen,
+    whileElementsMounted: autoUpdate,
+    middleware: [
+      offset(offsetValue),
+      flip({
+        crossAxis: placement.includes('-'),
+        fallbackAxisSideDirection: 'start',
+        padding: 5,
+      }),
+      shift({ padding: 5 }),
+    ],
+  })
+
+  const context = data.context
+
+  const hover = useHover(context, {
+    move: false,
+    enabled: open == null,
+  })
+  const focus = useFocus(context, {
+    enabled: open == null,
+  })
+  const dismiss = useDismiss(context)
+  const role = useRole(context, { role: 'tooltip' })
+
+  const interactions = useInteractions([hover, focus, dismiss, role])
+
+  return React.useMemo(
+    () => ({
+      open,
+      setOpen,
+      ...interactions,
+      ...data,
+    }),
+    [open, setOpen, interactions, data],
+  )
+}
+
+type ContextType = ReturnType<typeof usePortalToFollowElem> | null
+
+const PortalToFollowElemContext = React.createContext<ContextType>(null)
+
+export function usePortalToFollowElemContext() {
+  const context = React.useContext(PortalToFollowElemContext)
+
+  if (context == null)
+    throw new Error('PortalToFollowElem components must be wrapped in <PortalToFollowElem />')
+
+  return context
+}
+
+export function PortalToFollowElem({
   children,
-  controlShow,
-  controlHide,
-}) => {
-  const [isShowContent, { setTrue: showContent, setFalse: hideContent, toggle: toggleContent }] = useBoolean(false)
-  const [wrapElem, setWrapElem] = useState<HTMLDivElement | null>(null)
-
-  useEffect(() => {
-    if (controlShow)
-      showContent()
-  }, [controlShow])
-
-  useEffect(() => {
-    if (controlHide)
-      hideContent()
-  }, [controlHide])
-
-  // todo use click outside hidden
-  const triggerElemRef = useRef<HTMLDivElement>(null)
-
-  const calLoc = () => {
-    const triggerElem = triggerElemRef.current
-    if (!triggerElem) {
-      return {
-        display: 'none',
-      }
-    }
-    const {
-      left: triggerLeft,
-      top: triggerTop,
-      height,
-    } = triggerElem.getBoundingClientRect()
-
-    return {
-      position: 'fixed',
-      left: triggerLeft,
-      top: triggerTop + height,
-      zIndex: 999,
-    }
-  }
+  ...options
+}: { children: React.ReactNode } & PortalToFollowElemOptions) {
+  // This can accept any props as options, e.g. `placement`,
+  // or other positioning options.
+  const tooltip = usePortalToFollowElem(options)
+  return (
+    <PortalToFollowElemContext.Provider value={tooltip}>
+      {children}
+    </PortalToFollowElemContext.Provider>
+  )
+}
+
+export const PortalToFollowElemTrigger = React.forwardRef<
+HTMLElement,
+React.HTMLProps<HTMLElement> & { asChild?: boolean }
+>(({ children, asChild = false, ...props }, propRef) => {
+  const context = usePortalToFollowElemContext()
+  const childrenRef = (children as any).ref
+  const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef])
 
-  useEffect(() => {
-    if (isShowContent) {
-      const holder = document.createElement('div')
-      const root = createRoot(holder)
-      const style = calLoc()
-      root.render(
-        <div style={style as React.CSSProperties}>
-          {portalElem}
-        </div>,
-      )
-      document.body.appendChild(holder)
-      setWrapElem(holder)
-      console.log(holder)
-    }
-    else {
-      wrapElem?.remove?.()
-      setWrapElem(null)
-    }
-  }, [isShowContent])
+  // `asChild` allows the user to pass any element as the anchor
+  if (asChild && React.isValidElement(children)) {
+    return React.cloneElement(
+      children,
+      context.getReferenceProps({
+        ref,
+        ...props,
+        ...children.props,
+        'data-state': context.open ? 'open' : 'closed',
+      }),
+    )
+  }
 
   return (
-    <div ref={triggerElemRef as React.RefObject<HTMLDivElement>} onClick={toggleContent}>
+    <div
+      ref={ref}
+      className='inline-block'
+      // The user can style the trigger based on the state
+      data-state={context.open ? 'open' : 'closed'}
+      {...context.getReferenceProps(props)}
+    >
       {children}
     </div>
   )
-}
+})
+PortalToFollowElemTrigger.displayName = 'PortalToFollowElemTrigger'
+
+export const PortalToFollowElemContent = React.forwardRef<
+HTMLDivElement,
+React.HTMLProps<HTMLDivElement>
+>(({ style, ...props }, propRef) => {
+  const context = usePortalToFollowElemContext()
+  const ref = useMergeRefs([context.refs.setFloating, propRef])
+
+  if (!context.open)
+    return null
+
+  return (
+    <FloatingPortal>
+      <div
+        ref={ref}
+        style={{
+          ...context.floatingStyles,
+          ...style,
+        }}
+        {...context.getFloatingProps(props)}
+      />
+    </FloatingPortal>
+  )
+})
 
-export default React.memo(PortalToFollowElement)
+PortalToFollowElemContent.displayName = 'PortalToFollowElemContent'

+ 0 - 73
web/app/components/base/select-support-portal/index.tsx

@@ -1,73 +0,0 @@
-'use client'
-import React, { FC, useState } from 'react'
-import PortalToFollowElem from '../portal-to-follow-elem'
-import { ChevronDownIcon, CheckIcon } from '@heroicons/react/24/outline'
-import cn from 'classnames'
-
-export interface ISelectProps<T> {
-  value: T
-  items: { value: T, name: string }[]
-  onChange: (value: T) => void
-}
-
-const Select: FC<ISelectProps<string | number>> = ({
-  value,
-  items,
-  onChange
-}) => {
-  const [controlHide, setControlHide] = useState(0)
-  const itemsElement = items.map(item => {
-    const isSelected = item.value === value
-    return (
-      <div
-        key={item.value}
-        className={cn('relative h-9 leading-9 px-10 rounded-lg text-sm text-gray-700 hover:bg-gray-100')}
-        onClick={() => {
-          onChange(item.value)
-          setControlHide(Date.now())
-        }}
-      >
-        {isSelected && (
-          <div className='absolute left-4 top-1/2 translate-y-[-50%] flex items-center justify-center w-4 h-4 text-primary-600'>
-            <CheckIcon width={16} height={16}></CheckIcon>
-          </div>
-        )}
-        {item.name}
-      </div>
-    )
-  })
-  return (
-    <div>
-      <PortalToFollowElem
-        portalElem={(
-          <div
-            className='p-1 rounded-lg bg-white cursor-pointer'
-            style={{
-              boxShadow: '0px 10px 15px -3px rgba(0, 0, 0, 0.1), 0px 4px 6px rgba(0, 0, 0, 0.05)'
-            }}
-          >
-            {itemsElement}
-          </div>
-        )}
-        controlHide={controlHide}
-      >
-        <div className='relative '>
-          <div className='flex items-center h-9 px-3 gap-1 cursor-pointer  hover:bg-gray-50'>
-            <div className='text-sm text-gray-700'>{items.find(i => i.value === value)?.name}</div>
-            <ChevronDownIcon width={16} height={16} />
-          </div>
-          {/* <div
-            className='absolute z-50 left-0 top-9 p-1 w-[112px] rounded-lg bg-white'
-            style={{
-              boxShadow: '0px 10px 15px -3px rgba(0, 0, 0, 0.1), 0px 4px 6px rgba(0, 0, 0, 0.05)'
-            }}
-          >
-            {itemsElement}
-          </div> */}
-        </div>
-      </PortalToFollowElem>
-    </div>
-
-  )
-}
-export default React.memo(Select)

+ 37 - 16
web/app/components/explore/item-operation/index.tsx

@@ -1,15 +1,17 @@
 'use client'
 import type { FC } from 'react'
-import React from 'react'
+import React, { useEffect, useRef, useState } from 'react'
 import cn from 'classnames'
 import { useTranslation } from 'react-i18next'
+import { useBoolean } from 'ahooks'
 import { Edit03, Pin02, Trash03 } from '../../base/icons/src/vender/line/general'
 
 import s from './style.module.css'
-import Popover from '@/app/components/base/popover'
+import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
 
 export type IItemOperationProps = {
   className?: string
+  isItemHovering: boolean
   isPinned: boolean
   isShowRenameConversation?: boolean
   onRenameConversation?: () => void
@@ -20,6 +22,7 @@ export type IItemOperationProps = {
 
 const ItemOperation: FC<IItemOperationProps> = ({
   className,
+  isItemHovering,
   isPinned,
   togglePin,
   isShowRenameConversation,
@@ -28,13 +31,37 @@ const ItemOperation: FC<IItemOperationProps> = ({
   onDelete,
 }) => {
   const { t } = useTranslation()
-
+  const [open, setOpen] = useState(false)
+  const ref = useRef(null)
+  const [isHovering, { setTrue: setIsHovering, setFalse: setNotHovering }] = useBoolean(false)
+  useEffect(() => {
+    if (!isItemHovering && !isHovering)
+      setOpen(false)
+  }, [isItemHovering, isHovering])
   return (
-    <Popover
-      htmlContent={
-        <div className='w-full py-1' onClick={(e) => {
-          e.stopPropagation()
-        }}>
+    <PortalToFollowElem
+      open={open}
+      onOpenChange={setOpen}
+      placement='bottom-end'
+      offset={4}
+    >
+      <PortalToFollowElemTrigger
+        onClick={() => setOpen(v => !v)}
+      >
+        <div className={cn(className, s.btn, 'h-6 w-6 rounded-md border-none py-1', open && `${s.open} !bg-gray-100 !shadow-none`)}></div>
+      </PortalToFollowElemTrigger>
+      <PortalToFollowElemContent
+        className="z-50"
+      >
+        <div
+          ref={ref}
+          className={'min-w-[120px] p-1 bg-white rounded-lg border border--gray-200 shadow-lg'}
+          onMouseEnter={setIsHovering}
+          onMouseLeave={setNotHovering}
+          onClick={(e) => {
+            e.stopPropagation()
+          }}
+        >
           <div className={cn(s.actionItem, 'hover:bg-gray-50 group')} onClick={togglePin}>
             <Pin02 className='shrink-0 w-4 h-4 text-gray-500'/>
             <span className={s.actionName}>{isPinned ? t('explore.sidebar.action.unpin') : t('explore.sidebar.action.pin')}</span>
@@ -51,15 +78,9 @@ const ItemOperation: FC<IItemOperationProps> = ({
               <span className={cn(s.actionName, s.deleteActionItemChild)}>{t('explore.sidebar.action.delete')}</span>
             </div>
           )}
-
         </div>
-      }
-      trigger='click'
-      position='br'
-      btnElement={<div />}
-      btnClassName={open => cn(className, s.btn, 'h-6 w-6 rounded-md border-none py-1', open && '!bg-gray-100 !shadow-none')}
-      className={'!w-[120px] !px-0 h-fit !z-20'}
-    />
+      </PortalToFollowElemContent>
+    </PortalToFollowElem>
   )
 }
 export default React.memo(ItemOperation)

+ 2 - 4
web/app/components/explore/item-operation/style.module.css

@@ -2,7 +2,6 @@
   @apply h-9 py-2 px-3 mx-1 flex items-center gap-2 rounded-lg cursor-pointer;
 }
 
-
 .actionName {
   @apply text-gray-700 text-sm;
 }
@@ -19,14 +18,13 @@
   mask-image: url(~@/assets/action.svg);
 }
 
-body .btn {
+body .btn.open,
+body .btn:hover {
   background: url(~@/assets/action.svg) center center no-repeat transparent;
   background-size: 16px 16px;
-  /* mask-image: ; */
 }
 
 body .btn:hover {
-  /* background-image: ; */
   background-color: #F2F4F7;
 }
 

+ 9 - 11
web/app/components/explore/sidebar/app-nav-item/index.tsx

@@ -1,6 +1,9 @@
 'use client'
 import cn from 'classnames'
+import React, { useRef } from 'react'
+
 import { useRouter } from 'next/navigation'
+import { useHover } from 'ahooks'
 import s from './style.module.css'
 import ItemOperation from '@/app/components/explore/item-operation'
 import AppIcon from '@/app/components/base/app-icon'
@@ -30,35 +33,30 @@ export default function AppNavItem({
 }: IAppNavItemProps) {
   const router = useRouter()
   const url = `/explore/installed/${id}`
-
+  const ref = useRef(null)
+  const isHovering = useHover(ref)
   return (
     <div
+      ref={ref}
       key={id}
       className={cn(
         s.item,
         isSelected ? s.active : 'hover:bg-gray-200',
-        'flex h-8 justify-between px-2 rounded-lg text-sm font-normal ',
+        'flex h-8 items-center justify-between px-2 rounded-lg text-sm font-normal ',
       )}
       onClick={() => {
         router.push(url) // use Link causes popup item always trigger jump. Can not be solved by e.stopPropagation().
       }}
     >
       <div className='flex items-center space-x-2 w-0 grow'>
-        {/* <div
-          className={cn(
-            'shrink-0 mr-2 h-6 w-6 rounded-md border bg-[#D5F5F6]',
-          )}
-          style={{
-            borderColor: '0.5px solid rgba(0, 0, 0, 0.05)'
-          }}
-        /> */}
         <AppIcon size='tiny' icon={icon} background={icon_background} />
         <div className='overflow-hidden text-ellipsis whitespace-nowrap'>{name}</div>
       </div>
       {
-        <div className={cn(s.opBtn, 'shrink-0')} onClick={e => e.stopPropagation()}>
+        <div className='shrink-0 h-6' onClick={e => e.stopPropagation()}>
           <ItemOperation
             isPinned={isPinned}
+            isItemHovering={isHovering}
             togglePin={togglePin}
             isShowDelete={!uninstallable && !isSelected}
             onDelete={() => onDelete(id)}

+ 0 - 8
web/app/components/explore/sidebar/app-nav-item/style.module.css

@@ -6,12 +6,4 @@
   background: #FFFFFF;
   color: #344054;
   font-weight: 500;
-}
-
-.opBtn {
-  visibility: hidden;
-}
-
-.item:hover .opBtn {
-  visibility: visible;
 }

+ 1 - 1
web/app/components/explore/sidebar/index.tsx

@@ -106,7 +106,7 @@ const SideBar: FC<{
       {installedApps.length > 0 && (
         <div className='mt-10'>
           <div className='pl-2 text-xs text-gray-500 font-medium uppercase'>{t('explore.sidebar.workspace')}</div>
-          <div className='mt-3 space-y-1 overflow-y-auto overflow-x-hidden pb-20'
+          <div className='mt-3 space-y-1 overflow-y-auto overflow-x-hidden'
             style={{
               maxHeight: 'calc(100vh - 250px)',
             }}

+ 11 - 44
web/app/components/share/chat/sidebar/list/index.tsx

@@ -1,19 +1,15 @@
 'use client'
 import type { FC } from 'react'
 import React, { useRef, useState } from 'react'
-import {
-  ChatBubbleOvalLeftEllipsisIcon,
-} from '@heroicons/react/24/outline'
+
 import { useBoolean, useInfiniteScroll } from 'ahooks'
-import { ChatBubbleOvalLeftEllipsisIcon as ChatBubbleOvalLeftEllipsisSolidIcon } from '@heroicons/react/24/solid'
 import cn from 'classnames'
 import { useTranslation } from 'react-i18next'
 import RenameModal from '../rename-modal'
-import s from './style.module.css'
+import Item from './item'
 import type { ConversationItem } from '@/models/share'
 import { fetchConversations, renameConversation } from '@/service/share'
 import { fetchConversations as fetchUniversalConversations, renameConversation as renameUniversalConversation } from '@/service/universal-chat'
-import ItemOperation from '@/app/components/explore/item-operation'
 import Toast from '@/app/components/base/toast'
 
 export type IListProps = {
@@ -52,7 +48,6 @@ const List: FC<IListProps> = ({
   onDelete,
 }) => {
   const { t } = useTranslation()
-
   const listRef = useRef<HTMLDivElement>(null)
 
   useInfiniteScroll(
@@ -130,45 +125,17 @@ const List: FC<IListProps> = ({
     >
       {list.map((item) => {
         const isCurrent = item.id === currentId
-        const ItemIcon
-            = isCurrent ? ChatBubbleOvalLeftEllipsisSolidIcon : ChatBubbleOvalLeftEllipsisIcon
         return (
-          <div
-            onClick={() => onCurrentIdChange(item.id)}
+          <Item
             key={item.id}
-            className={cn(s.item,
-              isCurrent
-                ? 'bg-primary-50 text-primary-600'
-                : 'text-gray-700 hover:bg-gray-200 hover:text-gray-700',
-              'group flex justify-between items-center rounded-md px-2 py-2 text-sm font-medium cursor-pointer',
-            )}
-          >
-            <div className='flex items-center w-0 grow'>
-              <ItemIcon
-                className={cn(
-                  isCurrent
-                    ? 'text-primary-600'
-                    : 'text-gray-400 group-hover:text-gray-500',
-                  'mr-3 h-5 w-5 flex-shrink-0',
-                )}
-                aria-hidden="true"
-              />
-              <span>{item.name}</span>
-            </div>
-
-            {item.id !== '-1' && (
-              <div className={cn(s.opBtn, 'shrink-0')} onClick={e => e.stopPropagation()}>
-                <ItemOperation
-                  isPinned={isPinned}
-                  togglePin={() => onPinChanged(item.id)}
-                  isShowRenameConversation
-                  onRenameConversation={() => showRename(item)}
-                  isShowDelete
-                  onDelete={() => onDelete(item.id)}
-                />
-              </div>
-            )}
-          </div>
+            item={item}
+            isCurrent={isCurrent}
+            onClick={onCurrentIdChange}
+            isPinned={isPinned}
+            togglePin={onPinChanged}
+            onDelete={onDelete}
+            onRenameConversation={showRename}
+          />
         )
       })}
       {isShowRename && (

+ 77 - 0
web/app/components/share/chat/sidebar/list/item.tsx

@@ -0,0 +1,77 @@
+'use client'
+import type { FC } from 'react'
+import React, { useRef } from 'react'
+import cn from 'classnames'
+import { ChatBubbleOvalLeftEllipsisIcon as ChatBubbleOvalLeftEllipsisSolidIcon } from '@heroicons/react/24/solid'
+import {
+  ChatBubbleOvalLeftEllipsisIcon,
+} from '@heroicons/react/24/outline'
+import { useHover } from 'ahooks'
+import ItemOperation from '@/app/components/explore/item-operation'
+import type { ConversationItem } from '@/models/share'
+
+export type IItemProps = {
+  onClick: (id: string) => void
+  item: ConversationItem
+  isCurrent: boolean
+  isPinned: boolean
+  togglePin: (id: string) => void
+  onDelete: (id: string) => void
+  onRenameConversation: (item: ConversationItem) => void
+}
+
+const Item: FC<IItemProps> = ({
+  isCurrent,
+  item,
+  onClick,
+  isPinned,
+  togglePin,
+  onDelete,
+  onRenameConversation,
+}) => {
+  const ItemIcon = isCurrent ? ChatBubbleOvalLeftEllipsisSolidIcon : ChatBubbleOvalLeftEllipsisIcon
+  const ref = useRef(null)
+  const isHovering = useHover(ref)
+
+  return (
+    <div
+      ref={ref}
+      onClick={() => onClick(item.id)}
+      key={item.id}
+      className={cn(
+        isCurrent
+          ? 'bg-primary-50 text-primary-600'
+          : 'text-gray-700 hover:bg-gray-200 hover:text-gray-700',
+        'group flex justify-between items-center rounded-md px-2 py-2 text-sm font-medium cursor-pointer',
+      )}
+    >
+      <div className='flex items-center w-0 grow'>
+        <ItemIcon
+          className={cn(
+            isCurrent
+              ? 'text-primary-600'
+              : 'text-gray-400 group-hover:text-gray-500',
+            'mr-3 h-5 w-5 flex-shrink-0',
+          )}
+          aria-hidden="true"
+        />
+        <span>{item.name}</span>
+      </div>
+
+      {item.id !== '-1' && (
+        <div className='shrink-0 h-6' onClick={e => e.stopPropagation()}>
+          <ItemOperation
+            isPinned={isPinned}
+            isItemHovering={isHovering}
+            togglePin={() => togglePin(item.id)}
+            isShowDelete
+            isShowRenameConversation
+            onRenameConversation={() => onRenameConversation(item)}
+            onDelete={() => onDelete(item.id)}
+          />
+        </div>
+      )}
+    </div>
+  )
+}
+export default React.memo(Item)

+ 0 - 7
web/app/components/share/chat/sidebar/list/style.module.css

@@ -1,7 +0,0 @@
-.opBtn {
-  visibility: hidden;
-}
-
-.item:hover .opBtn {
-  visibility: visible;
-}

+ 1 - 0
web/package.json

@@ -16,6 +16,7 @@
   "dependencies": {
     "@babel/runtime": "^7.22.3",
     "@emoji-mart/data": "^1.1.2",
+    "@floating-ui/react": "^0.25.2",
     "@formatjs/intl-localematcher": "^0.2.32",
     "@headlessui/react": "^1.7.13",
     "@heroicons/react": "^2.0.16",

File diff suppressed because it is too large
+ 1676 - 1349
web/yarn.lock