Files
workspace/code/g3soft-libs/packages/ui/src/tree/Tree.vue
T
2026-10-09 22:05:02 +08:00

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>