Files
urbanLifeline/dify/web/app/components/app-sidebar/app-operations.tsx
2025-12-01 17:21:38 +08:00

215 lines
7.1 KiB
TypeScript

import type { JSX } from 'react'
import { cloneElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../base/portal-to-follow-elem'
import { RiMoreLine } from '@remixicon/react'
export type Operation = {
id: string
title: string
icon: JSX.Element
onClick: () => void
type?: 'divider'
}
type AppOperationsProps = {
gap: number
operations?: Operation[]
primaryOperations?: Operation[]
secondaryOperations?: Operation[]
}
const EMPTY_OPERATIONS: Operation[] = []
const AppOperations = ({
operations,
primaryOperations,
secondaryOperations,
gap,
}: AppOperationsProps) => {
const { t } = useTranslation()
const [visibleOpreations, setVisibleOperations] = useState<Operation[]>([])
const [moreOperations, setMoreOperations] = useState<Operation[]>([])
const [showMore, setShowMore] = useState(false)
const navRef = useRef<HTMLDivElement>(null)
const handleTriggerMore = useCallback(() => {
setShowMore(true)
}, [setShowMore])
const primaryOps = useMemo(() => {
if (operations)
return operations
if (primaryOperations)
return primaryOperations
return EMPTY_OPERATIONS
}, [operations, primaryOperations])
const secondaryOps = useMemo(() => {
if (operations)
return EMPTY_OPERATIONS
if (secondaryOperations)
return secondaryOperations
return EMPTY_OPERATIONS
}, [operations, secondaryOperations])
const inlineOperations = primaryOps.filter(operation => operation.type !== 'divider')
useEffect(() => {
const applyState = (visible: Operation[], overflow: Operation[]) => {
const combinedMore = [...overflow, ...secondaryOps]
if (!overflow.length && combinedMore[0]?.type === 'divider')
combinedMore.shift()
setVisibleOperations(visible)
setMoreOperations(combinedMore)
}
const inline = primaryOps.filter(operation => operation.type !== 'divider')
if (!inline.length) {
applyState([], [])
return
}
const navElement = navRef.current
const moreElement = document.getElementById('more-measure')
if (!navElement || !moreElement)
return
let width = 0
const containerWidth = navElement.clientWidth
const moreWidth = moreElement.clientWidth
if (containerWidth === 0 || moreWidth === 0)
return
const updatedEntries: Record<string, boolean> = inline.reduce((pre, cur) => {
pre[cur.id] = false
return pre
}, {} as Record<string, boolean>)
const childrens = Array.from(navElement.children).slice(0, -1)
for (let i = 0; i < childrens.length; i++) {
const child = childrens[i] as HTMLElement
const id = child.dataset.targetid
if (!id) break
const childWidth = child.clientWidth
if (width + gap + childWidth + moreWidth <= containerWidth) {
updatedEntries[id] = true
width += gap + childWidth
}
else {
if (i === childrens.length - 1 && width + childWidth <= containerWidth)
updatedEntries[id] = true
else
updatedEntries[id] = false
break
}
}
const visible = inline.filter(item => updatedEntries[item.id])
const overflow = inline.filter(item => !updatedEntries[item.id])
applyState(visible, overflow)
}, [gap, primaryOps, secondaryOps])
const shouldShowMoreButton = moreOperations.length > 0
return (
<>
<div
aria-hidden="true"
ref={navRef}
className="pointer-events-none flex h-0 items-center self-stretch overflow-hidden"
style={{ gap }}
>
{inlineOperations.map(operation => (
<Button
key={operation.id}
data-targetid={operation.id}
size={'small'}
variant={'secondary'}
className="gap-[1px]"
tabIndex={-1}
>
{cloneElement(operation.icon, { className: 'h-3.5 w-3.5 text-components-button-secondary-text' })}
<span className="system-xs-medium text-components-button-secondary-text">
{operation.title}
</span>
</Button>
))}
<Button
id="more-measure"
size={'small'}
variant={'secondary'}
className="gap-[1px]"
tabIndex={-1}
>
<RiMoreLine className="h-3.5 w-3.5 text-components-button-secondary-text" />
<span className="system-xs-medium text-components-button-secondary-text">
{t('common.operation.more')}
</span>
</Button>
</div>
<div className="flex items-center self-stretch overflow-hidden" style={{ gap }}>
{visibleOpreations.map(operation => (
<Button
key={operation.id}
data-targetid={operation.id}
size={'small'}
variant={'secondary'}
className="gap-[1px]"
onClick={operation.onClick}
>
{cloneElement(operation.icon, { className: 'h-3.5 w-3.5 text-components-button-secondary-text' })}
<span className="system-xs-medium text-components-button-secondary-text">
{operation.title}
</span>
</Button>
))}
{shouldShowMoreButton && (
<PortalToFollowElem
open={showMore}
onOpenChange={setShowMore}
placement="bottom-end"
offset={{ mainAxis: 4 }}
>
<PortalToFollowElemTrigger onClick={handleTriggerMore}>
<Button
size={'small'}
variant={'secondary'}
className="gap-[1px]"
>
<RiMoreLine className="h-3.5 w-3.5 text-components-button-secondary-text" />
<span className="system-xs-medium text-components-button-secondary-text">
{t('common.operation.more')}
</span>
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[30]">
<div className="flex min-w-[264px] flex-col rounded-[12px] border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]">
{moreOperations.map(item => item.type === 'divider'
? (
<div key={item.id} className="my-1 h-px bg-divider-subtle" />
)
: (
<div
key={item.id}
className="flex h-8 cursor-pointer items-center gap-x-1 rounded-lg p-1.5 hover:bg-state-base-hover"
onClick={item.onClick}
>
{cloneElement(item.icon, { className: 'h-4 w-4 text-text-tertiary' })}
<span className="system-md-regular text-text-secondary">{item.title}</span>
</div>
))}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)}
</div>
</>
)
}
export default AppOperations