feat: add @cherrystudio/ui component library

- Introduced a new UI component library for Cherry Studio, including various components such as buttons, inputs, and layout elements.
- Updated configuration files to include the new library and its dependencies.
- Enhanced the project structure to support modular imports and TypeScript definitions for better development experience.
This commit is contained in:
MyPrototypeWhat 2025-09-15 12:03:39 +08:00
parent 0038280fba
commit d3028f1dd1
26 changed files with 1965 additions and 4 deletions

View File

@ -103,7 +103,8 @@ export default defineConfig({
'@cherrystudio/ai-core/provider': resolve('packages/aiCore/src/core/providers'),
'@cherrystudio/ai-core/built-in/plugins': resolve('packages/aiCore/src/core/plugins/built-in'),
'@cherrystudio/ai-core': resolve('packages/aiCore/src'),
'@cherrystudio/extension-table-plus': resolve('packages/extension-table-plus/src')
'@cherrystudio/extension-table-plus': resolve('packages/extension-table-plus/src'),
'@cherrystudio/ui': resolve('packages/ui/src')
}
},
optimizeDeps: {

View File

@ -120,6 +120,7 @@
"@cherrystudio/embedjs-ollama": "^0.1.31",
"@cherrystudio/embedjs-openai": "^0.1.31",
"@cherrystudio/extension-table-plus": "workspace:^",
"@cherrystudio/ui": "workspace:*",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",

220
packages/ui/README.md Normal file
View File

@ -0,0 +1,220 @@
# @cherrystudio/ui
Cherry Studio UI 组件库 - 为 Cherry Studio 设计的 React 组件集合
## 特性
- 🎨 基于 Tailwind CSS 的现代化设计
- 📦 支持 ESM 和 CJS 格式
- 🔷 完整的 TypeScript 支持
- 🚀 可以作为 npm 包发布
- 🔧 开箱即用的常用 hooks 和工具函数
## 安装
```bash
# 安装组件库
npm install @cherrystudio/ui
# 安装必需的 peer dependencies
npm install @heroui/react framer-motion react react-dom tailwindcss
```
## 配置
### 1. Tailwind CSS 配置
在你的项目根目录创建 `tailwind.config.js` 文件:
```javascript
const { heroui } = require('@heroui/react')
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
// 你的应用内容
'./src/**/*.{js,ts,jsx,tsx}',
'./app/**/*.{js,ts,jsx,tsx}',
'./pages/**/*.{js,ts,jsx,tsx}',
'./components/**/*.{js,ts,jsx,tsx}',
// 包含 @cherrystudio/ui 组件
'./node_modules/@cherrystudio/ui/dist/**/*.{js,ts,jsx,tsx}',
// 包含 HeroUI 主题
'./node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}'
],
theme: {
extend: {
// 你的自定义主题扩展
}
},
darkMode: 'class',
plugins: [
heroui({
// HeroUI 主题配置
// 参考: https://heroui.com/docs/customization/theme
})
]
}
```
### 2. CSS 导入
在你的主 CSS 文件中导入 Tailwind
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
```
### 3. Provider 配置
在你的 App 根组件中添加 HeroUI Provider
```tsx
import { HeroUIProvider } from '@heroui/react'
function App() {
return (
<HeroUIProvider>
{/* 你的应用内容 */}
</HeroUIProvider>
)
}
```
## 使用
### 基础组件
```tsx
import { Button, Input } from '@cherrystudio/ui'
function App() {
return (
<div>
<Button variant="primary" size="md">
点击我
</Button>
<Input
type="text"
placeholder="请输入内容"
onChange={(value) => console.log(value)}
/>
</div>
)
}
```
### 分模块导入
```tsx
// 只导入组件
import { Button } from '@cherrystudio/ui/components'
// 只导入 hooks
import { useDebounce, useLocalStorage } from '@cherrystudio/ui/hooks'
// 只导入工具函数
import { cn, formatFileSize } from '@cherrystudio/ui/utils'
```
## 开发
```bash
# 安装依赖
yarn install
# 开发模式(监听文件变化)
yarn dev
# 构建
yarn build
# 类型检查
yarn type-check
# 运行测试
yarn test
```
## 目录结构
```text
src/
├── components/ # React 组件
│ ├── Button/ # 按钮组件
│ ├── Input/ # 输入框组件
│ └── index.ts # 组件导出
├── hooks/ # React Hooks
├── utils/ # 工具函数
├── types/ # 类型定义
└── index.ts # 主入口文件
```
## 组件列表
### Button 按钮
支持多种变体和尺寸的按钮组件。
**Props:**
- `variant`: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'
- `size`: 'sm' | 'md' | 'lg'
- `loading`: boolean
- `fullWidth`: boolean
- `leftIcon` / `rightIcon`: React.ReactNode
### Input 输入框
带有错误处理和密码显示切换的输入框组件。
**Props:**
- `type`: 'text' | 'password' | 'email' | 'number'
- `error`: boolean
- `errorMessage`: string
- `onChange`: (value: string) => void
## Hooks
### useDebounce
防抖处理,延迟执行状态更新。
### useLocalStorage
本地存储的 React Hook 封装。
### useClickOutside
检测点击元素外部区域。
### useCopyToClipboard
复制文本到剪贴板。
## 工具函数
### cn(...inputs)
基于 clsx 的类名合并工具,支持条件类名。
### formatFileSize(bytes)
格式化文件大小显示。
### debounce(func, delay)
防抖函数。
### throttle(func, delay)
节流函数。
## 许可证
MIT

97
packages/ui/package.json Normal file
View File

@ -0,0 +1,97 @@
{
"name": "@cherrystudio/ui",
"version": "1.0.0-alpha.1",
"description": "Cherry Studio UI Component Library - React Components for Cherry Studio",
"main": "dist/index.js",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"react-native": "dist/index.js",
"scripts": {
"build": "tsdown",
"dev": "tsc -w",
"clean": "rm -rf dist",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint src --ext .ts,.tsx --fix",
"type-check": "tsc --noEmit"
},
"keywords": [
"ui",
"components",
"react",
"tailwindcss",
"typescript",
"cherry-studio"
],
"author": "Cherry Studio",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/CherryHQ/cherry-studio.git"
},
"bugs": {
"url": "https://github.com/CherryHQ/cherry-studio/issues"
},
"homepage": "https://github.com/CherryHQ/cherry-studio#readme",
"peerDependencies": {
"@heroui/react": "^2.8.4",
"framer-motion": "^11.0.0 || ^12.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwindcss": "^4.1.13"
},
"dependencies": {
"clsx": "^2.1.1",
"lucide-react": "^0.525.0"
},
"devDependencies": {
"@heroui/react": "^2.8.4",
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4",
"framer-motion": "^12.23.12",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tsdown": "^0.12.9",
"typescript": "^5.6.2",
"vitest": "^3.2.4"
},
"sideEffects": false,
"engines": {
"node": ">=18.0.0"
},
"files": [
"dist",
"README.md"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"react-native": "./dist/index.js",
"import": "./dist/index.mjs",
"require": "./dist/index.js",
"default": "./dist/index.js"
},
"./components": {
"types": "./dist/components/index.d.ts",
"react-native": "./dist/components/index.js",
"import": "./dist/components/index.mjs",
"require": "./dist/components/index.js",
"default": "./dist/components/index.js"
},
"./hooks": {
"types": "./dist/hooks/index.d.ts",
"react-native": "./dist/hooks/index.js",
"import": "./dist/hooks/index.mjs",
"require": "./dist/hooks/index.js",
"default": "./dist/hooks/index.js"
},
"./utils": {
"types": "./dist/utils/index.d.ts",
"react-native": "./dist/utils/index.js",
"import": "./dist/utils/index.mjs",
"require": "./dist/utils/index.js",
"default": "./dist/utils/index.js"
}
},
"packageManager": "yarn@4.9.1"
}

View File

@ -0,0 +1,37 @@
// Original: src/renderer/src/components/DividerWithText.tsx
import React, { CSSProperties } from 'react'
import styled from 'styled-components'
interface DividerWithTextProps {
text: string
style?: CSSProperties
}
const DividerWithText: React.FC<DividerWithTextProps> = ({ text, style }) => {
return (
<DividerContainer style={style}>
<DividerText>{text}</DividerText>
<DividerLine />
</DividerContainer>
)
}
const DividerContainer = styled.div`
display: flex;
align-items: center;
margin: 0px 0;
`
const DividerText = styled.span`
font-size: 12px;
color: var(--color-text-2);
margin-right: 8px;
`
const DividerLine = styled.div`
flex: 1;
height: 1px;
background-color: var(--color-border);
`
export default DividerWithText

View File

@ -0,0 +1,45 @@
// Original: src/renderer/src/components/IndicatorLight.tsx
import React from 'react'
import styled from 'styled-components'
interface IndicatorLightProps {
color: string
size?: number
shadow?: boolean
style?: React.CSSProperties
animation?: boolean
}
const Light = styled.div<{
color: string
size: number
shadow?: boolean
style?: React.CSSProperties
animation?: boolean
}>`
width: ${({ size }) => size}px;
height: ${({ size }) => size}px;
border-radius: 50%;
background-color: ${({ color }) => color};
box-shadow: ${({ shadow, color }) => (shadow ? `0 0 6px ${color}` : 'none')};
animation: ${({ animation }) => (animation ? 'pulse 2s infinite' : 'none')};
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.6;
}
100% {
opacity: 1;
}
}
`
const IndicatorLight: React.FC<IndicatorLightProps> = ({ color, size = 8, shadow = true, style, animation = true }) => {
const actualColor = color === 'green' ? '#22c55e' : color
return <Light color={actualColor} size={size} shadow={shadow} style={style} animation={animation} />
}
export default IndicatorLight

View File

@ -0,0 +1,45 @@
// Original: src/renderer/src/components/Spinner.tsx
import { motion } from 'framer-motion'
import { Search } from 'lucide-react'
import styled from 'styled-components'
interface Props {
text: React.ReactNode
}
// Define variants for the spinner animation
const spinnerVariants = {
defaultColor: {
color: '#2a2a2a'
},
dimmed: {
color: '#8C9296'
}
}
export default function Spinner({ text }: Props) {
return (
<Searching
variants={spinnerVariants}
initial="defaultColor"
animate={['defaultColor', 'dimmed']}
transition={{
duration: 0.8,
repeat: Infinity,
repeatType: 'reverse',
ease: 'easeInOut'
}}>
<Search size={16} style={{ color: 'unset' }} />
<span>{text}</span>
</Searching>
)
}
const SearchWrapper = styled.div`
display: flex;
align-items: center;
gap: 4px;
/* font-size: 14px; */
padding: 0px;
/* padding-left: 0; */
`
const Searching = motion.create(SearchWrapper)

View File

@ -0,0 +1,23 @@
// Original: src/renderer/src/components/TextBadge.tsx
import { FC } from 'react'
import styled from 'styled-components'
interface Props {
text: string
style?: React.CSSProperties
}
const TextBadge: FC<Props> = ({ text, style }) => {
return <Container style={style}>{text}</Container>
}
const Container = styled.span`
font-size: 12px;
color: var(--color-primary);
background: var(--color-primary-bg);
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
`
export default TextBadge

View File

@ -0,0 +1,36 @@
// Original: src/renderer/src/components/Ellipsis/index.tsx
import type { HTMLAttributes } from 'react'
import styled, { css } from 'styled-components'
type Props = {
maxLine?: number
} & HTMLAttributes<HTMLDivElement>
const Ellipsis = (props: Props) => {
const { maxLine = 1, children, ...rest } = props
return (
<EllipsisContainer $maxLine={maxLine} {...rest}>
{children}
</EllipsisContainer>
)
}
const multiLineEllipsis = css<{ $maxLine: number }>`
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: ${({ $maxLine }) => $maxLine};
overflow-wrap: break-word;
`
const singleLineEllipsis = css`
display: block;
white-space: nowrap;
`
const EllipsisContainer = styled.div<{ $maxLine: number }>`
overflow: hidden;
text-overflow: ellipsis;
${({ $maxLine }) => ($maxLine > 1 ? multiLineEllipsis : singleLineEllipsis)}
`
export default Ellipsis

View File

@ -0,0 +1,52 @@
// Original: src/renderer/src/components/ExpandableText.tsx
import { Button } from 'antd'
import { memo, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface ExpandableTextProps {
text: string
style?: React.CSSProperties
}
const ExpandableText = ({
ref,
text,
style
}: ExpandableTextProps & { ref?: React.RefObject<HTMLParagraphElement> | null }) => {
const { t } = useTranslation()
const [isExpanded, setIsExpanded] = useState(false)
const toggleExpand = useCallback(() => {
setIsExpanded((prev) => !prev)
}, [])
const button = useMemo(() => {
return (
<Button type="link" onClick={toggleExpand} style={{ alignSelf: 'flex-end' }}>
{isExpanded ? t('common.collapse') : t('common.expand')}
</Button>
)
}, [isExpanded, t, toggleExpand])
return (
<Container ref={ref} style={style} $expanded={isExpanded}>
<TextContainer $expanded={isExpanded}>{text}</TextContainer>
{button}
</Container>
)
}
const Container = styled.div<{ $expanded?: boolean }>`
display: flex;
flex-direction: ${(props) => (props.$expanded ? 'column' : 'row')};
`
const TextContainer = styled.div<{ $expanded?: boolean }>`
overflow: hidden;
text-overflow: ${(props) => (props.$expanded ? 'unset' : 'ellipsis')};
white-space: ${(props) => (props.$expanded ? 'normal' : 'nowrap')};
line-height: ${(props) => (props.$expanded ? 'unset' : '30px')};
`
export default memo(ExpandableText)

View File

@ -0,0 +1,32 @@
// Original: src/renderer/src/components/Icons/ToolsCallingIcon.tsx
import { ToolOutlined } from '@ant-design/icons'
import { Tooltip } from 'antd'
import React, { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
const ToolsCallingIcon: FC<React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>> = (props) => {
const { t } = useTranslation()
return (
<Container>
<Tooltip title={t('models.function_calling')} placement="top">
<Icon {...(props as any)} />
</Tooltip>
</Container>
)
}
const Container = styled.div`
display: flex;
justify-content: center;
align-items: center;
`
const Icon = styled(ToolOutlined)`
color: var(--color-primary);
font-size: 15px;
margin-right: 6px;
`
export default ToolsCallingIcon

View File

@ -0,0 +1,31 @@
// Original: src/renderer/src/components/Icons/VisionIcon.tsx
import { Tooltip } from 'antd'
import { ImageIcon } from 'lucide-react'
import React, { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
const VisionIcon: FC<React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>> = (props) => {
const { t } = useTranslation()
return (
<Container>
<Tooltip title={t('models.type.vision')} placement="top">
<Icon size={15} {...(props as any)} />
</Tooltip>
</Container>
)
}
const Container = styled.div`
display: flex;
justify-content: center;
align-items: center;
`
const Icon = styled(ImageIcon)`
color: var(--color-primary);
margin-right: 6px;
`
export default VisionIcon

View File

@ -0,0 +1,32 @@
// Original: src/renderer/src/components/Icons/WebSearchIcon.tsx
import { GlobalOutlined } from '@ant-design/icons'
import { Tooltip } from 'antd'
import React, { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
const WebSearchIcon: FC<React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>> = (props) => {
const { t } = useTranslation()
return (
<Container>
<Tooltip title={t('models.type.websearch')} placement="top">
<Icon {...(props as any)} />
</Tooltip>
</Container>
)
}
const Container = styled.div`
display: flex;
justify-content: center;
align-items: center;
`
const Icon = styled(GlobalOutlined)`
color: var(--color-link);
font-size: 15px;
margin-right: 6px;
`
export default WebSearchIcon

View File

@ -0,0 +1,21 @@
// Base Components
export { default as DividerWithText } from './base/DividerWithText'
export { default as IndicatorLight } from './base/IndicatorLight'
export { default as Spinner } from './base/Spinner'
export { default as TextBadge } from './base/TextBadge'
// Display Components
export { default as Ellipsis } from './display/Ellipsis'
export { default as ExpandableText } from './display/ExpandableText'
// Layout Components
export { default as HorizontalScrollContainer } from './layout/HorizontalScrollContainer'
export { default as Scrollbar } from './layout/Scrollbar'
// Icon Components
export { default as ToolsCallingIcon } from './icons/ToolsCallingIcon'
export { default as VisionIcon } from './icons/VisionIcon'
export { default as WebSearchIcon } from './icons/WebSearchIcon'
// Interactive Components
export { default as InfoTooltip } from './interactive/InfoTooltip'

View File

@ -0,0 +1,21 @@
// Original: src/renderer/src/components/TooltipIcons/InfoTooltip.tsx
import { Tooltip, TooltipProps } from 'antd'
import { Info } from 'lucide-react'
type InheritedTooltipProps = Omit<TooltipProps, 'children'>
interface InfoTooltipProps extends InheritedTooltipProps {
iconColor?: string
iconSize?: string | number
iconStyle?: React.CSSProperties
}
const InfoTooltip = ({ iconColor = 'var(--color-text-2)', iconSize = 14, iconStyle, ...rest }: InfoTooltipProps) => {
return (
<Tooltip {...rest}>
<Info size={iconSize} color={iconColor} style={{ ...iconStyle }} role="img" aria-label="Information" />
</Tooltip>
)
}
export default InfoTooltip

View File

@ -0,0 +1,181 @@
// Original: src/renderer/src/components/HorizontalScrollContainer/index.tsx
import { ChevronRight } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import styled from 'styled-components'
import Scrollbar from './Scrollbar'
/**
*
* @param children
* @param dependencies
* @param scrollDistance
* @param className
* @param gap
* @param expandable
*/
export interface HorizontalScrollContainerProps {
children: React.ReactNode
dependencies?: readonly unknown[]
scrollDistance?: number
className?: string
gap?: string
expandable?: boolean
}
const HorizontalScrollContainer: React.FC<HorizontalScrollContainerProps> = ({
children,
dependencies = [],
scrollDistance = 200,
className,
gap = '8px',
expandable = false
}) => {
const scrollRef = useRef<HTMLDivElement>(null)
const [canScroll, setCanScroll] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
const [isScrolledToEnd, setIsScrolledToEnd] = useState(false)
const handleScrollRight = (event: React.MouseEvent) => {
scrollRef.current?.scrollBy({ left: scrollDistance, behavior: 'smooth' })
event.stopPropagation()
}
const handleContainerClick = (e: React.MouseEvent) => {
if (expandable) {
// 确保不是点击了其他交互元素(如 tag 的关闭按钮)
const target = e.target as HTMLElement
if (!target.closest('[data-no-expand]')) {
setIsExpanded(!isExpanded)
}
}
}
const checkScrollability = () => {
const scrollElement = scrollRef.current
if (scrollElement) {
const parentElement = scrollElement.parentElement
const availableWidth = parentElement ? parentElement.clientWidth : scrollElement.clientWidth
// 确保容器不会超出可用宽度
const canScrollValue = scrollElement.scrollWidth > Math.min(availableWidth, scrollElement.clientWidth)
setCanScroll(canScrollValue)
// 检查是否滚动到最右侧
if (canScrollValue) {
const isAtEnd = Math.abs(scrollElement.scrollLeft + scrollElement.clientWidth - scrollElement.scrollWidth) <= 1
setIsScrolledToEnd(isAtEnd)
} else {
setIsScrolledToEnd(false)
}
}
}
useEffect(() => {
const scrollElement = scrollRef.current
if (!scrollElement) return
checkScrollability()
const handleScroll = () => {
checkScrollability()
}
const resizeObserver = new ResizeObserver(checkScrollability)
resizeObserver.observe(scrollElement)
scrollElement.addEventListener('scroll', handleScroll)
window.addEventListener('resize', checkScrollability)
return () => {
resizeObserver.disconnect()
scrollElement.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', checkScrollability)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, dependencies)
return (
<Container
className={className}
$expandable={expandable}
$disableHoverButton={isScrolledToEnd}
onClick={expandable ? handleContainerClick : undefined}>
<ScrollContent ref={scrollRef} $gap={gap} $isExpanded={isExpanded} $expandable={expandable}>
{children}
</ScrollContent>
{canScroll && !isExpanded && !isScrolledToEnd && (
<ScrollButton onClick={handleScrollRight} className="scroll-right-button">
<ChevronRight size={14} />
</ScrollButton>
)}
</Container>
)
}
const Container = styled.div<{ $expandable?: boolean; $disableHoverButton?: boolean }>`
display: flex;
align-items: center;
flex: 1 1 auto;
min-width: 0;
max-width: 100%;
position: relative;
cursor: ${(props) => (props.$expandable ? 'pointer' : 'default')};
${(props) =>
!props.$disableHoverButton &&
`
&:hover {
.scroll-right-button {
opacity: 1;
}
}
`}
`
const ScrollContent = styled(Scrollbar)<{
$gap: string
$isExpanded?: boolean
$expandable?: boolean
}>`
display: flex;
overflow-x: ${(props) => (props.$expandable && props.$isExpanded ? 'hidden' : 'auto')};
overflow-y: hidden;
white-space: ${(props) => (props.$expandable && props.$isExpanded ? 'normal' : 'nowrap')};
gap: ${(props) => props.$gap};
flex-wrap: ${(props) => (props.$expandable && props.$isExpanded ? 'wrap' : 'nowrap')};
&::-webkit-scrollbar {
display: none;
}
`
const ScrollButton = styled.div`
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
z-index: 1;
opacity: 0;
transition: opacity 0.2s ease-in-out;
cursor: pointer;
background: var(--color-background);
border-radius: 50%;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
box-shadow:
0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 9px 28px 8px rgba(0, 0, 0, 0.05);
color: var(--color-text-2);
&:hover {
color: var(--color-text);
background: var(--color-list-item);
}
`
export default HorizontalScrollContainer

View File

@ -0,0 +1,75 @@
// Original: src/renderer/src/components/Scrollbar/index.tsx
import { throttle } from 'lodash'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import styled from 'styled-components'
export interface ScrollbarProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onScroll'> {
ref?: React.Ref<HTMLDivElement | null>
onScroll?: () => void // Custom onScroll prop for useScrollPosition's handleScroll
}
const Scrollbar: FC<ScrollbarProps> = ({ ref: passedRef, children, onScroll: externalOnScroll, ...htmlProps }) => {
const [isScrolling, setIsScrolling] = useState(false)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const clearScrollingTimeout = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}, [])
const handleScroll = useCallback(() => {
setIsScrolling(true)
clearScrollingTimeout()
timeoutRef.current = setTimeout(() => {
setIsScrolling(false)
timeoutRef.current = null
}, 1500)
}, [clearScrollingTimeout])
// eslint-disable-next-line react-hooks/exhaustive-deps
const throttledInternalScrollHandler = useCallback(throttle(handleScroll, 100, { leading: true, trailing: true }), [
handleScroll
])
// Combined scroll handler
const combinedOnScroll = useCallback(() => {
throttledInternalScrollHandler()
if (externalOnScroll) {
externalOnScroll()
}
}, [throttledInternalScrollHandler, externalOnScroll])
useEffect(() => {
return () => {
clearScrollingTimeout()
throttledInternalScrollHandler.cancel()
}
}, [throttledInternalScrollHandler, clearScrollingTimeout])
return (
<ScrollBarContainer
{...htmlProps} // Pass other HTML attributes
$isScrolling={isScrolling}
onScroll={combinedOnScroll} // Use the combined handler
ref={passedRef}>
{children}
</ScrollBarContainer>
)
}
const ScrollBarContainer = styled.div<{ $isScrolling: boolean }>`
overflow-y: auto;
&::-webkit-scrollbar-thumb {
transition: background 2s ease;
background: ${(props) => (props.$isScrolling ? 'var(--color-scrollbar-thumb)' : 'transparent')};
&:hover {
background: var(--color-scrollbar-thumb-hover);
}
}
`
Scrollbar.displayName = 'Scrollbar'
export default Scrollbar

View File

5
packages/ui/src/index.ts Normal file
View File

@ -0,0 +1,5 @@
// 主入口文件 - 导出所有公共API
export * from './components'
// export * from './hooks'
// export * from './types'
export * from './utils'

View File

@ -0,0 +1,9 @@
import { type ClassValue, clsx } from 'clsx'
/**
* CSS类名的工具函数
* clsxTailwind CSS
*/
export function cn(...inputs: ClassValue[]) {
return clsx(inputs)
}

View File

@ -0,0 +1,28 @@
// Tailwind config for UI component library
// This config is used for development and provides a template for consumers
let heroui
try {
// Try to load heroui if available (dev environment)
heroui = require('@heroui/react').heroui
} catch (e) {
// Fallback for environments without heroui
heroui = () => ({})
}
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
// 扫描当前包的所有组件文件
'./src/**/*.{js,ts,jsx,tsx}',
// 扫描 HeroUI 的组件样式(如果存在)
'./node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}'
],
theme: {
extend: {
// 基础组件库主题扩展
}
},
darkMode: 'class',
plugins: [heroui()]
}

29
packages/ui/tsconfig.json Normal file
View File

@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"jsx": "react-jsx",
"lib": ["DOM", "DOM.Iterable", "ES6"],
"incremental": true,
"isolatedModules": true,
"noFallthroughCasesInSwitch": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist",
"**/*.test.*",
"**/__tests__/**"
]
}

View File

@ -0,0 +1,16 @@
import { defineConfig } from 'tsdown'
export default defineConfig({
entry: {
index: 'src/index.ts',
'components/index': 'src/components/index.ts',
'hooks/index': 'src/hooks/index.ts',
'utils/index': 'src/utils/index.ts'
},
outDir: 'dist',
format: ['esm', 'cjs'],
clean: true,
dts: true,
tsconfig: 'tsconfig.json',
external: ['react', 'react-dom']
})

View File

@ -1,3 +1,4 @@
import { Scrollbar } from '@cherrystudio/ui'
import {
DragDropContext,
Draggable,
@ -8,7 +9,6 @@ import {
OnDragStartResponder,
ResponderProvided
} from '@hello-pangea/dnd'
import Scrollbar from '@renderer/components/Scrollbar'
import { droppableReorder } from '@renderer/utils'
import { type ScrollToOptions, useVirtualizer, type VirtualItem } from '@tanstack/react-virtual'
import { type Key, memo, useCallback, useImperativeHandle, useRef } from 'react'

View File

@ -9,7 +9,8 @@
"packages/mcp-trace/**/*",
"packages/aiCore/src/**/*",
"src/main/integration/cherryin/index.js",
"packages/extension-table-plus/**/*"
"packages/extension-table-plus/**/*",
"packages/ui/**/*"
],
"compilerOptions": {
"composite": true,
@ -29,7 +30,8 @@
"@cherrystudio/ai-core/built-in/plugins": ["packages/aiCore/src/core/plugins/built-in/index.ts"],
"@cherrystudio/ai-core/*": ["packages/aiCore/src/*"],
"@cherrystudio/ai-core": ["packages/aiCore/src/index.ts"],
"@cherrystudio/extension-table-plus": ["packages/extension-table-plus/src/index.ts"]
"@cherrystudio/extension-table-plus": ["packages/extension-table-plus/src/index.ts"],
"@cherrystudio/ui": ["packages/ui/src/index.ts"]
},
"experimentalDecorators": true,
"emitDecoratorMetadata": true,

922
yarn.lock

File diff suppressed because it is too large Load Diff