使用 dnd-kit 实现一个类似 window 窗口鼠标长按标题栏,可拖拽移动。释放鼠标时窗口位置固定的功能。
拖拽核心
简单封装一个 dnd-kit hooks 文件
'use client'
import React, { CSSProperties } from 'react'
import { DndContext, useDraggable, useDroppable } from '@dnd-kit/core'
import { Button } from 'antd'
import { DragOutlined } from '@ant-design/icons'
import { useDockAction } from '@/components/desktop/dock/dock-context'
export const useWindowDraggable = ({ id }: { id: number }) => {
const { attributes, listeners, setNodeRef, transform, activatorEvent } = useDraggable({
id: `draggable-${id}`,
data: { type: 'window', id: id },
})
const { getWindowItem } = useDockAction()
const { position } = getWindowItem(id)
const style: CSSProperties = transform
? {
transform: `translate3d(${position.offsetX + transform.x}px, ${position.offsetY + transform.y}px, 0)`,
}
: {}
return {
setNodeRef,
style,
draggable_button: <Button {...listeners} {...attributes} icon={<DragOutlined />} />,
}
}
export const Droppable: React.FC<React.PropsWithChildren & { id?: string }> = (props) => {
const { isOver, setNodeRef } = useDroppable({
id: `droppable-${props.id}`,
})
const style: CSSProperties = {
backgroundColor: isOver ? 'rgba(255,255,255,.1)' : undefined,
}
return (
<div ref={setNodeRef} style={style}>
{props.children}
</div>
)
}
export const WindowDndContext: React.FC<React.PropsWithChildren> = ({ children }) => {
const { changeWindowPositionItem } = useDockAction()
return (
<DndContext
onDragEnd={(event) => {
const { activatorEvent, active, delta } = event
active &&
changeWindowPositionItem(active.data.current?.id, {
offsetY: delta.y,
offsetX: delta.x,
})
}}
>
<Droppable>{children}</Droppable>
</DndContext>
)
}
useWindowDraggable为长按拖拽 hooks,返回一个 ref、 使用transform改变位置的 style、draggable_button长按触发拖拽的按钮。Droppable为开始拖拽可放置区域组件,这里仅改变背景色。WindowDndContext为 Context 组件。
onDragEnd方法为释放鼠标时执行的回调函数,event 对象内的 delta 记录了释放时,窗口的偏移量 x 与 y。每次将释放时,将该值记录下来,供下次拖拽移动时相加使用,避免再次拖拽时transform偏移量错误。
再次拖拽移动时,需要与上次释放时的值开始进行偏移定位。
...
const { position } = getWindowItem(id)
const style: CSSProperties = transform
? {
transform: `translate3d(${position.offsetX + transform.x}px, ${position.offsetY + transform.y}px, 0)`,
}
: {}
...
窗口定位列表
打开窗口列表的的 Context 组件。内部维护一个打开窗口的定位信息。
当需要打开一个窗口时,使用 push 方法新增一个。changeActivity可改变激活状态。
'use client'
import createCtx from '@/lib/create-ctx'
import React from 'react'
export const DockContext =
createCtx<{ title: string; hidden: boolean; activity: boolean; position: { offsetY: number; offsetX: number } }[]>()
export const useDockAction = () => {
const window_list = DockContext.useStore()
const changeDock = DockContext.useDispatch()
return {
getWindowItem: (window_index: number) => {
return window_list[window_index]
},
changeWindowPositionItem: (window_index: number, position: { offsetY: number; offsetX: number }) => {
const item = window_list[window_index]
item.position = {
offsetX: item.position.offsetX + position.offsetX,
offsetY: item.position.offsetY + position.offsetY,
}
changeDock([...window_list])
},
push: (window_type: string) => {
const list = [
...window_list.map((item) => {
item.activity = false
return item
}),
{ title: window_type, hidden: false, activity: true, position: { offsetY: 0, offsetX: 0 } },
]
changeDock(list)
return list.length
},
remove: (window_index: number) => {
window_list.splice(window_index, 1)
changeDock([...window_list])
},
changeActivity: (window_index: number) => {
window_list.map((item) => {
item.activity = false
return item
})
const item = window_list[window_index]
item.activity = true
changeDock([...window_list])
},
}
}
export const DockContextProvider: React.FC<React.PropsWithChildren> = ({ children }) => {
return <DockContext.ContextProvider value={[]}>{children}</DockContext.ContextProvider>
}
接下来使用就很简单了。首先将 WindowDndContext 与 DockContextProvider 插入所需要拖拽的组件顶层。注意层级关系,DockContextProvider为最外层。
import React from 'react'
import Window from '@/components/desktop/window'
import { DockContextProvider } from '@/components/desktop/dock/dock-context'
import { OpenAppList } from '@/components/desktop/open-app-list'
import { WindowDndContext } from '@/components/desktop/dnd'
const Desktop: React.FC = () => {
return (
<DockContextProvider>
<WindowDndContext>
<Window>
<OpenAppList />
</Window>
</WindowDndContext>
</DockContextProvider>
)
}
export default Desktop
窗口拖拽组件
'use client'
import React from 'react'
import { WindowCloseButton, WindowItemStyle } from '@/components/desktop/style'
import { useDockAction } from '@/components/desktop/dock/dock-context'
import { useWindowDraggable } from '@/components/desktop/dnd'
import { Card, CardProps, Space } from 'antd'
import { CloseOutlined } from '@ant-design/icons'
const WindowBox: React.FC<{ window_id: number } & CardProps> = ({ children, window_id, ...props }) => {
const { remove, getWindowItem, changeActivity } = useDockAction()
const { setNodeRef, style, draggable_button } = useWindowDraggable({ id: window_id })
const { position, activity } = getWindowItem(window_id)
return (
<WindowItemStyle
ref={setNodeRef}
style={{
transform: `translate3d(${position.offsetX}px, ${position.offsetY}px, 0)`,
zIndex: activity ? 101 : 100,
...style,
}}
onClick={() => {
changeActivity(window_id)
}}
>
<Card
title={props.title}
extra={
<Space>
{draggable_button}
<WindowCloseButton
icon={<CloseOutlined />}
type="text"
onClick={(e) => {
e.stopPropagation()
remove(window_id)
}}
/>
</Space>
}
styles={{ body: { flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' } }}
style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
>
{children}
</Card>
</WindowItemStyle>
)
}
export default WindowBox
总结
实现起来很简单,使用 dnd-kit 的 useDraggable hooks 可以快速实现一个拖拽样式与事件。每次释放时记录当前的 x、y 偏移量。再次拖拽时需要在当前的 x、y 偏移量的基础上进行叠加。
后续将使用该组件实现拖拽文件夹、文件实现“剪切、移动”功能。
效果
长按 + 按钮时可整体拖拽当前“窗口”