修改树的显示

This commit is contained in:
Qiubo Huang 2026-03-01 11:45:27 +08:00
parent bbcb734f72
commit d4b1e41171
1 changed files with 351 additions and 27 deletions

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import {
Folder,
FolderOpen,
@ -624,8 +624,15 @@ watch(mockTreeData, (newData) => {
}, { deep: true, immediate: true })
const selectedId = ref<string | null>('1')
const focusedId = ref<string | null>('1')
const expandedIds = ref<Set<string>>(new Set())
const dialogExpandedIds = ref<Set<string>>(new Set())
const treeRootRef = ref<HTMLElement | null>(null)
const contextMenuRef = ref<HTMLElement | null>(null)
const addNodeDialogRef = ref<HTMLElement | null>(null)
const operationDialogRef = ref<HTMLElement | null>(null)
const liveMessage = ref('')
const contextMenuTriggerId = ref<string | null>(null)
const contextMenu = ref({
show: false,
@ -818,8 +825,17 @@ const handleToggle = (id: string, isDialog = false) => {
const targetSet = isDialog ? dialogExpandedIds : expandedIds
if (targetSet.value.has(id)) {
targetSet.value.delete(id)
if (!isDialog && focusedId.value && focusedId.value !== id && isDescendantId(id, focusedId.value)) {
setActiveNode(id, true, true)
}
if (!isDialog) {
announce('Node collapsed')
}
} else {
targetSet.value.add(id)
if (!isDialog) {
announce('Node expanded')
}
}
}
@ -830,6 +846,7 @@ const handleDoubleClick = (id: string, node: TreeNode) => {
}
const handleSelect = (id: string, node: TreeNode) => {
focusedId.value = id
selectedId.value = id
emit('update:modelValue', id)
emit('select', node)
@ -846,11 +863,13 @@ const expandAll = () => {
})
}
expandNode(mockTreeData.value)
announce('All nodes expanded')
}
//
const collapseAll = () => {
expandedIds.value.clear()
announce('All nodes collapsed')
}
const isExpanded = (id: string, isDialog = false) => {
@ -858,6 +877,202 @@ const isExpanded = (id: string, isDialog = false) => {
return targetSet.value.has(id)
}
const isSelected = (id: string) => selectedId.value === id
const getNodeById = (id: string) => findNode(mockTreeData.value, id)
const getVisibleNodeIndex = (id: string) => flattenedNodes.value.findIndex(item => item.node.id === id)
const getNodeElement = (id: string): HTMLElement | null => {
if (!treeRootRef.value) return null
const safeId = id.replace(/"/g, '\\"')
return treeRootRef.value.querySelector(`[data-node-id="${safeId}"]`) as HTMLElement | null
}
const focusNodeElement = (id: string) => {
nextTick(() => {
getNodeElement(id)?.focus()
})
}
const setActiveNode = (id: string, select = true, focus = false) => {
const node = getNodeById(id)
if (!node) return
focusedId.value = id
if (select) {
handleSelect(id, node)
}
if (focus) {
focusNodeElement(id)
}
}
const isTabStop = (id: string, index: number) => {
if (focusedId.value) return focusedId.value === id
return index === 0
}
const moveFocusByOffset = (offset: number) => {
if (flattenedNodes.value.length === 0) return
const currentId = focusedId.value || selectedId.value || flattenedNodes.value[0].node.id
const currentIndex = getVisibleNodeIndex(currentId)
const fallbackIndex = currentIndex < 0 ? 0 : currentIndex
const nextIndex = Math.min(flattenedNodes.value.length - 1, Math.max(0, fallbackIndex + offset))
const nextNode = flattenedNodes.value[nextIndex]?.node
if (nextNode) {
setActiveNode(nextNode.id, true, true)
}
}
const focusFirstVisibleNode = () => {
const first = flattenedNodes.value[0]?.node
if (first) setActiveNode(first.id, true, true)
}
const focusLastVisibleNode = () => {
const last = flattenedNodes.value[flattenedNodes.value.length - 1]?.node
if (last) setActiveNode(last.id, true, true)
}
const isDescendantId = (ancestorId: string, nodeId: string) => {
if (ancestorId === nodeId) return true
let currentParent = findParentNode(mockTreeData.value, nodeId)
while (currentParent) {
if (currentParent.id === ancestorId) return true
currentParent = findParentNode(mockTreeData.value, currentParent.id)
}
return false
}
const announce = (message: string) => {
liveMessage.value = ''
nextTick(() => {
liveMessage.value = message
})
}
const openContextMenuFromKeyboard = (nodeId: string) => {
const targetEl = getNodeElement(nodeId)
const rect = targetEl?.getBoundingClientRect()
const x = rect ? Math.min(rect.left + 24, window.innerWidth - 200) : 24
const y = rect ? Math.min(rect.bottom, window.innerHeight - 320) : 24
contextMenuTriggerId.value = nodeId
contextMenu.value = {
show: true,
x,
y,
nodeId
}
}
const focusFirstMenuItem = () => {
const item = contextMenuRef.value?.querySelector('.menu-item[tabindex="0"]') as HTMLElement | null
item?.focus()
}
const focusNodeAfterContextMenu = () => {
if (!contextMenuTriggerId.value) return
const triggerId = contextMenuTriggerId.value
contextMenuTriggerId.value = null
focusNodeElement(triggerId)
}
const handleMenuKeydown = (event: KeyboardEvent) => {
if (!contextMenu.value.show || !contextMenuRef.value) return
const items = Array.from(contextMenuRef.value.querySelectorAll('.menu-item[tabindex="0"]')) as HTMLElement[]
if (items.length === 0) return
const currentIndex = items.findIndex(item => item === document.activeElement)
if (event.key === 'ArrowDown') {
event.preventDefault()
const next = currentIndex < 0 ? 0 : (currentIndex + 1) % items.length
items[next].focus()
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
const prev = currentIndex < 0 ? items.length - 1 : (currentIndex - 1 + items.length) % items.length
items[prev].focus()
return
}
if (event.key === 'Home') {
event.preventDefault()
items[0].focus()
return
}
if (event.key === 'End') {
event.preventDefault()
items[items.length - 1].focus()
return
}
if (event.key === 'Escape') {
event.preventDefault()
closeContextMenu()
return
}
if ((event.key === 'Enter' || event.key === ' ') && document.activeElement instanceof HTMLElement) {
event.preventDefault()
document.activeElement.click()
}
}
const handleNodeFocus = (id: string, node: TreeNode) => {
focusedId.value = id
if (!isSelected(id)) {
handleSelect(id, node)
}
}
const handleTreeKeydown = (event: KeyboardEvent, node: TreeNode, level: number) => {
const { key } = event
if (key === 'ArrowDown') {
event.preventDefault()
moveFocusByOffset(1)
return
}
if (key === 'ArrowUp') {
event.preventDefault()
moveFocusByOffset(-1)
return
}
if (key === 'Home') {
event.preventDefault()
focusFirstVisibleNode()
return
}
if (key === 'End') {
event.preventDefault()
focusLastVisibleNode()
return
}
if (key === 'ArrowRight') {
event.preventDefault()
if (hasChildren(node)) {
if (!isExpanded(node.id)) {
handleToggle(node.id)
} else {
const currentIndex = getVisibleNodeIndex(node.id)
const nextNode = flattenedNodes.value[currentIndex + 1]
if (nextNode && nextNode.level === level + 1) {
setActiveNode(nextNode.node.id, true, true)
}
}
}
return
}
if (key === 'ArrowLeft') {
event.preventDefault()
if (hasChildren(node) && isExpanded(node.id)) {
handleToggle(node.id)
} else {
const parentNode = findParentNode(mockTreeData.value, node.id)
if (parentNode) {
setActiveNode(parentNode.id, true, true)
}
}
return
}
if (key === 'Enter') {
event.preventDefault()
handleSelect(node.id, node)
return
}
if (key === ' ') {
event.preventDefault()
if (hasChildren(node)) {
handleToggle(node.id)
} else {
handleSelect(node.id, node)
}
return
}
if (key === 'ContextMenu' || (key === 'F10' && event.shiftKey)) {
event.preventDefault()
handleSelect(node.id, node)
openContextMenuFromKeyboard(node.id)
}
}
const hasChildren = (node: TreeNode) => node.children && node.children.length > 0
// isTerminal
@ -896,10 +1111,15 @@ const handleContextMenu = (e: MouseEvent, nodeId: string) => {
y,
nodeId
}
contextMenuTriggerId.value = nodeId
}
const closeContextMenu = () => {
const closeContextMenu = (restoreFocus = true) => {
const wasOpen = contextMenu.value.show
contextMenu.value.show = false
if (wasOpen && restoreFocus) {
focusNodeAfterContextMenu()
}
}
const openOperationDialog = (mode: 'copy' | 'clone' | 'branch') => {
@ -914,7 +1134,7 @@ const openOperationDialog = (mode: 'copy' | 'clone' | 'branch') => {
owner: currentNode?.owner || 'admin'
}
dialogExpandedIds.value = new Set(expandedIds.value)
closeContextMenu()
closeContextMenu(false)
}
const handleDelete = () => {
@ -929,8 +1149,15 @@ const handleDelete = () => {
if (selectedId.value === nodeId) {
selectedId.value = null
}
const fallbackId = selectedId.value && getNodeById(selectedId.value)
? selectedId.value
: flattenedNodes.value[0]?.node.id
if (fallbackId) {
setActiveNode(fallbackId, true, true)
}
contextMenuTriggerId.value = null
}
closeContextMenu()
closeContextMenu(false)
}
//
@ -959,7 +1186,7 @@ const handleAddSibling = () => {
}
console.log('addNodeDialog set to show')
closeContextMenu()
closeContextMenu(false)
}
//
@ -983,7 +1210,7 @@ const handleAddChild = () => {
}
console.log('addNodeDialog set to show')
closeContextMenu()
closeContextMenu(false)
}
//
@ -1293,6 +1520,49 @@ onMounted(() => {
initializeReviewHistory()
})
watch(() => contextMenu.value.show, (show) => {
if (show) {
nextTick(() => {
contextMenuRef.value?.focus()
focusFirstMenuItem()
})
}
})
watch(() => addNodeDialog.value.show, (show) => {
if (show) {
nextTick(() => {
addNodeDialogRef.value?.focus()
})
} else if (contextMenuTriggerId.value) {
focusNodeAfterContextMenu()
}
})
watch(() => operationDialog.value.show, (show) => {
if (show) {
nextTick(() => {
operationDialogRef.value?.focus()
})
} else if (contextMenuTriggerId.value) {
focusNodeAfterContextMenu()
}
})
watch(flattenedNodes, (nodes) => {
if (nodes.length === 0) {
focusedId.value = null
selectedId.value = null
return
}
if (focusedId.value && !nodes.some(item => item.node.id === focusedId.value)) {
focusedId.value = nodes[0].node.id
}
if (selectedId.value && !nodes.some(item => item.node.id === selectedId.value)) {
selectedId.value = focusedId.value || nodes[0].node.id
}
}, { immediate: true })
//
defineExpose({
updateNodeContent
@ -1309,15 +1579,15 @@ defineExpose({
<span>Vehicle level - Default(modified)</span>
</div>
<div class="tree-header-actions">
<button class="tree-header-btn" title="Expand All" @click="expandAll">
<button class="tree-header-btn" title="Expand All" aria-label="Expand all nodes" @click="expandAll">
<Plus :size="12" />
</button>
<button class="tree-header-btn" title="Collapse All" @click="collapseAll">
<button class="tree-header-btn" title="Collapse All" aria-label="Collapse all nodes" @click="collapseAll">
<Minus :size="12" />
</button>
</div>
</div>
<div class="tree-content">
<div ref="treeRootRef" class="tree-content" role="tree" aria-label="Document tree">
<div class="tree-column-headers">
<span class="tree-col-name">Name</span>
<span class="tree-col-status">Status</span>
@ -1325,13 +1595,21 @@ defineExpose({
</div>
<transition-group name="tree-row" tag="div" class="tree-nodes">
<div
v-for="{ node, level } in flattenedNodes"
v-for="({ node, level }, index) in flattenedNodes"
:key="node.id"
:class="['tree-node-wrapper', { 'has-children': hasChildren(node) }]"
>
<div
:class="['tree-node', { selected: isSelected(node.id) }]"
:data-node-id="node.id"
role="treeitem"
:aria-level="level + 1"
:aria-selected="isSelected(node.id)"
:aria-expanded="hasChildren(node) ? isExpanded(node.id) : undefined"
:tabindex="isTabStop(node.id, index) ? 0 : -1"
@click="handleSelect(node.id, node)"
@focus="handleNodeFocus(node.id, node)"
@keydown="handleTreeKeydown($event, node, level)"
@dblclick="handleDoubleClick(node.id, node)"
@contextmenu="handleContextMenu($event, node.id)"
>
@ -1339,11 +1617,18 @@ defineExpose({
class="tree-node-main"
:style="{ paddingLeft: `${level * 16 + 4}px` }"
>
<span class="tree-node-toggle" @click.stop="handleToggle(node.id)">
<button
v-if="hasChildren(node)"
type="button"
class="tree-node-toggle"
:aria-label="isExpanded(node.id) ? 'Collapse node' : 'Expand node'"
:aria-expanded="isExpanded(node.id)"
@click.stop="handleToggle(node.id)"
>
<Minus v-if="hasChildren(node) && isExpanded(node.id)" :size="12" />
<Plus v-else-if="hasChildren(node)" :size="12" />
<span v-else class="tree-node-spacer" />
</span>
</button>
<span v-else class="tree-node-spacer" />
<span class="tree-node-icon">
<Link v-if="isMappedNode(node)" :size="14" class="icon-mapped" />
<FileText v-else-if="isTerminalNode(node)" :size="14" class="icon-terminal" />
@ -1384,44 +1669,48 @@ defineExpose({
</div>
<!-- 右键菜单 -->
<div v-if="contextMenu.show" class="context-menu" :style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
@click.stop>
<div v-if="contextMenu.show" ref="contextMenuRef" class="context-menu" :style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
role="menu" tabindex="-1" @keydown="handleMenuKeydown" @click.stop>
<div class="menu-group">
<div class="menu-item" @click="handleAddSibling">
<div class="menu-item" role="menuitem" tabindex="0" @click="handleAddSibling">
<CornerUpLeft :size="14" />
<span>添加同级节点</span>
</div>
<div class="menu-item" @click="handleAddChild">
<div class="menu-item" role="menuitem" tabindex="0" @click="handleAddChild">
<CornerDownLeft :size="14" />
<span>添加下级节点</span>
</div>
<div class="menu-item delete" @click="handleDelete">
<div class="menu-item delete" role="menuitem" tabindex="0" @click="handleDelete">
<Trash2 :size="14" />
<span>edit</span>
</div>
<div class="menu-item delete" @click="handleDelete">
<div class="menu-item delete" role="menuitem" tabindex="0" @click="handleDelete">
<Trash2 :size="14" />
<span>Delete</span>
</div>
</div>
<div class="menu-divider"></div>
<div class="menu-group">
<div class="menu-item" :class="{ disabled: !isCopyEnabled(contextMenuNode) }"
<div class="menu-item" :class="{ disabled: !isCopyEnabled(contextMenuNode) }" role="menuitem"
:tabindex="isCopyEnabled(contextMenuNode) ? 0 : -1"
@click="isCopyEnabled(contextMenuNode) && openOperationDialog('copy')">
<Copy :size="14" />
<span>添加文档 (Copy From)...</span>
</div>
<div class="menu-item" :class="{ disabled: !isCloneEnabled(contextMenuNode) }"
<div class="menu-item" :class="{ disabled: !isCloneEnabled(contextMenuNode) }" role="menuitem"
:tabindex="isCloneEnabled(contextMenuNode) ? 0 : -1"
@click="isCloneEnabled(contextMenuNode) && openOperationDialog('clone')">
<Database :size="14" />
<span>添加文档 (Clone From)...</span>
</div>
<div class="menu-item" :class="{ disabled: !isBranchEnabled(contextMenuNode) }"
<div class="menu-item" :class="{ disabled: !isBranchEnabled(contextMenuNode) }" role="menuitem"
:tabindex="isBranchEnabled(contextMenuNode) ? 0 : -1"
@click="isBranchEnabled(contextMenuNode) && openOperationDialog('branch')">
<GitBranch :size="14" />
<span>Branch From...</span>
</div>
<div class="menu-item" :class="{ disabled: !isMapEnabled(contextMenuNode) }"
<div class="menu-item" :class="{ disabled: !isMapEnabled(contextMenuNode) }" role="menuitem"
:tabindex="isMapEnabled(contextMenuNode) ? 0 : -1"
@click="isMapEnabled(contextMenuNode) && handleMapTo()">
<Plus :size="14" />
<span>Map To...</span>
@ -1431,10 +1720,10 @@ defineExpose({
<!-- 添加节点对话框 -->
<div v-if="addNodeDialog.show" class="dialog-overlay" @click="addNodeDialog.show = false">
<div class="add-node-dialog" @click.stop>
<div ref="addNodeDialogRef" class="add-node-dialog" role="dialog" aria-modal="true" aria-label="Add node dialog" tabindex="-1" @keydown.esc="addNodeDialog.show = false" @click.stop>
<div class="dialog-header">
<h3>{{ addNodeDialog.mode === 'sibling' ? '添加同级节点' : '添加下级节点' }}</h3>
<button class="close-btn" @click="addNodeDialog.show = false">
<button class="close-btn" aria-label="Close dialog" @click="addNodeDialog.show = false">
<X :size="18" />
</button>
</div>
@ -1503,11 +1792,11 @@ defineExpose({
<!-- 操作选择弹窗 -->
<div v-if="operationDialog.show" class="dialog-overlay" @click="operationDialog.show = false">
<div class="operation-dialog" @click.stop>
<div ref="operationDialogRef" class="operation-dialog" role="dialog" aria-modal="true" aria-label="Operation dialog" tabindex="-1" @keydown.esc="operationDialog.show = false" @click.stop>
<div class="dialog-header">
<h3>{{ operationDialog.mode === 'copy' ? 'Copy Node' : operationDialog.mode === 'clone' ? 'Clone Node' :
'Branch Node' }}</h3>
<button class="close-btn" @click="operationDialog.show = false">
<button class="close-btn" aria-label="Close dialog" @click="operationDialog.show = false">
<X :size="18" />
</button>
</div>
@ -1566,6 +1855,7 @@ defineExpose({
</div>
</div>
</div>
<div class="sr-only" aria-live="polite" aria-atomic="true">{{ liveMessage }}</div>
</div>
</template>
@ -1710,6 +2000,13 @@ defineExpose({
user-select: none;
font-size: 12px;
will-change: background-color, box-shadow, color;
outline: none;
}
.tree-node:focus-visible {
box-shadow:
0 0 0 2px rgba(0, 120, 212, 0.9),
0 0 0 4px rgba(255, 255, 255, 0.85);
}
.tree-node.selected {
@ -1750,12 +2047,21 @@ defineExpose({
margin-right: 2px;
cursor: pointer;
color: #666;
background: transparent;
border: none;
padding: 0;
transition:
background-color 160ms ease-out,
color 160ms ease-out,
transform 220ms ease-out;
}
.tree-node-toggle:focus-visible {
outline: 2px solid #0078d4;
outline-offset: 1px;
border-radius: 2px;
}
.tree-node:hover .tree-node-toggle {
color: #005a9e;
transform: scale(1.18);
@ -1992,6 +2298,12 @@ defineExpose({
transform: translateX(2px);
}
.menu-item:focus-visible {
outline: 2px solid #0078d4;
outline-offset: -2px;
background: #eef6ff;
}
.menu-item.disabled {
opacity: 0.4;
cursor: not-allowed;
@ -2404,4 +2716,16 @@ defineExpose({
.node-owner-input::placeholder {
color: #999;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>