dify/web/app/components/app-sidebar/app-info/app-operations.tsx
yyh dfcc0f8863
refactor(dify-ui): finish primitive migration from web/base/ui to @langgenius/dify-ui (#35349)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-17 08:46:11 +00:00

220 lines
6.7 KiB
TypeScript

import type { JSX } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { RiMoreLine } from '@remixicon/react'
import { cloneElement, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
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 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-px"
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-px"
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('operation.more', { ns: 'common' })}
</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-px"
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 && (
<DropdownMenu open={showMore} onOpenChange={setShowMore}>
<DropdownMenuTrigger
render={(
<Button
size="small"
variant="secondary"
className="gap-px"
/>
)}
>
<>
<RiMoreLine className="h-3.5 w-3.5 text-components-button-secondary-text" />
<span className="system-xs-medium text-components-button-secondary-text">
{t('operation.more', { ns: 'common' })}
</span>
</>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="min-w-[264px]"
>
{moreOperations.map(item => item.type === 'divider'
? (
<DropdownMenuSeparator key={item.id} />
)
: (
<DropdownMenuItem
key={item.id}
className="gap-x-1 px-1.5"
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>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</>
)
}
export default AppOperations