794 lines
24 KiB
Vue
794 lines
24 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
computed,
|
|
isVNode,
|
|
nextTick,
|
|
onBeforeUnmount,
|
|
provide,
|
|
reactive,
|
|
ref,
|
|
watch,
|
|
type VNode,
|
|
} from 'vue'
|
|
import { useDirection } from '../_composables/useDirection'
|
|
import { useLocale } from '../_composables/useLocale'
|
|
import { useNamespace } from '../_composables/useNamespace'
|
|
import { usePopupContainer } from '../_composables/usePopupContainer'
|
|
import { horizontalArrowStep } from '../_utils/direction'
|
|
import { computePosition, type RectLike } from '../_utils/position'
|
|
import { nextZIndex, releaseZIndex } from '../_utils/z-index'
|
|
import { treeContextKey } from './context'
|
|
import G3TreeNode from './TreeNode.vue'
|
|
import type {
|
|
DropPosition,
|
|
TreeCheckPayload,
|
|
TreeContext,
|
|
TreeContextMenuItem,
|
|
TreeKey,
|
|
TreeNodeData,
|
|
TreeProps,
|
|
} from './types'
|
|
import {
|
|
buildFlat,
|
|
calcDropPosition,
|
|
collectDescendants,
|
|
conductCheck,
|
|
filterTree,
|
|
getNodeChildren,
|
|
getNodeKey,
|
|
getParentKeys,
|
|
resolveFieldNames,
|
|
} from './utils'
|
|
|
|
/**
|
|
* 树形控件 —— 支持选中、勾选联动、过滤、拖拽排序、懒加载与右键菜单。
|
|
*
|
|
* 四套键(展开 / 选中 / 勾选 / 加载)都支持受控(传 prop 即受控)与非受控;
|
|
* 勾选非严格模式下只保存显式勾选的键,父子联动与半选状态由数据推导。
|
|
*/
|
|
defineOptions({ name: 'G3Tree' })
|
|
|
|
const props = withDefaults(defineProps<TreeProps>(), {
|
|
treeData: () => [],
|
|
fieldNames: undefined,
|
|
checkable: false,
|
|
selectable: true,
|
|
multiple: false,
|
|
checkStrictly: false,
|
|
disabled: false,
|
|
lazy: false,
|
|
draggable: false,
|
|
externalDragKey: undefined,
|
|
filter: '',
|
|
blockNode: false,
|
|
defaultExpandAll: false,
|
|
defaultExpandParent: true,
|
|
autoExpandParent: false,
|
|
contextMenu: undefined,
|
|
expandedKeys: undefined,
|
|
selectedKeys: undefined,
|
|
checkedKeys: undefined,
|
|
loadedKeys: undefined,
|
|
defaultExpandedKeys: () => [],
|
|
defaultSelectedKeys: () => [],
|
|
defaultCheckedKeys: () => [],
|
|
defaultLoadedKeys: () => [],
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
select: [keys: TreeKey[], info: { node: TreeNodeData; selectedKeys: TreeKey[]; nativeEvent: MouseEvent }]
|
|
check: [payload: TreeCheckPayload, info: { node: TreeNodeData; checked: boolean }]
|
|
expand: [expandedKeys: TreeKey[], info: { node: TreeNodeData; expanded: boolean }]
|
|
load: [node: TreeNodeData]
|
|
drop: [info: { dragKey: TreeKey; dropKey: TreeKey; dropPosition: DropPosition; node: TreeNodeData; event: DragEvent }]
|
|
dragstart: [info: { node: TreeNodeData; event: DragEvent }]
|
|
dragover: [info: { node: TreeNodeData; dropPosition: DropPosition; event: DragEvent }]
|
|
dragleave: [info: { node: TreeNodeData; event: DragEvent }]
|
|
dragend: [info: { node: TreeNodeData; event: DragEvent }]
|
|
contextmenu: [info: { node: TreeNodeData | null; key: TreeKey | null; event: MouseEvent }]
|
|
contextMenuClick: [info: { item: TreeContextMenuItem; node: TreeNodeData | null; key: TreeKey | null; event: MouseEvent }]
|
|
'update:expandedKeys': [keys: TreeKey[]]
|
|
'update:selectedKeys': [keys: TreeKey[]]
|
|
'update:checkedKeys': [keys: TreeKey[]]
|
|
'update:loadedKeys': [keys: TreeKey[]]
|
|
}>()
|
|
|
|
const { b, e, is } = useNamespace('tree')
|
|
const textDirection = useDirection()
|
|
const locale = useLocale()
|
|
const teleportTo = usePopupContainer()
|
|
|
|
const fieldNames = computed(() => resolveFieldNames(props.fieldNames))
|
|
const flat = computed(() => buildFlat(props.treeData, fieldNames.value))
|
|
|
|
/* ---------- 受控 / 非受控键模型 ---------- */
|
|
function createKeyModel(options: {
|
|
controlled: () => TreeKey[] | undefined
|
|
initial: () => TreeKey[]
|
|
emitKeys: (keys: TreeKey[]) => void
|
|
}) {
|
|
const internal = ref<string[]>(options.initial().map(String))
|
|
const set = computed(() => {
|
|
const value = options.controlled()
|
|
return new Set(value === undefined ? internal.value : value.map(String))
|
|
})
|
|
function update(next: Set<string>) {
|
|
const keys = [...next]
|
|
if (options.controlled() === undefined) internal.value = keys
|
|
options.emitKeys(keys)
|
|
}
|
|
return { set, update, internal }
|
|
}
|
|
|
|
const expandedModel = createKeyModel({
|
|
controlled: () => props.expandedKeys,
|
|
initial: () => {
|
|
if (props.defaultExpandAll) {
|
|
return [...flat.value.nodeMap.keys()]
|
|
}
|
|
const keys = props.defaultExpandedKeys
|
|
return props.defaultExpandParent
|
|
? [...new Set([...keys.map(String), ...getParentKeys(keys, flat.value.parentMap)])]
|
|
: keys
|
|
},
|
|
emitKeys: (keys) => emit('update:expandedKeys', keys),
|
|
})
|
|
|
|
const selectedModel = createKeyModel({
|
|
controlled: () => props.selectedKeys,
|
|
initial: () => props.defaultSelectedKeys,
|
|
emitKeys: (keys) => emit('update:selectedKeys', keys),
|
|
})
|
|
|
|
const loadedModel = createKeyModel({
|
|
controlled: () => props.loadedKeys,
|
|
initial: () => props.defaultLoadedKeys,
|
|
emitKeys: (keys) => emit('update:loadedKeys', keys),
|
|
})
|
|
|
|
/** 勾选:非严格模式只保存显式键,父子联动由 conductCheck 推导 */
|
|
const explicitChecked = ref<string[]>(
|
|
(Array.isArray(props.defaultCheckedKeys) ? props.defaultCheckedKeys : []).map(String),
|
|
)
|
|
const checkedExplicitSet = computed(() => {
|
|
const controlled = props.checkedKeys
|
|
if (controlled === undefined) return new Set(explicitChecked.value)
|
|
const list = Array.isArray(controlled) ? controlled : controlled.checked
|
|
return new Set(list.map(String))
|
|
})
|
|
|
|
const conductResult = computed(() => {
|
|
if (props.checkStrictly) {
|
|
return { checked: new Set(checkedExplicitSet.value), half: new Set<string>() }
|
|
}
|
|
return conductCheck(props.treeData, checkedExplicitSet.value, fieldNames.value)
|
|
})
|
|
|
|
const expandedSet = expandedModel.set
|
|
const selectedSet = selectedModel.set
|
|
const loadingSet = ref(new Set<string>())
|
|
|
|
/* ---------- 过滤 ---------- */
|
|
const filterActive = computed(() => props.filter.trim() !== '')
|
|
const filterInfo = computed(() => filterTree(props.treeData, props.filter, fieldNames.value))
|
|
|
|
/** 过滤命中的祖先需要临时展开,但不写入用户的 expandedKeys */
|
|
const effectiveExpanded = computed(() => {
|
|
const set = new Set(expandedSet.value)
|
|
if (filterActive.value) filterInfo.value.expand.forEach((key) => set.add(key))
|
|
return set
|
|
})
|
|
|
|
/* ---------- 可见节点(键盘导航用) ---------- */
|
|
const visibleKeys = computed(() => {
|
|
const keys: string[] = []
|
|
const visible = filterActive.value ? filterInfo.value.matched : null
|
|
function walk(nodes: TreeNodeData[]) {
|
|
for (const node of nodes) {
|
|
const key = String(getNodeKey(node, fieldNames.value))
|
|
if (visible && !visible.has(key)) continue
|
|
keys.push(key)
|
|
if (effectiveExpanded.value.has(key)) {
|
|
const children = getNodeChildren(node, fieldNames.value)
|
|
if (children) walk(children)
|
|
}
|
|
}
|
|
}
|
|
walk(props.treeData)
|
|
return keys
|
|
})
|
|
|
|
const activeKey = ref<string | null>(null)
|
|
watch(
|
|
visibleKeys,
|
|
(keys) => {
|
|
if (!keys.length) activeKey.value = null
|
|
else if (!activeKey.value || !keys.includes(activeKey.value)) activeKey.value = keys[0]
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
const nodeEls = new Map<string, HTMLElement>()
|
|
function registerNode(key: string, el: HTMLElement | null) {
|
|
if (el) nodeEls.set(key, el)
|
|
}
|
|
function unregisterNode(key: string) {
|
|
nodeEls.delete(key)
|
|
}
|
|
|
|
/* ---------- 展开 ---------- */
|
|
function nodeOf(key: string): TreeNodeData | undefined {
|
|
return flat.value.nodeMap.get(key)
|
|
}
|
|
|
|
function toggleExpand(node: TreeNodeData, key: TreeKey) {
|
|
const textKey = String(key)
|
|
if (props.disabled) return
|
|
const next = new Set(expandedSet.value)
|
|
const willExpand = !next.has(textKey)
|
|
if (willExpand) next.add(textKey)
|
|
else next.delete(textKey)
|
|
expandedModel.update(next)
|
|
emit('expand', [...next], { node, expanded: willExpand })
|
|
// 懒加载:首次展开且未加载过、无子节点、未显式标记叶子
|
|
if (
|
|
willExpand &&
|
|
props.lazy &&
|
|
!loadedModel.set.value.has(textKey) &&
|
|
!getNodeChildren(node, fieldNames.value) &&
|
|
node.isLeaf !== true
|
|
) {
|
|
loadingSet.value.add(textKey)
|
|
emit('load', node)
|
|
}
|
|
}
|
|
|
|
watch(
|
|
() => loadedModel.set.value,
|
|
(loaded) => {
|
|
if (!loadingSet.value.size) return
|
|
const next = new Set(loadingSet.value)
|
|
loaded.forEach((key) => next.delete(key))
|
|
loadingSet.value = next
|
|
},
|
|
)
|
|
|
|
function expandParents(key: string) {
|
|
if (!props.autoExpandParent) return
|
|
const parents = getParentKeys([key], flat.value.parentMap)
|
|
if (!parents.length) return
|
|
const next = new Set(expandedSet.value)
|
|
parents.forEach((parent) => next.add(parent))
|
|
expandedModel.update(next)
|
|
}
|
|
|
|
/* ---------- 选中 ---------- */
|
|
function handleSelect(node: TreeNodeData, key: TreeKey, event: MouseEvent) {
|
|
if (props.disabled || !props.selectable) return
|
|
activeKey.value = String(key)
|
|
nodeEls.get(String(key))?.focus()
|
|
const current = new Set(selectedSet.value)
|
|
const textKey = String(key)
|
|
if (props.multiple && (event.ctrlKey || event.metaKey)) {
|
|
if (current.has(textKey)) current.delete(textKey)
|
|
else current.add(textKey)
|
|
} else if (current.has(textKey) && current.size === 1) {
|
|
current.clear()
|
|
} else {
|
|
current.clear()
|
|
current.add(textKey)
|
|
}
|
|
selectedModel.update(current)
|
|
expandParents(textKey)
|
|
emit('select', [...current], { node, selectedKeys: [...current], nativeEvent: event })
|
|
}
|
|
|
|
/* ---------- 勾选 ---------- */
|
|
function handleCheck(node: TreeNodeData, key: TreeKey) {
|
|
if (props.disabled || node.disableCheckbox === true) return
|
|
const textKey = String(key)
|
|
const next = new Set(checkedExplicitSet.value)
|
|
const turnOn = !conductResult.value.checked.has(textKey)
|
|
|
|
if (props.checkStrictly) {
|
|
if (turnOn) next.add(textKey)
|
|
else next.delete(textKey)
|
|
} else {
|
|
// ① 先从显式集合剔除该子树,② 需要勾选时再整棵写入,
|
|
// ③ 祖先必须移出显式集合(否则取消子节点会被父节点残留条目级联拉回勾选)
|
|
collectDescendants(textKey, flat.value.childrenMap).forEach((descendant) => next.delete(descendant))
|
|
getParentKeys([textKey], flat.value.parentMap).forEach((ancestor) => next.delete(ancestor))
|
|
if (turnOn) {
|
|
collectDescendants(textKey, flat.value.childrenMap).forEach((descendant) => next.add(descendant))
|
|
}
|
|
}
|
|
|
|
explicitChecked.value = [...next]
|
|
if (props.checkedKeys !== undefined) emit('update:checkedKeys', [...next])
|
|
|
|
const result = props.checkStrictly
|
|
? { checked: new Set(next), half: new Set<string>() }
|
|
: conductCheck(props.treeData, next, fieldNames.value)
|
|
emit(
|
|
'check',
|
|
{ checked: [...result.checked], halfChecked: [...result.half] },
|
|
{ node, checked: turnOn },
|
|
)
|
|
expandParents(textKey)
|
|
}
|
|
|
|
/* ---------- 拖拽 ---------- */
|
|
const dragKey = ref<string | null>(null)
|
|
const dropKey = ref<string | null>(null)
|
|
const dropPosition = ref<DropPosition | null>(null)
|
|
let internalDrag = false
|
|
/** drop 事件里要用的落点快照(清理视觉状态前先存下来) */
|
|
let dropSnapshot: DropPosition | null = null
|
|
|
|
function resolveDragKey(dataTransfer: DataTransfer | null, event: DragEvent): string | null {
|
|
if (internalDrag) return dragKey.value
|
|
if (props.externalDragKey && dataTransfer) {
|
|
const key = props.externalDragKey(dataTransfer, event)
|
|
return key == null ? null : String(key)
|
|
}
|
|
return null
|
|
}
|
|
|
|
function isDescendant(ancestor: string, target: string): boolean {
|
|
return collectDescendants(ancestor, flat.value.childrenMap).includes(target)
|
|
}
|
|
|
|
function resetDragVisual() {
|
|
dragKey.value = null
|
|
dropKey.value = null
|
|
dropPosition.value = null
|
|
}
|
|
|
|
function onDragStart(node: TreeNodeData, key: TreeKey, event: DragEvent) {
|
|
internalDrag = true
|
|
dragKey.value = String(key)
|
|
event.dataTransfer?.setData('text/plain', String(key))
|
|
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'
|
|
emit('dragstart', { node, event })
|
|
}
|
|
|
|
function onDragOver(node: TreeNodeData, key: TreeKey, event: DragEvent) {
|
|
const sourceKey = resolveDragKey(event.dataTransfer, event)
|
|
if (!sourceKey) return
|
|
const textKey = String(key)
|
|
if (sourceKey === textKey || isDescendant(sourceKey, textKey)) return
|
|
event.preventDefault()
|
|
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
|
|
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
|
const position = calcDropPosition(event.clientY, rect)
|
|
dropKey.value = textKey
|
|
dropPosition.value = position
|
|
emit('dragover', { node, dropPosition: position, event })
|
|
}
|
|
|
|
function onDragLeave(node: TreeNodeData, key: TreeKey, event: DragEvent) {
|
|
if (dropKey.value !== String(key)) return
|
|
dropKey.value = null
|
|
dropPosition.value = null
|
|
emit('dragleave', { node, event })
|
|
}
|
|
|
|
function onDrop(node: TreeNodeData, key: TreeKey, event: DragEvent) {
|
|
const sourceKey = resolveDragKey(event.dataTransfer, event)
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
// 先快照落点,再清理视觉状态(否则读到的永远是 null)
|
|
dropSnapshot = dropPosition.value
|
|
const targetKey = String(key)
|
|
resetDragVisual()
|
|
if (!sourceKey || sourceKey === targetKey || !dropSnapshot) return
|
|
if (isDescendant(sourceKey, targetKey)) return
|
|
emit('drop', {
|
|
dragKey: sourceKey,
|
|
dropKey: targetKey,
|
|
dropPosition: dropSnapshot,
|
|
node,
|
|
event,
|
|
})
|
|
dropSnapshot = null
|
|
}
|
|
|
|
function onDragEnd(node: TreeNodeData, _key: TreeKey, event: DragEvent) {
|
|
resetDragVisual()
|
|
internalDrag = false
|
|
emit('dragend', { node, event })
|
|
}
|
|
|
|
function onRootDragLeave(event: DragEvent) {
|
|
const related = event.relatedTarget as Node | null
|
|
if (related && (event.currentTarget as HTMLElement).contains(related)) return
|
|
resetDragVisual()
|
|
internalDrag = false
|
|
}
|
|
|
|
function onRootDrop(event: DragEvent) {
|
|
// 落在空白处:不处理,清理视觉状态
|
|
event.preventDefault()
|
|
resetDragVisual()
|
|
internalDrag = false
|
|
}
|
|
|
|
/* ---------- 键盘导航 ---------- */
|
|
function moveActive(delta: 1 | -1) {
|
|
const keys = visibleKeys.value
|
|
if (!keys.length) return
|
|
const pos = keys.indexOf(activeKey.value ?? keys[0])
|
|
const next = (pos + delta + keys.length) % keys.length
|
|
activeKey.value = keys[next]
|
|
nextTick(() => nodeEls.get(keys[next])?.focus())
|
|
}
|
|
|
|
function onKeydown(event: KeyboardEvent) {
|
|
if (props.disabled) return
|
|
const keys = visibleKeys.value
|
|
if (!keys.length) return
|
|
const key = activeKey.value ?? keys[0]
|
|
const node = nodeOf(key)
|
|
if (!node) return
|
|
switch (event.key) {
|
|
case 'ArrowDown':
|
|
event.preventDefault()
|
|
moveActive(1)
|
|
break
|
|
case 'ArrowUp':
|
|
event.preventDefault()
|
|
moveActive(-1)
|
|
break
|
|
case 'ArrowRight':
|
|
case 'ArrowLeft': {
|
|
event.preventDefault()
|
|
// 朝行内结束方向(LTR 右 / RTL 左)= 展开或进第一个子节点;反向 = 收起或回父节点。
|
|
// 树的缩进与展开箭头都跟着方向走,键盘语义必须与画面一致。
|
|
if (horizontalArrowStep(event.key, textDirection.value) === 1) {
|
|
if (!effectiveExpanded.value.has(key)) toggleExpand(node, key)
|
|
else {
|
|
const children = getNodeChildren(node, fieldNames.value)
|
|
const firstChild = children?.[0]
|
|
if (firstChild) {
|
|
const childKey = String(getNodeKey(firstChild, fieldNames.value))
|
|
activeKey.value = childKey
|
|
nextTick(() => nodeEls.get(childKey)?.focus())
|
|
}
|
|
}
|
|
} else {
|
|
if (effectiveExpanded.value.has(key)) {
|
|
toggleExpand(node, key)
|
|
break
|
|
}
|
|
const parent = flat.value.parentMap.get(key)
|
|
if (parent) {
|
|
activeKey.value = parent
|
|
nextTick(() => nodeEls.get(parent)?.focus())
|
|
}
|
|
}
|
|
break
|
|
}
|
|
case 'Enter':
|
|
event.preventDefault()
|
|
handleSelect(node, key, event as unknown as MouseEvent)
|
|
break
|
|
case ' ':
|
|
event.preventDefault()
|
|
if (props.checkable) handleCheck(node, key)
|
|
else handleSelect(node, key, event as unknown as MouseEvent)
|
|
break
|
|
}
|
|
}
|
|
|
|
/* ---------- 右键菜单 ---------- */
|
|
const menuVisible = ref(false)
|
|
const menuStyle = ref<Record<string, string>>({})
|
|
const menuItems = ref<TreeContextMenuItem[]>([])
|
|
const menuTarget = ref<{ node: TreeNodeData | null; key: TreeKey | null }>({ node: null, key: null })
|
|
const menuZIndex = ref(0)
|
|
let menuAnchor: RectLike | null = null
|
|
|
|
function closeMenu() {
|
|
if (!menuVisible.value) return
|
|
menuVisible.value = false
|
|
if (menuZIndex.value) {
|
|
releaseZIndex(menuZIndex.value)
|
|
menuZIndex.value = 0
|
|
}
|
|
document.removeEventListener('mousedown', onDocumentMouseDown, true)
|
|
document.removeEventListener('keydown', onDocumentKeydown, true)
|
|
window.removeEventListener('scroll', closeMenu, true)
|
|
window.removeEventListener('resize', closeMenu)
|
|
}
|
|
|
|
function onDocumentMouseDown(event: MouseEvent) {
|
|
const target = event.target as Node | null
|
|
if (target && (target as HTMLElement).closest?.(`.${b('context-menu')}`)) return
|
|
closeMenu()
|
|
}
|
|
|
|
function onDocumentKeydown(event: KeyboardEvent) {
|
|
if (event.key === 'Escape') closeMenu()
|
|
}
|
|
|
|
function openMenu(node: TreeNodeData | null, key: TreeKey | null, event: MouseEvent) {
|
|
const source = props.contextMenu
|
|
const items =
|
|
typeof source === 'function' ? source(node, key) : Array.isArray(source) ? source : []
|
|
if (!items.length) return
|
|
menuItems.value = items
|
|
menuTarget.value = { node, key }
|
|
menuAnchor = {
|
|
left: event.clientX,
|
|
top: event.clientY,
|
|
right: event.clientX,
|
|
bottom: event.clientY,
|
|
width: 0,
|
|
height: 0,
|
|
}
|
|
// 菜单已开着又被打开(换目标节点)时先回收旧层级,避免档位里留下永不释放的占位
|
|
if (menuZIndex.value) releaseZIndex(menuZIndex.value)
|
|
menuZIndex.value = nextZIndex('popup')
|
|
menuVisible.value = true
|
|
nextTick(() => {
|
|
const el = document.querySelector<HTMLElement>(`.${b('context-menu')}`)
|
|
const size = { width: el?.offsetWidth ?? 160, height: el?.offsetHeight ?? 0 }
|
|
const { x, y } = computePosition({
|
|
placement: 'bottom-start',
|
|
triggerRect: menuAnchor as RectLike,
|
|
floatSize: size,
|
|
offset: 4,
|
|
})
|
|
menuStyle.value = { left: `${x}px`, top: `${y}px`, zIndex: String(menuZIndex.value) }
|
|
})
|
|
document.addEventListener('mousedown', onDocumentMouseDown, true)
|
|
document.addEventListener('keydown', onDocumentKeydown, true)
|
|
window.addEventListener('scroll', closeMenu, true)
|
|
window.addEventListener('resize', closeMenu)
|
|
}
|
|
|
|
function onNodeContextMenu(node: TreeNodeData, key: TreeKey, event: MouseEvent) {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
emit('contextmenu', { node, key, event })
|
|
openMenu(node, key, event)
|
|
}
|
|
|
|
function onRootContextMenu(event: MouseEvent) {
|
|
emit('contextmenu', { node: null, key: null, event })
|
|
openMenu(null, null, event)
|
|
}
|
|
|
|
function onMenuClick(item: TreeContextMenuItem, event: MouseEvent) {
|
|
if (item.disabled) return
|
|
emit('contextMenuClick', { item, node: menuTarget.value.node, key: menuTarget.value.key, event })
|
|
closeMenu()
|
|
}
|
|
|
|
onBeforeUnmount(() => {
|
|
closeMenu()
|
|
})
|
|
|
|
/* ---------- 上下文 ----------
|
|
用 getter 暴露响应式值:读取发生在消费方(TreeNode 的渲染 / computed)的
|
|
响应式作用域里,因此依赖能被正确收集,不需要额外包一层 reactive。 */
|
|
const treeContext: TreeContext = {
|
|
get fieldNames() {
|
|
return fieldNames.value
|
|
},
|
|
get checkable() {
|
|
return props.checkable
|
|
},
|
|
get selectable() {
|
|
return props.selectable
|
|
},
|
|
get multiple() {
|
|
return props.multiple
|
|
},
|
|
get checkStrictly() {
|
|
return props.checkStrictly
|
|
},
|
|
get disabled() {
|
|
return props.disabled
|
|
},
|
|
get lazy() {
|
|
return props.lazy
|
|
},
|
|
get draggable() {
|
|
return props.draggable
|
|
},
|
|
get blockNode() {
|
|
return props.blockNode
|
|
},
|
|
get expandedSet() {
|
|
return effectiveExpanded.value
|
|
},
|
|
get selectedSet() {
|
|
return selectedSet.value
|
|
},
|
|
get checkedSet() {
|
|
return conductResult.value.checked
|
|
},
|
|
get halfSet() {
|
|
return conductResult.value.half
|
|
},
|
|
get loadedSet() {
|
|
return loadedModel.set.value
|
|
},
|
|
get loadingSet() {
|
|
return loadingSet.value
|
|
},
|
|
get filterActive() {
|
|
return filterActive.value
|
|
},
|
|
get filterVisible() {
|
|
return filterActive.value ? filterInfo.value.matched : null
|
|
},
|
|
get filterKeyword() {
|
|
return props.filter
|
|
},
|
|
get activeKey() {
|
|
return activeKey.value
|
|
},
|
|
get dragKey() {
|
|
return dragKey.value
|
|
},
|
|
get dropKey() {
|
|
return dropKey.value
|
|
},
|
|
get dropPosition() {
|
|
return dropPosition.value
|
|
},
|
|
toggleExpand,
|
|
handleSelect,
|
|
handleCheck,
|
|
onDragStart,
|
|
onDragOver,
|
|
onDragLeave,
|
|
onDrop,
|
|
onDragEnd,
|
|
onNodeContextMenu,
|
|
registerNode,
|
|
unregisterNode,
|
|
isActive: (key: string) => activeKey.value === key,
|
|
}
|
|
|
|
provide(treeContextKey, treeContext)
|
|
|
|
/** 渲染函数组件:把 VNode 形态的菜单图标原样渲染 */
|
|
const VNodeRenderer = (renderProps: { node: VNode }) => renderProps.node
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
:class="[b(), { [is('disabled')]: disabled }]"
|
|
role="tree"
|
|
:aria-multiselectable="multiple || undefined"
|
|
@keydown="onKeydown"
|
|
@dragleave="onRootDragLeave"
|
|
@dragover.prevent
|
|
@drop="onRootDrop"
|
|
@contextmenu="onRootContextMenu"
|
|
>
|
|
<template v-if="treeData.length">
|
|
<G3TreeNode
|
|
v-for="node in treeData"
|
|
:key="String(getNodeKey(node, fieldNames))"
|
|
:node="node"
|
|
:level="0"
|
|
>
|
|
<template v-if="$slots.title" #title="scope">
|
|
<slot name="title" v-bind="scope" />
|
|
</template>
|
|
</G3TreeNode>
|
|
</template>
|
|
<div v-else :class="e('empty')">
|
|
<slot name="empty">{{ locale.empty.description }}</slot>
|
|
</div>
|
|
|
|
<Teleport :to="teleportTo">
|
|
<Transition name="g3-popover">
|
|
<div v-if="menuVisible" :class="e('context-menu')" :style="menuStyle" role="menu">
|
|
<template v-for="(item, index) in menuItems" :key="item.key ?? index">
|
|
<div v-if="item.divider" :class="e('context-menu-divider')" role="separator" />
|
|
<button
|
|
v-else
|
|
type="button"
|
|
:class="[
|
|
e('context-menu-item'),
|
|
{
|
|
[is('context-menu-item-danger')]: item.danger,
|
|
[is('context-menu-item-disabled')]: item.disabled,
|
|
},
|
|
]"
|
|
role="menuitem"
|
|
:disabled="item.disabled"
|
|
@click="onMenuClick(item, $event)"
|
|
>
|
|
<span v-if="item.icon" :class="e('context-menu-icon')">
|
|
<component :is="item.icon" v-if="!isVNode(item.icon)" />
|
|
<VNodeRenderer v-else :node="item.icon" />
|
|
</span>
|
|
<span>{{ item.label }}</span>
|
|
</button>
|
|
</template>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="scss">
|
|
@use '@g3soft/tokens/scss' as *;
|
|
|
|
.#{b('tree')} {
|
|
position: relative;
|
|
width: 100%;
|
|
outline: none;
|
|
}
|
|
|
|
.#{e('tree', 'empty')} {
|
|
padding: v('space-6', 32px) 0;
|
|
color: v('text-3', rgba(0, 0, 0, 0.45));
|
|
font-size: v('font-size', 14px);
|
|
text-align: center;
|
|
}
|
|
|
|
/* ---------- 右键菜单 ---------- */
|
|
.#{e('tree', 'context-menu')} {
|
|
position: fixed;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-width: v('dropdown-min-width', 180px);
|
|
padding: v('space-1', 4px);
|
|
border: 1px solid v('border-color', #d9d9d9);
|
|
border-radius: v('radius', 6px);
|
|
background: v('bg-container', #fff);
|
|
box-shadow: v('shadow-popup', 0 6px 16px rgba(0, 0, 0, 0.12));
|
|
}
|
|
|
|
.#{e('tree', 'context-menu-item')} {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: v('space-2', 8px);
|
|
padding: v('dropdown-item-padding-y', 5px) v('dropdown-item-padding-x', 8px);
|
|
border: 0;
|
|
border-radius: v('dropdown-item-radius', 4px);
|
|
background: transparent;
|
|
color: v('text-1', rgba(0, 0, 0, 0.88));
|
|
font-family: inherit;
|
|
font-size: v('font-size', 14px);
|
|
text-align: left;
|
|
cursor: pointer;
|
|
transition: background-color v('duration-fast', 0.1s) v('ease', ease);
|
|
}
|
|
|
|
.#{e('tree', 'context-menu-item')}:hover:not(:disabled) {
|
|
background: v('fill', rgba(0, 0, 0, 0.04));
|
|
}
|
|
|
|
.#{e('tree', 'context-menu-item')}.#{is('context-menu-item-danger')} {
|
|
color: v('color-danger', #ec4246);
|
|
}
|
|
|
|
.#{e('tree', 'context-menu-item')}.#{is('context-menu-item-disabled')} {
|
|
color: v('text-disabled', rgba(0, 0, 0, 0.25));
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.#{e('tree', 'context-menu-icon')} {
|
|
display: inline-flex;
|
|
|
|
svg {
|
|
width: 1em;
|
|
height: 1em;
|
|
}
|
|
}
|
|
|
|
.#{e('tree', 'context-menu-divider')} {
|
|
height: 1px;
|
|
margin: v('dropdown-divider-margin', 4px 0);
|
|
background: v('border-color', #d9d9d9);
|
|
}
|
|
</style>
|