269 lines
8.8 KiB
TypeScript
269 lines
8.8 KiB
TypeScript
import type { Ref } from 'vue'
|
||
import { ref } from 'vue'
|
||
import type { DropdownInstance, TreeV2Instance } from 'element-plus'
|
||
|
||
interface NodeBase<T> { id: string; label: string; children?: T[] }
|
||
type NodeType<T> = T & NodeBase<T>
|
||
type LocateResult<T> = { node: NodeType<T>; parent: NodeType<T> | null; container: NodeType<T>[]; index: number } | null
|
||
|
||
interface MenuItem { key: string; text: string; disabled?: boolean }
|
||
|
||
interface ContextMenuConfig<T> {
|
||
dataRef: Ref<NodeType<T>[]>;
|
||
treeRef: Ref<TreeV2Instance | undefined>;
|
||
expandedKeysRef: Ref<string[]>;
|
||
locate: (id: string) => LocateResult<T>;
|
||
startEdit: (node: NodeType<T>) => void;
|
||
}
|
||
|
||
interface ContextMenuContext<T> {
|
||
createNode: (prefix: string) => NodeType<T>;
|
||
setData: (next: NodeType<T>[]) => void;
|
||
expandNode: (id: string) => void;
|
||
setCurrentKey: (id: string) => void;
|
||
locate: (id: string) => LocateResult<T>;
|
||
startEdit: (node: NodeType<T>) => void;
|
||
dataRef: Ref<NodeType<T>[]>;
|
||
}
|
||
|
||
interface LevelConfig {
|
||
depth: number
|
||
addKey?: string
|
||
addText?: string
|
||
allowDelete?: boolean
|
||
}
|
||
|
||
interface HierarchyConfig {
|
||
rootKey: string
|
||
rootText: string
|
||
levels: LevelConfig[]
|
||
}
|
||
|
||
interface ContextMenuHandler<T> {
|
||
getMenuItems: (node: NodeType<T> | null, ctx: ContextMenuContext<T>) => MenuItem[];
|
||
execute: (cmd: string, node: NodeType<T> | null, ctx: ContextMenuContext<T>) => Promise<void> | void;
|
||
}
|
||
|
||
/**
|
||
* 获取节点深度
|
||
*/
|
||
const getDepth = <T>(node: NodeType<T>, ctx: ContextMenuContext<T>): number => {
|
||
const target = ctx.locate(node.id)
|
||
if (!target) return -1
|
||
let depth = 0
|
||
let p = target.parent
|
||
while (p) {
|
||
depth += 1
|
||
const pt = ctx.locate(p.id)
|
||
p = pt?.parent ?? null
|
||
}
|
||
return depth
|
||
}
|
||
|
||
/**
|
||
* 层级化上下文菜单处理器
|
||
*/
|
||
class HierarchyContextMenuHandler<T> implements ContextMenuHandler<T> {
|
||
constructor(private config: HierarchyConfig) {}
|
||
|
||
getMenuItems = (node: NodeType<T> | null, ctx: ContextMenuContext<T>): MenuItem[] => {
|
||
// 空白区域右键 - 添加根节点
|
||
if (!node) {
|
||
return [{ key: this.config.rootKey, text: this.config.rootText }]
|
||
}
|
||
|
||
const depth = getDepth(node, ctx)
|
||
const levelConfig = this.config.levels.find(l => l.depth === depth)
|
||
|
||
if (!levelConfig) {
|
||
// 未配置的层级,只显示删除
|
||
return [{ key: 'delete', text: '删除' }]
|
||
}
|
||
|
||
const items: MenuItem[] = []
|
||
|
||
// 添加子级菜单项
|
||
if (levelConfig.addKey && levelConfig.addText) {
|
||
items.push({ key: levelConfig.addKey, text: levelConfig.addText })
|
||
}
|
||
|
||
// 删除菜单项
|
||
if (levelConfig.allowDelete !== false) {
|
||
items.push({ key: 'delete', text: '删除' })
|
||
}
|
||
|
||
return items
|
||
}
|
||
|
||
execute = async (cmd: string, node: NodeType<T> | null, ctx: ContextMenuContext<T>) => {
|
||
// 添加根节点
|
||
if (!node && cmd === this.config.rootKey) {
|
||
const next = ctx.createNode('root')
|
||
next.label = this.config.rootText.replace('添加', '')
|
||
ctx.setData([...ctx.dataRef.value, next])
|
||
return
|
||
}
|
||
|
||
if (!node) return
|
||
|
||
// 删除节点
|
||
if (cmd === 'delete') {
|
||
const target = ctx.locate(node.id)
|
||
if (!target) return
|
||
target.container.splice(target.index, 1)
|
||
ctx.setData([...ctx.dataRef.value])
|
||
return
|
||
}
|
||
|
||
// 查找匹配的层级配置
|
||
const depth = getDepth(node, ctx)
|
||
const levelConfig = this.config.levels.find(l => l.depth === depth && l.addKey === cmd)
|
||
|
||
if (levelConfig) {
|
||
const target = ctx.locate(node.id)
|
||
if (!target) return
|
||
|
||
const next = ctx.createNode(node.id)
|
||
next.label = levelConfig.addText?.replace('添加', '') || '新目录'
|
||
|
||
target.node.children = target.node.children || []
|
||
target.node.children.push(next)
|
||
ctx.setData([...ctx.dataRef.value])
|
||
ctx.expandNode(target.node.id)
|
||
ctx.setCurrentKey(next.id)
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 默认上下文菜单处理器
|
||
*/
|
||
class DefaultContextMenuHandler<T> implements ContextMenuHandler<T> {
|
||
getMenuItems = (node: NodeType<T> | null, _ctx: ContextMenuContext<T>): MenuItem[] => {
|
||
if (!node) return [{ key: 'add-root', text: '添加根目录' }]
|
||
return [
|
||
{ key: 'add-sibling', text: '添加目录' },
|
||
{ key: 'add-child', text: '添加子目录' },
|
||
{ key: 'rename', text: '重命名' },
|
||
{ key: 'delete', text: '删除' },
|
||
]
|
||
}
|
||
|
||
execute = async (cmd: string, node: NodeType<T> | null, ctx: ContextMenuContext<T>) => {
|
||
if (!node && cmd === 'add-root') {
|
||
ctx.setData([...ctx.dataRef.value, ctx.createNode('node')])
|
||
return
|
||
}
|
||
if (!node) return
|
||
if (cmd === 'add-sibling') {
|
||
const target = ctx.locate(node.id)
|
||
if (!target) return
|
||
const prefix = target.parent ? target.parent.id : 'node'
|
||
target.container.push(ctx.createNode(prefix))
|
||
ctx.setData([...ctx.dataRef.value])
|
||
return
|
||
}
|
||
if (cmd === 'add-child') {
|
||
const target = ctx.locate(node.id)
|
||
if (!target) return
|
||
target.node.children = target.node.children || []
|
||
target.node.children.push(ctx.createNode(target.node.id))
|
||
ctx.setData([...ctx.dataRef.value])
|
||
ctx.expandNode(target.node.id)
|
||
ctx.setCurrentKey(target.node.id)
|
||
return
|
||
}
|
||
if (cmd === 'rename') { ctx.startEdit(node); return }
|
||
if (cmd === 'delete') {
|
||
const target = ctx.locate(node.id)
|
||
if (!target) return
|
||
target.container.splice(target.index, 1)
|
||
ctx.setData([...ctx.dataRef.value])
|
||
}
|
||
}
|
||
}
|
||
|
||
class DbTreeContextMenu<T> {
|
||
dropdownRef = ref<DropdownInstance>()
|
||
position = ref({ top: 0, left: 0, bottom: 0, right: 0 } as DOMRect)
|
||
triggerRef = ref({ getBoundingClientRect: () => this.position.value })
|
||
currentNode = ref<NodeType<T> | null>(null)
|
||
|
||
private config: ContextMenuConfig<T>
|
||
private handler: ContextMenuHandler<T>
|
||
|
||
constructor(config: ContextMenuConfig<T>, handler?: ContextMenuHandler<T>) {
|
||
this.config = config
|
||
this.handler = handler ?? new DefaultContextMenuHandler<T>()
|
||
}
|
||
|
||
private createNode = (prefix: string): NodeType<T> => {
|
||
const suffix = Math.random().toString(36).slice(2, 8)
|
||
const id = `${prefix}-${suffix}`
|
||
return { id, label: '新目录' } as NodeType<T>
|
||
}
|
||
|
||
private setData = (next: NodeType<T>[]) => { this.config.dataRef.value = next }
|
||
private expandNode = (id: string) => {
|
||
const keys = Array.from(new Set([...this.config.expandedKeysRef.value, id]))
|
||
this.config.expandedKeysRef.value = keys
|
||
this.config.treeRef.value?.setExpandedKeys(keys)
|
||
}
|
||
private setCurrentKey = (id: string) => { this.config.treeRef.value?.setCurrentKey?.(id) }
|
||
private ctx = (): ContextMenuContext<T> => ({
|
||
createNode: this.createNode,
|
||
setData: this.setData,
|
||
expandNode: this.expandNode,
|
||
setCurrentKey: this.setCurrentKey,
|
||
locate: this.config.locate,
|
||
startEdit: this.config.startEdit,
|
||
dataRef: this.config.dataRef,
|
||
})
|
||
|
||
getMenuItems = (): MenuItem[] => this.handler.getMenuItems(this.currentNode.value, this.ctx())
|
||
|
||
openContextMenu = (event: MouseEvent, nodeData: NodeType<T>) => {
|
||
const { clientX, clientY } = event
|
||
this.position.value = DOMRect.fromRect({ x: clientX, y: clientY })
|
||
event.preventDefault()
|
||
this.currentNode.value = nodeData
|
||
this.dropdownRef.value?.handleOpen()
|
||
}
|
||
|
||
openBlankContextMenu = (event: MouseEvent) => {
|
||
const { clientX, clientY } = event
|
||
this.position.value = DOMRect.fromRect({ x: clientX, y: clientY })
|
||
event.preventDefault()
|
||
this.currentNode.value = null
|
||
this.dropdownRef.value?.handleOpen()
|
||
}
|
||
|
||
closeContextMenu = () => { this.dropdownRef.value?.handleClose() }
|
||
|
||
onCommand = async (cmd: string) => { await this.handler.execute(cmd, this.currentNode.value, this.ctx()) }
|
||
|
||
onGlobalCommand = (cmd: string) => { void this.handler.execute(cmd, this.currentNode.value, this.ctx()) }
|
||
}
|
||
|
||
/**
|
||
* 创建层级化的上下文菜单处理器
|
||
* @param config 层级配置
|
||
* @returns ContextMenuHandler
|
||
*/
|
||
const createHierarchyContextMenuHandler = <T>(config: HierarchyConfig): ContextMenuHandler<T> => {
|
||
return new HierarchyContextMenuHandler<T>(config)
|
||
}
|
||
|
||
const createContextMenu = <T>(config: ContextMenuConfig<T>, handler?: ContextMenuHandler<T> | HierarchyConfig) => {
|
||
// 如果传入的是 HierarchyConfig,自动创建 HierarchyContextMenuHandler
|
||
if (handler && 'rootKey' in handler && 'rootText' in handler && 'levels' in handler) {
|
||
return new DbTreeContextMenu<T>(config, createHierarchyContextMenuHandler<T>(handler))
|
||
}
|
||
return new DbTreeContextMenu<T>(config, handler as ContextMenuHandler<T> | undefined)
|
||
}
|
||
|
||
export { DbTreeContextMenu, HierarchyContextMenuHandler, createHierarchyContextMenuHandler }
|
||
export type { ContextMenuConfig, ContextMenuHandler, MenuItem, NodeType, LocateResult, HierarchyConfig, LevelConfig }
|
||
|
||
export const useContextMenu = <T>(config: ContextMenuConfig<T>, handler?: ContextMenuHandler<T> | HierarchyConfig) => createContextMenu<T>(config, handler)
|