mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-10 07:19:02 +08:00
perf: draggable virtual list (#7904)
* perf(TopicsTab): use DraggableVirtualList for the topic list - Add a DraggableVirtualList implemented using react-virtual - Rename DragableList to DraggableList - Add tests * refactor: improve props, fix drag area
This commit is contained in:
parent
33da5d31cf
commit
da5badc189
2
src/renderer/src/components/DraggableList/index.tsx
Normal file
2
src/renderer/src/components/DraggableList/index.tsx
Normal file
@ -0,0 +1,2 @@
|
||||
export { default as DraggableList } from './list'
|
||||
export { default as DraggableVirtualList } from './virtual-list'
|
||||
@ -23,7 +23,7 @@ interface Props<T> {
|
||||
droppableProps?: Partial<DroppableProps>
|
||||
}
|
||||
|
||||
const DragableList: FC<Props<any>> = ({
|
||||
const DraggableList: FC<Props<any>> = ({
|
||||
children,
|
||||
list,
|
||||
style,
|
||||
@ -78,4 +78,4 @@ const DragableList: FC<Props<any>> = ({
|
||||
)
|
||||
}
|
||||
|
||||
export default DragableList
|
||||
export default DraggableList
|
||||
212
src/renderer/src/components/DraggableList/virtual-list.tsx
Normal file
212
src/renderer/src/components/DraggableList/virtual-list.tsx
Normal file
@ -0,0 +1,212 @@
|
||||
import {
|
||||
DragDropContext,
|
||||
Draggable,
|
||||
Droppable,
|
||||
DroppableProps,
|
||||
DropResult,
|
||||
OnDragEndResponder,
|
||||
OnDragStartResponder,
|
||||
ResponderProvided
|
||||
} from '@hello-pangea/dnd'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { droppableReorder } from '@renderer/utils'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { type Key, memo, useCallback, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* 泛型 Props,用于配置 DraggableVirtualList。
|
||||
*
|
||||
* @template T 列表元素的类型
|
||||
* @property {string} [className] 根节点附加 class
|
||||
* @property {React.CSSProperties} [style] 根节点附加样式
|
||||
* @property {React.CSSProperties} [itemStyle] 元素内容区域的附加样式
|
||||
* @property {React.CSSProperties} [itemContainerStyle] 元素拖拽容器的附加样式
|
||||
* @property {Partial<DroppableProps>} [droppableProps] 透传给 Droppable 的额外配置
|
||||
* @property {(list: T[]) => void} onUpdate 拖拽排序完成后的回调,返回新的列表顺序
|
||||
* @property {OnDragStartResponder} [onDragStart] 开始拖拽时的回调
|
||||
* @property {OnDragEndResponder} [onDragEnd] 结束拖拽时的回调
|
||||
* @property {T[]} list 渲染的数据源
|
||||
* @property {(index: number) => Key} [itemKey] 提供给虚拟列表的行 key,若不提供默认使用 index
|
||||
* @property {number} [overscan=5] 前后额外渲染的行数,提升快速滚动时的体验
|
||||
* @property {(item: T, index: number) => React.ReactNode} children 列表项渲染函数
|
||||
*/
|
||||
interface DraggableVirtualListProps<T> {
|
||||
ref?: React.Ref<HTMLDivElement>
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
itemStyle?: React.CSSProperties
|
||||
itemContainerStyle?: React.CSSProperties
|
||||
droppableProps?: Partial<DroppableProps>
|
||||
onUpdate: (list: T[]) => void
|
||||
onDragStart?: OnDragStartResponder
|
||||
onDragEnd?: OnDragEndResponder
|
||||
list: T[]
|
||||
itemKey?: (index: number) => Key
|
||||
overscan?: number
|
||||
children: (item: T, index: number) => React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* 带虚拟滚动与拖拽排序能力的(垂直)列表组件。
|
||||
* - 滚动容器由该组件内部管理。
|
||||
* @template T 列表元素的类型
|
||||
* @param {DraggableVirtualListProps<T>} props 组件参数
|
||||
* @returns {React.ReactElement}
|
||||
*/
|
||||
function DraggableVirtualList<T>({
|
||||
ref,
|
||||
className,
|
||||
style,
|
||||
itemStyle,
|
||||
itemContainerStyle,
|
||||
droppableProps,
|
||||
onDragStart,
|
||||
onUpdate,
|
||||
onDragEnd,
|
||||
list,
|
||||
itemKey,
|
||||
overscan = 5,
|
||||
children
|
||||
}: DraggableVirtualListProps<T>): React.ReactElement {
|
||||
const _onDragEnd = (result: DropResult, provided: ResponderProvided) => {
|
||||
onDragEnd?.(result, provided)
|
||||
if (result.destination) {
|
||||
const sourceIndex = result.source.index
|
||||
const destIndex = result.destination.index
|
||||
const reorderAgents = droppableReorder(list, sourceIndex, destIndex)
|
||||
onUpdate(reorderAgents)
|
||||
}
|
||||
}
|
||||
|
||||
// 虚拟列表滚动容器的 ref
|
||||
const parentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: list.length,
|
||||
getScrollElement: useCallback(() => parentRef.current, []),
|
||||
getItemKey: itemKey,
|
||||
estimateSize: useCallback(() => 50, []),
|
||||
overscan
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={ref} className={`${className} draggable-virtual-list`} style={{ height: '100%', ...style }}>
|
||||
<DragDropContext onDragStart={onDragStart} onDragEnd={_onDragEnd}>
|
||||
<Droppable
|
||||
droppableId="droppable"
|
||||
mode="virtual"
|
||||
renderClone={(provided, _snapshot, rubric) => {
|
||||
const item = list[rubric.source.index]
|
||||
return (
|
||||
<div
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
ref={provided.innerRef}
|
||||
style={{
|
||||
...itemStyle,
|
||||
...provided.draggableProps.style
|
||||
}}>
|
||||
{item && children(item, rubric.source.index)}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
{...droppableProps}>
|
||||
{(provided) => {
|
||||
// 让 dnd 和虚拟列表共享同一个滚动容器
|
||||
const setRefs = (el: HTMLDivElement | null) => {
|
||||
provided.innerRef(el)
|
||||
parentRef.current = el
|
||||
}
|
||||
|
||||
return (
|
||||
<Scrollbar
|
||||
ref={setRefs}
|
||||
{...provided.droppableProps}
|
||||
className="virtual-scroller"
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
overflowY: 'auto',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div
|
||||
className="virtual-list"
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative'
|
||||
}}>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => (
|
||||
<VirtualRow
|
||||
key={virtualItem.key}
|
||||
virtualItem={virtualItem}
|
||||
list={list}
|
||||
itemStyle={itemStyle}
|
||||
itemContainerStyle={itemContainerStyle}
|
||||
virtualizer={virtualizer}
|
||||
children={children}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Scrollbar>
|
||||
)
|
||||
}}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染单个可拖拽的虚拟列表项,高度为动态测量
|
||||
*/
|
||||
const VirtualRow = memo(({ virtualItem, list, children, itemStyle, itemContainerStyle, virtualizer }: any) => {
|
||||
const item = list[virtualItem.index]
|
||||
const draggableId = String(virtualItem.key)
|
||||
return (
|
||||
<Draggable
|
||||
key={`draggable_${draggableId}_${virtualItem.index}`}
|
||||
draggableId={draggableId}
|
||||
index={virtualItem.index}>
|
||||
{(provided) => {
|
||||
const setDragRefs = (el: HTMLElement | null) => {
|
||||
provided.innerRef(el)
|
||||
virtualizer.measureElement(el)
|
||||
}
|
||||
|
||||
const dndStyle = provided.draggableProps.style
|
||||
const virtualizerTransform = `translateY(${virtualItem.start}px)`
|
||||
|
||||
// dnd 的 transform 负责拖拽时的位移和让位动画,
|
||||
// virtualizer 的 translateY 负责将项定位到虚拟列表的正确位置,
|
||||
// 它们拼接起来可以同时实现拖拽视觉效果和虚拟化定位。
|
||||
const combinedTransform = dndStyle?.transform
|
||||
? `${dndStyle.transform} ${virtualizerTransform}`
|
||||
: virtualizerTransform
|
||||
|
||||
return (
|
||||
<div
|
||||
{...provided.draggableProps}
|
||||
ref={setDragRefs}
|
||||
className="draggable-item"
|
||||
data-index={virtualItem.index}
|
||||
style={{
|
||||
...itemContainerStyle,
|
||||
...dndStyle,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: combinedTransform
|
||||
}}>
|
||||
<div {...provided.dragHandleProps} className="draggable-content" style={{ ...itemStyle }}>
|
||||
{item && children(item, virtualItem.index)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Draggable>
|
||||
)
|
||||
})
|
||||
|
||||
export default DraggableVirtualList
|
||||
@ -3,7 +3,7 @@ import { FC, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onScroll'> {
|
||||
ref?: React.RefObject<HTMLDivElement | null>
|
||||
ref?: React.Ref<HTMLDivElement | null>
|
||||
onScroll?: () => void // Custom onScroll prop for useScrollPosition's handleScroll
|
||||
}
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import DragableList from '../DragableList'
|
||||
import { DraggableList } from '../DraggableList'
|
||||
|
||||
// mock @hello-pangea/dnd 组件
|
||||
vi.mock('@hello-pangea/dnd', () => {
|
||||
@ -49,7 +49,7 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
describe('DragableList', () => {
|
||||
describe('DraggableList', () => {
|
||||
describe('rendering', () => {
|
||||
it('should render all list items', () => {
|
||||
const list = [
|
||||
@ -58,9 +58,9 @@ describe('DragableList', () => {
|
||||
{ id: 'c', name: 'C' }
|
||||
]
|
||||
render(
|
||||
<DragableList list={list} onUpdate={() => {}}>
|
||||
<DraggableList list={list} onUpdate={() => {}}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DragableList>
|
||||
</DraggableList>
|
||||
)
|
||||
const items = screen.getAllByTestId('item')
|
||||
expect(items.length).toBe(3)
|
||||
@ -74,9 +74,9 @@ describe('DragableList', () => {
|
||||
const style = { background: 'red' }
|
||||
const listStyle = { color: 'blue' }
|
||||
render(
|
||||
<DragableList list={list} style={style} listStyle={listStyle} onUpdate={() => {}}>
|
||||
<DraggableList list={list} style={style} listStyle={listStyle} onUpdate={() => {}}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DragableList>
|
||||
</DraggableList>
|
||||
)
|
||||
// 检查 style 是否传递到外层容器
|
||||
const virtualList = screen.getByTestId('virtual-list')
|
||||
@ -85,9 +85,9 @@ describe('DragableList', () => {
|
||||
|
||||
it('should render nothing when list is empty', () => {
|
||||
render(
|
||||
<DragableList list={[]} onUpdate={() => {}}>
|
||||
<DraggableList list={[]} onUpdate={() => {}}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DragableList>
|
||||
</DraggableList>
|
||||
)
|
||||
// 虚拟列表存在但无内容
|
||||
const items = screen.queryAllByTestId('item')
|
||||
@ -106,9 +106,9 @@ describe('DragableList', () => {
|
||||
const onUpdate = vi.fn()
|
||||
|
||||
render(
|
||||
<DragableList list={list} onUpdate={onUpdate}>
|
||||
<DraggableList list={list} onUpdate={onUpdate}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DragableList>
|
||||
</DraggableList>
|
||||
)
|
||||
|
||||
// 直接调用 window.triggerOnDragEnd 模拟拖拽结束
|
||||
@ -128,9 +128,9 @@ describe('DragableList', () => {
|
||||
const onDragEnd = vi.fn()
|
||||
|
||||
render(
|
||||
<DragableList list={list} onUpdate={() => {}} onDragStart={onDragStart} onDragEnd={onDragEnd}>
|
||||
<DraggableList list={list} onUpdate={() => {}} onDragStart={onDragStart} onDragEnd={onDragEnd}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DragableList>
|
||||
</DraggableList>
|
||||
)
|
||||
|
||||
// 先手动调用 onDragStart
|
||||
@ -150,9 +150,9 @@ describe('DragableList', () => {
|
||||
const onUpdate = vi.fn()
|
||||
|
||||
render(
|
||||
<DragableList list={list} onUpdate={onUpdate}>
|
||||
<DraggableList list={list} onUpdate={onUpdate}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DragableList>
|
||||
</DraggableList>
|
||||
)
|
||||
|
||||
// 模拟拖拽到自身
|
||||
@ -168,9 +168,9 @@ describe('DragableList', () => {
|
||||
const onUpdate = vi.fn()
|
||||
|
||||
render(
|
||||
<DragableList list={list} onUpdate={onUpdate}>
|
||||
<DraggableList list={list} onUpdate={onUpdate}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DragableList>
|
||||
</DraggableList>
|
||||
)
|
||||
|
||||
// 拖拽自身
|
||||
@ -188,9 +188,9 @@ describe('DragableList', () => {
|
||||
// 不传 onDragStart/onDragEnd
|
||||
expect(() => {
|
||||
render(
|
||||
<DragableList list={list} onUpdate={() => {}}>
|
||||
<DraggableList list={list} onUpdate={() => {}}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DragableList>
|
||||
</DraggableList>
|
||||
)
|
||||
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 1 } }, {})
|
||||
}).not.toThrow()
|
||||
@ -201,9 +201,9 @@ describe('DragableList', () => {
|
||||
const onUpdate = vi.fn()
|
||||
|
||||
render(
|
||||
<DragableList list={list} onUpdate={onUpdate}>
|
||||
<DraggableList list={list} onUpdate={onUpdate}>
|
||||
{(item) => <div data-testid="item">{item}</div>}
|
||||
</DragableList>
|
||||
</DraggableList>
|
||||
)
|
||||
|
||||
// 拖拽第0项到第2项
|
||||
@ -222,9 +222,9 @@ describe('DragableList', () => {
|
||||
]
|
||||
|
||||
render(
|
||||
<DragableList list={list} onUpdate={() => {}}>
|
||||
<DraggableList list={list} onUpdate={() => {}}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DragableList>
|
||||
</DraggableList>
|
||||
)
|
||||
|
||||
// placeholder 应该在初始渲染时就存在
|
||||
@ -240,9 +240,9 @@ describe('DragableList', () => {
|
||||
]
|
||||
const onUpdate = vi.fn()
|
||||
render(
|
||||
<DragableList list={list} onUpdate={onUpdate}>
|
||||
<DraggableList list={list} onUpdate={onUpdate}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DragableList>
|
||||
</DraggableList>
|
||||
)
|
||||
|
||||
// 拖拽第2项到第0项
|
||||
@ -272,9 +272,9 @@ describe('DragableList', () => {
|
||||
{ id: 'c', name: 'C' }
|
||||
]
|
||||
const { container } = render(
|
||||
<DragableList list={list} onUpdate={() => {}}>
|
||||
<DraggableList list={list} onUpdate={() => {}}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DragableList>
|
||||
</DraggableList>
|
||||
)
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
@ -0,0 +1,164 @@
|
||||
/// <reference types="@vitest/browser/context" />
|
||||
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import DraggableVirtualList from '../DraggableList/virtual-list'
|
||||
|
||||
// Mock 依赖项
|
||||
vi.mock('@hello-pangea/dnd', () => ({
|
||||
__esModule: true,
|
||||
DragDropContext: ({ children, onDragEnd, onDragStart }) => {
|
||||
// 挂载到 window 以便测试用例直接调用
|
||||
window.triggerOnDragEnd = (result = { source: { index: 0 }, destination: { index: 1 } }, provided = {}) => {
|
||||
onDragEnd?.(result, provided)
|
||||
}
|
||||
window.triggerOnDragStart = (result = { source: { index: 0 } }, provided = {}) => {
|
||||
onDragStart?.(result, provided)
|
||||
}
|
||||
return <div data-testid="drag-drop-context">{children}</div>
|
||||
},
|
||||
Droppable: ({ children, renderClone }) => (
|
||||
<div data-testid="droppable">
|
||||
{/* 模拟 renderClone 的调用 */}
|
||||
{renderClone &&
|
||||
renderClone({ draggableProps: {}, dragHandleProps: {}, innerRef: vi.fn() }, {}, { source: { index: 0 } })}
|
||||
{children({ droppableProps: {}, innerRef: vi.fn() })}
|
||||
</div>
|
||||
),
|
||||
Draggable: ({ children, draggableId, index }) => (
|
||||
<div data-testid={`draggable-${draggableId}-${index}`}>
|
||||
{children({ draggableProps: {}, dragHandleProps: {}, innerRef: vi.fn() }, {})}
|
||||
</div>
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-virtual', () => ({
|
||||
useVirtualizer: ({ count }) => ({
|
||||
getVirtualItems: () =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
index,
|
||||
key: index,
|
||||
start: index * 50,
|
||||
size: 50
|
||||
})),
|
||||
getTotalSize: () => count * 50,
|
||||
measureElement: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('react-virtualized-auto-sizer', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }) => <div data-testid="auto-sizer">{children({ height: 500, width: 300 })}</div>
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/components/Scrollbar', () => ({
|
||||
__esModule: true,
|
||||
default: ({ ref, children, ...props }) => (
|
||||
<div ref={ref} {...props} data-testid="scrollbar">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}))
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
triggerOnDragEnd: (result?: any, provided?: any) => void
|
||||
triggerOnDragStart: (result?: any, provided?: any) => void
|
||||
}
|
||||
}
|
||||
|
||||
describe('DraggableVirtualList', () => {
|
||||
const sampleList = [
|
||||
{ id: 'a', name: 'Item A' },
|
||||
{ id: 'b', name: 'Item B' },
|
||||
{ id: 'c', name: 'Item C' }
|
||||
]
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render all list items provided', () => {
|
||||
render(
|
||||
<DraggableVirtualList list={sampleList} onUpdate={() => {}}>
|
||||
{(item) => <div data-testid="test-item">{item.name}</div>}
|
||||
</DraggableVirtualList>
|
||||
)
|
||||
const items = screen.getAllByTestId('test-item')
|
||||
// 我们的 mock 中,renderClone 会渲染一个额外的 item
|
||||
expect(items.length).toBe(sampleList.length + 1)
|
||||
expect(items[0]).toHaveTextContent('Item A')
|
||||
expect(items[1]).toHaveTextContent('Item A')
|
||||
expect(items[2]).toHaveTextContent('Item B')
|
||||
expect(items[3]).toHaveTextContent('Item C')
|
||||
})
|
||||
|
||||
it('should render nothing when the list is empty', () => {
|
||||
render(
|
||||
<DraggableVirtualList list={[]} onUpdate={() => {}}>
|
||||
{/* @ts-ignore test*/}
|
||||
{(item) => <div data-testid="test-item">{item.name}</div>}
|
||||
</DraggableVirtualList>
|
||||
)
|
||||
const items = screen.queryAllByTestId('test-item')
|
||||
expect(items.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('drag and drop', () => {
|
||||
it('should call onUpdate with the new order after a drag operation', () => {
|
||||
const onUpdate = vi.fn()
|
||||
render(
|
||||
<DraggableVirtualList list={sampleList} onUpdate={onUpdate}>
|
||||
{(item) => <div>{item.name}</div>}
|
||||
</DraggableVirtualList>
|
||||
)
|
||||
|
||||
window.triggerOnDragEnd({ source: { index: 0 }, destination: { index: 2 } })
|
||||
const expectedOrder = [sampleList[1], sampleList[2], sampleList[0]] // B, C, A
|
||||
expect(onUpdate).toHaveBeenCalledWith(expectedOrder)
|
||||
})
|
||||
|
||||
it('should call onDragStart and onDragEnd callbacks', () => {
|
||||
const onDragStart = vi.fn()
|
||||
const onDragEnd = vi.fn()
|
||||
render(
|
||||
<DraggableVirtualList list={sampleList} onUpdate={() => {}} onDragStart={onDragStart} onDragEnd={onDragEnd}>
|
||||
{(item) => <div>{item.name}</div>}
|
||||
</DraggableVirtualList>
|
||||
)
|
||||
|
||||
window.triggerOnDragStart()
|
||||
expect(onDragStart).toHaveBeenCalledTimes(1)
|
||||
|
||||
window.triggerOnDragEnd()
|
||||
expect(onDragEnd).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call onUpdate if destination is not defined', () => {
|
||||
const onUpdate = vi.fn()
|
||||
render(
|
||||
<DraggableVirtualList list={sampleList} onUpdate={onUpdate}>
|
||||
{(item) => <div>{item.name}</div>}
|
||||
</DraggableVirtualList>
|
||||
)
|
||||
|
||||
window.triggerOnDragEnd({ source: { index: 0 }, destination: null })
|
||||
expect(onUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('snapshot', () => {
|
||||
it('should match snapshot with custom styles', () => {
|
||||
const { container } = render(
|
||||
<DraggableVirtualList
|
||||
list={sampleList}
|
||||
onUpdate={() => {}}
|
||||
className="custom-class"
|
||||
style={{ border: '1px solid red' }}
|
||||
itemStyle={{ background: 'blue' }}>
|
||||
{(item) => <div>{item.name}</div>}
|
||||
</DraggableVirtualList>
|
||||
)
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,6 +1,6 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`DragableList > snapshot > should match snapshot 1`] = `
|
||||
exports[`DraggableList > snapshot > should match snapshot 1`] = `
|
||||
<div>
|
||||
<div
|
||||
data-testid="drag-drop-context"
|
||||
@ -0,0 +1,91 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`DraggableVirtualList > snapshot > should match snapshot with custom styles 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="custom-class draggable-virtual-list"
|
||||
style="height: 100%; border: 1px solid red;"
|
||||
>
|
||||
<div
|
||||
data-testid="drag-drop-context"
|
||||
>
|
||||
<div
|
||||
data-testid="droppable"
|
||||
>
|
||||
<div
|
||||
style="background: blue;"
|
||||
>
|
||||
<div>
|
||||
Item A
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="virtual-scroller"
|
||||
data-testid="scrollbar"
|
||||
style="height: 100%; width: 100%; overflow-y: auto; position: relative;"
|
||||
>
|
||||
<div
|
||||
class="virtual-list"
|
||||
style="height: 150px; width: 100%; position: relative;"
|
||||
>
|
||||
<div
|
||||
data-testid="draggable-0-0"
|
||||
>
|
||||
<div
|
||||
class="draggable-item"
|
||||
data-index="0"
|
||||
style="position: absolute; top: 0px; left: 0px; width: 100%; transform: translateY(0px);"
|
||||
>
|
||||
<div
|
||||
class="draggable-content"
|
||||
style="background: blue;"
|
||||
>
|
||||
<div>
|
||||
Item A
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-testid="draggable-1-1"
|
||||
>
|
||||
<div
|
||||
class="draggable-item"
|
||||
data-index="1"
|
||||
style="position: absolute; top: 0px; left: 0px; width: 100%; transform: translateY(50px);"
|
||||
>
|
||||
<div
|
||||
class="draggable-content"
|
||||
style="background: blue;"
|
||||
>
|
||||
<div>
|
||||
Item B
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-testid="draggable-2-2"
|
||||
>
|
||||
<div
|
||||
class="draggable-item"
|
||||
data-index="2"
|
||||
style="position: absolute; top: 0px; left: 0px; width: 100%; transform: translateY(100px);"
|
||||
>
|
||||
<div
|
||||
class="draggable-content"
|
||||
style="background: blue;"
|
||||
>
|
||||
<div>
|
||||
Item C
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@ -33,7 +33,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import DragableList from '../DragableList'
|
||||
import { DraggableList } from '../DraggableList'
|
||||
import MinAppIcon from '../Icons/MinAppIcon'
|
||||
import UserPopup from '../Popups/UserPopup'
|
||||
|
||||
@ -288,7 +288,7 @@ const PinnedApps: FC = () => {
|
||||
const { openMinappKeepAlive } = useMinappPopup()
|
||||
|
||||
return (
|
||||
<DragableList list={pinned} onUpdate={updatePinnedMinapps} listStyle={{ marginBottom: 5 }}>
|
||||
<DraggableList list={pinned} onUpdate={updatePinnedMinapps} listStyle={{ marginBottom: 5 }}>
|
||||
{(app) => {
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
@ -316,7 +316,7 @@ const PinnedApps: FC = () => {
|
||||
</Tooltip>
|
||||
)
|
||||
}}
|
||||
</DragableList>
|
||||
</DraggableList>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { MenuOutlined } from '@ant-design/icons'
|
||||
import DragableList from '@renderer/components/DragableList'
|
||||
import { DraggableList } from '@renderer/components/DraggableList'
|
||||
import { Box, HStack } from '@renderer/components/Layout'
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { useAgents } from '@renderer/hooks/useAgents'
|
||||
@ -43,7 +43,7 @@ const PopupContainer: React.FC = () => {
|
||||
centered>
|
||||
<Container>
|
||||
{agents.length > 0 && (
|
||||
<DragableList list={agents} onUpdate={updateAgents}>
|
||||
<DraggableList list={agents} onUpdate={updateAgents}>
|
||||
{(item) => (
|
||||
<AgentItem>
|
||||
<Box mr={8}>
|
||||
@ -54,7 +54,7 @@ const PopupContainer: React.FC = () => {
|
||||
</HStack>
|
||||
</AgentItem>
|
||||
)}
|
||||
</DragableList>
|
||||
</DraggableList>
|
||||
)}
|
||||
{agents.length === 0 && <Empty description="" />}
|
||||
</Container>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { DownOutlined, PlusOutlined, RightOutlined } from '@ant-design/icons'
|
||||
import DragableList from '@renderer/components/DragableList'
|
||||
import { DraggableList } from '@renderer/components/DraggableList'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { useAgents } from '@renderer/hooks/useAgents'
|
||||
import { useAssistants } from '@renderer/hooks/useAssistant'
|
||||
@ -92,7 +92,7 @@ const Assistants: FC<AssistantsTabProps> = ({
|
||||
)}
|
||||
{!collapsedTags[group.tag] && (
|
||||
<div>
|
||||
<DragableList
|
||||
<DraggableList
|
||||
list={group.assistants}
|
||||
onUpdate={(newList) => handleGroupReorder(group.tag, newList)}
|
||||
onDragStart={() => setDragging(true)}
|
||||
@ -111,7 +111,7 @@ const Assistants: FC<AssistantsTabProps> = ({
|
||||
handleSortByChange={handleSortByChange}
|
||||
/>
|
||||
)}
|
||||
</DragableList>
|
||||
</DraggableList>
|
||||
</div>
|
||||
)}
|
||||
</TagsContainer>
|
||||
@ -129,7 +129,7 @@ const Assistants: FC<AssistantsTabProps> = ({
|
||||
|
||||
return (
|
||||
<Container className="assistants-tab" ref={containerRef}>
|
||||
<DragableList
|
||||
<DraggableList
|
||||
list={assistants}
|
||||
onUpdate={updateAssistants}
|
||||
onDragStart={() => setDragging(true)}
|
||||
@ -148,7 +148,7 @@ const Assistants: FC<AssistantsTabProps> = ({
|
||||
handleSortByChange={handleSortByChange}
|
||||
/>
|
||||
)}
|
||||
</DragableList>
|
||||
</DraggableList>
|
||||
{!dragging && (
|
||||
<AssistantAddItem onClick={onCreateAssistant}>
|
||||
<AssistantName>
|
||||
|
||||
@ -9,11 +9,10 @@ import {
|
||||
QuestionCircleOutlined,
|
||||
UploadOutlined
|
||||
} from '@ant-design/icons'
|
||||
import DragableList from '@renderer/components/DragableList'
|
||||
import { DraggableVirtualList as DraggableList } from '@renderer/components/DraggableList'
|
||||
import CopyIcon from '@renderer/components/Icons/CopyIcon'
|
||||
import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
|
||||
import PromptPopup from '@renderer/components/Popups/PromptPopup'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { useAssistant, useAssistants } from '@renderer/hooks/useAssistant'
|
||||
import { modelGenerating } from '@renderer/hooks/useRuntime'
|
||||
@ -447,92 +446,86 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
|
||||
}, [assistant.topics, pinTopicsToTop])
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items: getTopicMenuItems }} trigger={['contextMenu']}>
|
||||
<Container className="topics-tab">
|
||||
<DragableList list={sortedTopics} onUpdate={updateTopics}>
|
||||
{(topic) => {
|
||||
const isActive = topic.id === activeTopic?.id
|
||||
const topicName = topic.name.replace('`', '')
|
||||
const topicPrompt = topic.prompt
|
||||
const fullTopicPrompt = t('common.prompt') + ': ' + topicPrompt
|
||||
<DraggableList
|
||||
className="topics-tab"
|
||||
list={sortedTopics}
|
||||
onUpdate={updateTopics}
|
||||
style={{ padding: '10px 0 10px 10px' }}
|
||||
itemContainerStyle={{ paddingBottom: '8px' }}>
|
||||
{(topic) => {
|
||||
const isActive = topic.id === activeTopic?.id
|
||||
const topicName = topic.name.replace('`', '')
|
||||
const topicPrompt = topic.prompt
|
||||
const fullTopicPrompt = t('common.prompt') + ': ' + topicPrompt
|
||||
|
||||
const getTopicNameClassName = () => {
|
||||
if (isRenaming(topic.id)) return 'shimmer'
|
||||
if (isNewlyRenamed(topic.id)) return 'typing'
|
||||
return ''
|
||||
}
|
||||
const getTopicNameClassName = () => {
|
||||
if (isRenaming(topic.id)) return 'shimmer'
|
||||
if (isNewlyRenamed(topic.id)) return 'typing'
|
||||
return ''
|
||||
}
|
||||
|
||||
return (
|
||||
<TopicListItem
|
||||
onContextMenu={() => setTargetTopic(topic)}
|
||||
className={isActive ? 'active' : ''}
|
||||
onClick={() => onSwitchTopic(topic)}
|
||||
style={{ borderRadius }}>
|
||||
{isPending(topic.id) && !isActive && <PendingIndicator />}
|
||||
<TopicNameContainer>
|
||||
<TopicName className={getTopicNameClassName()} title={topicName}>
|
||||
{topicName}
|
||||
</TopicName>
|
||||
{!topic.pinned && (
|
||||
<Tooltip
|
||||
placement="bottom"
|
||||
mouseEnterDelay={0.7}
|
||||
title={
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', opacity: 0.8, fontStyle: 'italic' }}>
|
||||
{t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })}
|
||||
</div>
|
||||
return (
|
||||
<Dropdown menu={{ items: getTopicMenuItems }} trigger={['contextMenu']}>
|
||||
<TopicListItem
|
||||
onContextMenu={() => setTargetTopic(topic)}
|
||||
className={isActive ? 'active' : ''}
|
||||
onClick={() => onSwitchTopic(topic)}
|
||||
style={{ borderRadius }}>
|
||||
{isPending(topic.id) && !isActive && <PendingIndicator />}
|
||||
<TopicNameContainer>
|
||||
<TopicName className={getTopicNameClassName()} title={topicName}>
|
||||
{topicName}
|
||||
</TopicName>
|
||||
{!topic.pinned && (
|
||||
<Tooltip
|
||||
placement="bottom"
|
||||
mouseEnterDelay={0.7}
|
||||
title={
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', opacity: 0.8, fontStyle: 'italic' }}>
|
||||
{t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })}
|
||||
</div>
|
||||
}>
|
||||
<MenuButton
|
||||
className="menu"
|
||||
onClick={(e) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
handleConfirmDelete(topic, e)
|
||||
} else if (deletingTopicId === topic.id) {
|
||||
handleConfirmDelete(topic, e)
|
||||
} else {
|
||||
handleDeleteClick(topic.id, e)
|
||||
}
|
||||
}}>
|
||||
{deletingTopicId === topic.id ? (
|
||||
<DeleteOutlined style={{ color: 'var(--color-error)' }} />
|
||||
) : (
|
||||
<CloseOutlined />
|
||||
)}
|
||||
</MenuButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{topic.pinned && (
|
||||
<MenuButton className="pin">
|
||||
<PushpinOutlined />
|
||||
</div>
|
||||
}>
|
||||
<MenuButton
|
||||
className="menu"
|
||||
onClick={(e) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
handleConfirmDelete(topic, e)
|
||||
} else if (deletingTopicId === topic.id) {
|
||||
handleConfirmDelete(topic, e)
|
||||
} else {
|
||||
handleDeleteClick(topic.id, e)
|
||||
}
|
||||
}}>
|
||||
{deletingTopicId === topic.id ? (
|
||||
<DeleteOutlined style={{ color: 'var(--color-error)' }} />
|
||||
) : (
|
||||
<CloseOutlined />
|
||||
)}
|
||||
</MenuButton>
|
||||
)}
|
||||
</TopicNameContainer>
|
||||
{topicPrompt && (
|
||||
<TopicPromptText className="prompt" title={fullTopicPrompt}>
|
||||
{fullTopicPrompt}
|
||||
</TopicPromptText>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showTopicTime && (
|
||||
<TopicTime className="time">{dayjs(topic.createdAt).format('MM/DD HH:mm')}</TopicTime>
|
||||
{topic.pinned && (
|
||||
<MenuButton className="pin">
|
||||
<PushpinOutlined />
|
||||
</MenuButton>
|
||||
)}
|
||||
</TopicListItem>
|
||||
)
|
||||
}}
|
||||
</DragableList>
|
||||
<div style={{ minHeight: '10px' }}></div>
|
||||
</Container>
|
||||
</Dropdown>
|
||||
</TopicNameContainer>
|
||||
{topicPrompt && (
|
||||
<TopicPromptText className="prompt" title={fullTopicPrompt}>
|
||||
{fullTopicPrompt}
|
||||
</TopicPromptText>
|
||||
)}
|
||||
{showTopicTime && <TopicTime className="time">{dayjs(topic.createdAt).format('MM/DD HH:mm')}</TopicTime>}
|
||||
</TopicListItem>
|
||||
</Dropdown>
|
||||
)
|
||||
}}
|
||||
</DraggableList>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled(Scrollbar)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px;
|
||||
`
|
||||
|
||||
const TopicListItem = styled.div`
|
||||
padding: 7px 12px;
|
||||
border-radius: var(--list-item-border-radius);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { DeleteOutlined, EditOutlined, SettingOutlined } from '@ant-design/icons'
|
||||
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||
import DragableList from '@renderer/components/DragableList'
|
||||
import { DraggableList } from '@renderer/components/DraggableList'
|
||||
import ListItem from '@renderer/components/ListItem'
|
||||
import PromptPopup from '@renderer/components/Popups/PromptPopup'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
@ -98,7 +98,7 @@ const KnowledgePage: FC = () => {
|
||||
<ContentContainer id="content-container">
|
||||
<KnowledgeSideNav>
|
||||
<ScrollContainer>
|
||||
<DragableList
|
||||
<DraggableList
|
||||
list={bases}
|
||||
onUpdate={updateKnowledgeBases}
|
||||
style={{ marginBottom: 0, paddingBottom: isDragging ? 50 : 0 }}
|
||||
@ -116,7 +116,7 @@ const KnowledgePage: FC = () => {
|
||||
</div>
|
||||
</Dropdown>
|
||||
)}
|
||||
</DragableList>
|
||||
</DraggableList>
|
||||
{!isDragging && (
|
||||
<AddKnowledgeItem onClick={handleAddKnowledge}>
|
||||
<AddKnowledgeName>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import DragableList from '@renderer/components/DragableList'
|
||||
import { DraggableList } from '@renderer/components/DraggableList'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { usePaintings } from '@renderer/hooks/usePaintings'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
@ -38,7 +38,7 @@ const PaintingsList: FC<PaintingsListProps> = ({
|
||||
<PlusOutlined />
|
||||
</NewPaintingButton>
|
||||
)}
|
||||
<DragableList
|
||||
<DraggableList
|
||||
list={paintings}
|
||||
onUpdate={(value) => updatePaintings(namespace, value)}
|
||||
onDragStart={() => setDragging(true)}
|
||||
@ -61,7 +61,7 @@ const PaintingsList: FC<PaintingsListProps> = ({
|
||||
</DeleteButton>
|
||||
</CanvasWrapper>
|
||||
)}
|
||||
</DragableList>
|
||||
</DraggableList>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { DeleteOutlined, EditOutlined, ExclamationCircleOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import DragableList from '@renderer/components/DragableList'
|
||||
import { DraggableList } from '@renderer/components/DraggableList'
|
||||
import FileItem from '@renderer/pages/files/FileItem'
|
||||
import { Assistant, QuickPhrase } from '@renderer/types'
|
||||
import { Button, Flex, Input, Modal, Popconfirm, Space } from 'antd'
|
||||
@ -87,7 +87,7 @@ const AssistantRegularPromptsSettings: FC<AssistantRegularPromptsSettingsProps>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<StyledPromptList>
|
||||
<DragableList
|
||||
<DraggableList
|
||||
list={reversedPrompts}
|
||||
onUpdate={(newPrompts) => handleUpdateOrder([...newPrompts].reverse())}
|
||||
style={{ paddingBottom: dragging ? '34px' : 0 }}
|
||||
@ -120,7 +120,7 @@ const AssistantRegularPromptsSettings: FC<AssistantRegularPromptsSettingsProps>
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DragableList>
|
||||
</DraggableList>
|
||||
</StyledPromptList>
|
||||
</SettingRow>
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { EditOutlined } from '@ant-design/icons'
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import DragableList from '@renderer/components/DragableList'
|
||||
import { DraggableList } from '@renderer/components/DraggableList'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
||||
import { MCPServer } from '@renderer/types'
|
||||
@ -117,7 +117,7 @@ const McpServersList: FC = () => {
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</ListHeader>
|
||||
<DragableList style={{ width: '100%' }} list={mcpServers} onUpdate={updateMcpServers}>
|
||||
<DraggableList style={{ width: '100%' }} list={mcpServers} onUpdate={updateMcpServers}>
|
||||
{(server: MCPServer) => (
|
||||
<ServerCard key={server.id} onClick={() => navigate(`/settings/mcp/settings`, { state: { server } })}>
|
||||
<ServerHeader>
|
||||
@ -171,7 +171,7 @@ const McpServersList: FC = () => {
|
||||
</ServerFooter>
|
||||
</ServerCard>
|
||||
)}
|
||||
</DragableList>
|
||||
</DraggableList>
|
||||
{mcpServers.length === 0 && (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { DeleteOutlined, EditOutlined, ExclamationCircleOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import DragableList from '@renderer/components/DragableList'
|
||||
import { DraggableList } from '@renderer/components/DraggableList'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import FileItem from '@renderer/pages/files/FileItem'
|
||||
import QuickPhraseService from '@renderer/services/QuickPhraseService'
|
||||
@ -79,7 +79,7 @@ const QuickPhraseSettings: FC = () => {
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<QuickPhraseList>
|
||||
<DragableList
|
||||
<DraggableList
|
||||
list={reversedPhrases}
|
||||
onUpdate={(newPhrases) => handleUpdateOrder([...newPhrases].reverse())}
|
||||
style={{ paddingBottom: dragging ? '34px' : 0 }}
|
||||
@ -109,7 +109,7 @@ const QuickPhraseSettings: FC = () => {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DragableList>
|
||||
</DraggableList>
|
||||
</QuickPhraseList>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user