修改树的显示
This commit is contained in:
parent
bbcb734f72
commit
d4b1e41171
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted } from 'vue'
|
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
||||||
import {
|
import {
|
||||||
Folder,
|
Folder,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
|
@ -624,8 +624,15 @@ watch(mockTreeData, (newData) => {
|
||||||
}, { deep: true, immediate: true })
|
}, { deep: true, immediate: true })
|
||||||
|
|
||||||
const selectedId = ref<string | null>('1')
|
const selectedId = ref<string | null>('1')
|
||||||
|
const focusedId = ref<string | null>('1')
|
||||||
const expandedIds = ref<Set<string>>(new Set())
|
const expandedIds = ref<Set<string>>(new Set())
|
||||||
const dialogExpandedIds = 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({
|
const contextMenu = ref({
|
||||||
show: false,
|
show: false,
|
||||||
|
|
@ -818,8 +825,17 @@ const handleToggle = (id: string, isDialog = false) => {
|
||||||
const targetSet = isDialog ? dialogExpandedIds : expandedIds
|
const targetSet = isDialog ? dialogExpandedIds : expandedIds
|
||||||
if (targetSet.value.has(id)) {
|
if (targetSet.value.has(id)) {
|
||||||
targetSet.value.delete(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 {
|
} else {
|
||||||
targetSet.value.add(id)
|
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) => {
|
const handleSelect = (id: string, node: TreeNode) => {
|
||||||
|
focusedId.value = id
|
||||||
selectedId.value = id
|
selectedId.value = id
|
||||||
emit('update:modelValue', id)
|
emit('update:modelValue', id)
|
||||||
emit('select', node)
|
emit('select', node)
|
||||||
|
|
@ -846,11 +863,13 @@ const expandAll = () => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
expandNode(mockTreeData.value)
|
expandNode(mockTreeData.value)
|
||||||
|
announce('All nodes expanded')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 折叠所有节点
|
// 折叠所有节点
|
||||||
const collapseAll = () => {
|
const collapseAll = () => {
|
||||||
expandedIds.value.clear()
|
expandedIds.value.clear()
|
||||||
|
announce('All nodes collapsed')
|
||||||
}
|
}
|
||||||
|
|
||||||
const isExpanded = (id: string, isDialog = false) => {
|
const isExpanded = (id: string, isDialog = false) => {
|
||||||
|
|
@ -858,6 +877,202 @@ const isExpanded = (id: string, isDialog = false) => {
|
||||||
return targetSet.value.has(id)
|
return targetSet.value.has(id)
|
||||||
}
|
}
|
||||||
const isSelected = (id: string) => selectedId.value === 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
|
const hasChildren = (node: TreeNode) => node.children && node.children.length > 0
|
||||||
// 终端节点:只根据显式标记的isTerminal判断
|
// 终端节点:只根据显式标记的isTerminal判断
|
||||||
|
|
@ -896,10 +1111,15 @@ const handleContextMenu = (e: MouseEvent, nodeId: string) => {
|
||||||
y,
|
y,
|
||||||
nodeId
|
nodeId
|
||||||
}
|
}
|
||||||
|
contextMenuTriggerId.value = nodeId
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeContextMenu = () => {
|
const closeContextMenu = (restoreFocus = true) => {
|
||||||
|
const wasOpen = contextMenu.value.show
|
||||||
contextMenu.value.show = false
|
contextMenu.value.show = false
|
||||||
|
if (wasOpen && restoreFocus) {
|
||||||
|
focusNodeAfterContextMenu()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openOperationDialog = (mode: 'copy' | 'clone' | 'branch') => {
|
const openOperationDialog = (mode: 'copy' | 'clone' | 'branch') => {
|
||||||
|
|
@ -914,7 +1134,7 @@ const openOperationDialog = (mode: 'copy' | 'clone' | 'branch') => {
|
||||||
owner: currentNode?.owner || 'admin'
|
owner: currentNode?.owner || 'admin'
|
||||||
}
|
}
|
||||||
dialogExpandedIds.value = new Set(expandedIds.value)
|
dialogExpandedIds.value = new Set(expandedIds.value)
|
||||||
closeContextMenu()
|
closeContextMenu(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
|
|
@ -929,8 +1149,15 @@ const handleDelete = () => {
|
||||||
if (selectedId.value === nodeId) {
|
if (selectedId.value === nodeId) {
|
||||||
selectedId.value = null
|
selectedId.value = null
|
||||||
}
|
}
|
||||||
|
const fallbackId = selectedId.value && getNodeById(selectedId.value)
|
||||||
|
? selectedId.value
|
||||||
|
: flattenedNodes.value[0]?.node.id
|
||||||
|
if (fallbackId) {
|
||||||
|
setActiveNode(fallbackId, true, true)
|
||||||
}
|
}
|
||||||
closeContextMenu()
|
contextMenuTriggerId.value = null
|
||||||
|
}
|
||||||
|
closeContextMenu(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加同级节点
|
// 添加同级节点
|
||||||
|
|
@ -959,7 +1186,7 @@ const handleAddSibling = () => {
|
||||||
}
|
}
|
||||||
console.log('addNodeDialog set to show')
|
console.log('addNodeDialog set to show')
|
||||||
|
|
||||||
closeContextMenu()
|
closeContextMenu(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加下级节点
|
// 添加下级节点
|
||||||
|
|
@ -983,7 +1210,7 @@ const handleAddChild = () => {
|
||||||
}
|
}
|
||||||
console.log('addNodeDialog set to show')
|
console.log('addNodeDialog set to show')
|
||||||
|
|
||||||
closeContextMenu()
|
closeContextMenu(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查找节点在父节点中的索引位置
|
// 查找节点在父节点中的索引位置
|
||||||
|
|
@ -1293,6 +1520,49 @@ onMounted(() => {
|
||||||
initializeReviewHistory()
|
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({
|
defineExpose({
|
||||||
updateNodeContent
|
updateNodeContent
|
||||||
|
|
@ -1309,15 +1579,15 @@ defineExpose({
|
||||||
<span>Vehicle level - Default(modified)</span>
|
<span>Vehicle level - Default(modified)</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tree-header-actions">
|
<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" />
|
<Plus :size="12" />
|
||||||
</button>
|
</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" />
|
<Minus :size="12" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tree-content">
|
<div ref="treeRootRef" class="tree-content" role="tree" aria-label="Document tree">
|
||||||
<div class="tree-column-headers">
|
<div class="tree-column-headers">
|
||||||
<span class="tree-col-name">Name</span>
|
<span class="tree-col-name">Name</span>
|
||||||
<span class="tree-col-status">Status</span>
|
<span class="tree-col-status">Status</span>
|
||||||
|
|
@ -1325,13 +1595,21 @@ defineExpose({
|
||||||
</div>
|
</div>
|
||||||
<transition-group name="tree-row" tag="div" class="tree-nodes">
|
<transition-group name="tree-row" tag="div" class="tree-nodes">
|
||||||
<div
|
<div
|
||||||
v-for="{ node, level } in flattenedNodes"
|
v-for="({ node, level }, index) in flattenedNodes"
|
||||||
:key="node.id"
|
:key="node.id"
|
||||||
:class="['tree-node-wrapper', { 'has-children': hasChildren(node) }]"
|
:class="['tree-node-wrapper', { 'has-children': hasChildren(node) }]"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
:class="['tree-node', { selected: isSelected(node.id) }]"
|
: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)"
|
@click="handleSelect(node.id, node)"
|
||||||
|
@focus="handleNodeFocus(node.id, node)"
|
||||||
|
@keydown="handleTreeKeydown($event, node, level)"
|
||||||
@dblclick="handleDoubleClick(node.id, node)"
|
@dblclick="handleDoubleClick(node.id, node)"
|
||||||
@contextmenu="handleContextMenu($event, node.id)"
|
@contextmenu="handleContextMenu($event, node.id)"
|
||||||
>
|
>
|
||||||
|
|
@ -1339,11 +1617,18 @@ defineExpose({
|
||||||
class="tree-node-main"
|
class="tree-node-main"
|
||||||
:style="{ paddingLeft: `${level * 16 + 4}px` }"
|
: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" />
|
<Minus v-if="hasChildren(node) && isExpanded(node.id)" :size="12" />
|
||||||
<Plus v-else-if="hasChildren(node)" :size="12" />
|
<Plus v-else-if="hasChildren(node)" :size="12" />
|
||||||
|
</button>
|
||||||
<span v-else class="tree-node-spacer" />
|
<span v-else class="tree-node-spacer" />
|
||||||
</span>
|
|
||||||
<span class="tree-node-icon">
|
<span class="tree-node-icon">
|
||||||
<Link v-if="isMappedNode(node)" :size="14" class="icon-mapped" />
|
<Link v-if="isMappedNode(node)" :size="14" class="icon-mapped" />
|
||||||
<FileText v-else-if="isTerminalNode(node)" :size="14" class="icon-terminal" />
|
<FileText v-else-if="isTerminalNode(node)" :size="14" class="icon-terminal" />
|
||||||
|
|
@ -1384,44 +1669,48 @@ defineExpose({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 右键菜单 -->
|
<!-- 右键菜单 -->
|
||||||
<div v-if="contextMenu.show" class="context-menu" :style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
|
<div v-if="contextMenu.show" ref="contextMenuRef" class="context-menu" :style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
|
||||||
@click.stop>
|
role="menu" tabindex="-1" @keydown="handleMenuKeydown" @click.stop>
|
||||||
<div class="menu-group">
|
<div class="menu-group">
|
||||||
<div class="menu-item" @click="handleAddSibling">
|
<div class="menu-item" role="menuitem" tabindex="0" @click="handleAddSibling">
|
||||||
<CornerUpLeft :size="14" />
|
<CornerUpLeft :size="14" />
|
||||||
<span>添加同级节点</span>
|
<span>添加同级节点</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="menu-item" @click="handleAddChild">
|
<div class="menu-item" role="menuitem" tabindex="0" @click="handleAddChild">
|
||||||
<CornerDownLeft :size="14" />
|
<CornerDownLeft :size="14" />
|
||||||
<span>添加下级节点</span>
|
<span>添加下级节点</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="menu-item delete" @click="handleDelete">
|
<div class="menu-item delete" role="menuitem" tabindex="0" @click="handleDelete">
|
||||||
<Trash2 :size="14" />
|
<Trash2 :size="14" />
|
||||||
<span>edit</span>
|
<span>edit</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="menu-item delete" @click="handleDelete">
|
<div class="menu-item delete" role="menuitem" tabindex="0" @click="handleDelete">
|
||||||
<Trash2 :size="14" />
|
<Trash2 :size="14" />
|
||||||
<span>Delete</span>
|
<span>Delete</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="menu-divider"></div>
|
<div class="menu-divider"></div>
|
||||||
<div class="menu-group">
|
<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')">
|
@click="isCopyEnabled(contextMenuNode) && openOperationDialog('copy')">
|
||||||
<Copy :size="14" />
|
<Copy :size="14" />
|
||||||
<span>添加文档 (Copy From)...</span>
|
<span>添加文档 (Copy From)...</span>
|
||||||
</div>
|
</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')">
|
@click="isCloneEnabled(contextMenuNode) && openOperationDialog('clone')">
|
||||||
<Database :size="14" />
|
<Database :size="14" />
|
||||||
<span>添加文档 (Clone From)...</span>
|
<span>添加文档 (Clone From)...</span>
|
||||||
</div>
|
</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')">
|
@click="isBranchEnabled(contextMenuNode) && openOperationDialog('branch')">
|
||||||
<GitBranch :size="14" />
|
<GitBranch :size="14" />
|
||||||
<span>Branch From...</span>
|
<span>Branch From...</span>
|
||||||
</div>
|
</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()">
|
@click="isMapEnabled(contextMenuNode) && handleMapTo()">
|
||||||
<Plus :size="14" />
|
<Plus :size="14" />
|
||||||
<span>Map To...</span>
|
<span>Map To...</span>
|
||||||
|
|
@ -1431,10 +1720,10 @@ defineExpose({
|
||||||
|
|
||||||
<!-- 添加节点对话框 -->
|
<!-- 添加节点对话框 -->
|
||||||
<div v-if="addNodeDialog.show" class="dialog-overlay" @click="addNodeDialog.show = false">
|
<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">
|
<div class="dialog-header">
|
||||||
<h3>{{ addNodeDialog.mode === 'sibling' ? '添加同级节点' : '添加下级节点' }}</h3>
|
<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" />
|
<X :size="18" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1503,11 +1792,11 @@ defineExpose({
|
||||||
|
|
||||||
<!-- 操作选择弹窗 -->
|
<!-- 操作选择弹窗 -->
|
||||||
<div v-if="operationDialog.show" class="dialog-overlay" @click="operationDialog.show = false">
|
<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">
|
<div class="dialog-header">
|
||||||
<h3>{{ operationDialog.mode === 'copy' ? 'Copy Node' : operationDialog.mode === 'clone' ? 'Clone Node' :
|
<h3>{{ operationDialog.mode === 'copy' ? 'Copy Node' : operationDialog.mode === 'clone' ? 'Clone Node' :
|
||||||
'Branch Node' }}</h3>
|
'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" />
|
<X :size="18" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1566,6 +1855,7 @@ defineExpose({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sr-only" aria-live="polite" aria-atomic="true">{{ liveMessage }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -1710,6 +2000,13 @@ defineExpose({
|
||||||
user-select: none;
|
user-select: none;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
will-change: background-color, box-shadow, color;
|
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 {
|
.tree-node.selected {
|
||||||
|
|
@ -1750,12 +2047,21 @@ defineExpose({
|
||||||
margin-right: 2px;
|
margin-right: 2px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #666;
|
color: #666;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
transition:
|
transition:
|
||||||
background-color 160ms ease-out,
|
background-color 160ms ease-out,
|
||||||
color 160ms ease-out,
|
color 160ms ease-out,
|
||||||
transform 220ms 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 {
|
.tree-node:hover .tree-node-toggle {
|
||||||
color: #005a9e;
|
color: #005a9e;
|
||||||
transform: scale(1.18);
|
transform: scale(1.18);
|
||||||
|
|
@ -1992,6 +2298,12 @@ defineExpose({
|
||||||
transform: translateX(2px);
|
transform: translateX(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu-item:focus-visible {
|
||||||
|
outline: 2px solid #0078d4;
|
||||||
|
outline-offset: -2px;
|
||||||
|
background: #eef6ff;
|
||||||
|
}
|
||||||
|
|
||||||
.menu-item.disabled {
|
.menu-item.disabled {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
|
@ -2404,4 +2716,16 @@ defineExpose({
|
||||||
.node-owner-input::placeholder {
|
.node-owner-input::placeholder {
|
||||||
color: #999;
|
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>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue