init
This commit is contained in:
268
apps/web-ele/src/components/db-tree/contextMenu.ts
Normal file
268
apps/web-ele/src/components/db-tree/contextMenu.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user