feat: add keyboard shortcuts and context menu (Phase 6)

Phase 6 - Enhancements:
- Add useKeyboardShortcuts composable for global shortcuts
- Implement keyboard shortcuts: F2 (rename), Delete, Ctrl+N (new folder)
- Add ContextMenu component with positioning logic
- Implement right-click context menu for tree nodes
- Add rename dialog for nodes
- Support delete with confirmation
- Add "new subfolder" action for folders

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
liumangmang
2026-04-03 15:55:39 +08:00
parent caed481d23
commit e23ba1c3c9
4 changed files with 311 additions and 1 deletions

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
export interface ContextMenuItem {
label: string
icon?: any
action: () => void
divider?: boolean
disabled?: boolean
}
interface Props {
items: ContextMenuItem[]
x: number
y: number
show: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
close: []
}>()
const menuRef = ref<HTMLElement>()
const menuStyle = computed(() => {
if (!menuRef.value) {
return { left: `${props.x}px`, top: `${props.y}px` }
}
const rect = menuRef.value.getBoundingClientRect()
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
let left = props.x
let top = props.y
// Adjust if menu goes off right edge
if (left + rect.width > viewportWidth) {
left = viewportWidth - rect.width - 8
}
// Adjust if menu goes off bottom edge
if (top + rect.height > viewportHeight) {
top = viewportHeight - rect.height - 8
}
return { left: `${left}px`, top: `${top}px` }
})
function handleClick(item: ContextMenuItem) {
if (!item.disabled) {
item.action()
emit('close')
}
}
function handleClickOutside(event: MouseEvent) {
if (menuRef.value && !menuRef.value.contains(event.target as Node)) {
emit('close')
}
}
onMounted(() => {
if (props.show) {
setTimeout(() => {
document.addEventListener('click', handleClickOutside)
}, 0)
}
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
<template>
<Teleport to="body">
<div
v-if="show"
ref="menuRef"
class="fixed z-50 min-w-[180px] bg-slate-800 border border-slate-700 rounded-lg shadow-xl py-1"
:style="menuStyle"
>
<template v-for="(item, index) in items" :key="index">
<div
v-if="item.divider"
class="h-px bg-slate-700 my-1"
/>
<button
v-else
class="w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors"
:class="{
'text-slate-300 hover:bg-slate-700': !item.disabled,
'text-slate-500 cursor-not-allowed': item.disabled,
}"
@click="handleClick(item)"
>
<component
v-if="item.icon"
:is="item.icon"
class="w-4 h-4 flex-shrink-0"
/>
<span>{{ item.label }}</span>
</button>
</template>
</div>
</Teleport>
</template>

View File

@@ -3,21 +3,94 @@ import { ref, computed, provide } from 'vue'
import { useSessionTreeStore } from '../stores/sessionTree'
import { useWorkspaceStore } from '../stores/workspace'
import { useTreeDragDrop } from '../composables/useTreeDragDrop'
import { useKeyboardShortcuts } from '../composables/useKeyboardShortcuts'
import SessionTreeNode from './SessionTreeNode.vue'
import { FolderPlus } from 'lucide-vue-next'
import ContextMenu from './ContextMenu.vue'
import type { ContextMenuItem } from './ContextMenu.vue'
import { FolderPlus, Edit2, Trash2 } from 'lucide-vue-next'
const treeStore = useSessionTreeStore()
const workspaceStore = useWorkspaceStore()
const showNewFolderDialog = ref(false)
const newFolderName = ref('')
const showRenameDialog = ref(false)
const renameNodeId = ref<string | null>(null)
const renameValue = ref('')
// Context menu
const showContextMenu = ref(false)
const contextMenuX = ref(0)
const contextMenuY = ref(0)
const contextMenuNodeId = ref<string | null>(null)
const rootNodes = computed(() => treeStore.rootNodes)
const contextMenuItems = computed<ContextMenuItem[]>(() => {
if (!contextMenuNodeId.value) return []
const node = treeStore.nodes.find(n => n.id === contextMenuNodeId.value)
if (!node) return []
const items: ContextMenuItem[] = [
{
label: '重命名',
icon: Edit2,
action: () => startRename(node.id),
},
]
if (node.type === 'folder') {
items.push({
label: '新建子文件夹',
icon: FolderPlus,
action: () => createSubFolder(node.id),
})
}
items.push(
{ divider: true } as ContextMenuItem,
{
label: '删除',
icon: Trash2,
action: () => deleteNode(node.id),
}
)
return items
})
// Provide drag-drop handlers to child components
const dragHandlers = useTreeDragDrop()
provide('dragHandlers', dragHandlers)
// Keyboard shortcuts
useKeyboardShortcuts([
{
key: 'F2',
handler: () => {
if (treeStore.selectedNodeId) {
startRename(treeStore.selectedNodeId)
}
},
},
{
key: 'Delete',
handler: () => {
if (treeStore.selectedNodeId) {
deleteNode(treeStore.selectedNodeId)
}
},
},
{
key: 'n',
ctrl: true,
handler: () => {
showNewFolderDialog.value = true
},
},
])
function handleNodeClick(nodeId: string) {
const node = treeStore.nodes.find(n => n.id === nodeId)
if (!node) return
@@ -31,12 +104,56 @@ function handleNodeClick(nodeId: string) {
treeStore.selectNode(nodeId)
}
function handleNodeContextMenu(nodeId: string, event: MouseEvent) {
event.preventDefault()
contextMenuNodeId.value = nodeId
contextMenuX.value = event.clientX
contextMenuY.value = event.clientY
showContextMenu.value = true
treeStore.selectNode(nodeId)
}
function createFolder() {
if (!newFolderName.value.trim()) return
treeStore.createFolder(newFolderName.value.trim())
newFolderName.value = ''
showNewFolderDialog.value = false
}
function createSubFolder(_parentId: string) {
showNewFolderDialog.value = true
// TODO: Set parent context for new folder
}
function startRename(nodeId: string) {
const node = treeStore.nodes.find(n => n.id === nodeId)
if (!node) return
renameNodeId.value = nodeId
renameValue.value = node.name
showRenameDialog.value = true
}
function confirmRename() {
if (!renameNodeId.value || !renameValue.value.trim()) return
treeStore.renameNode(renameNodeId.value, renameValue.value.trim())
showRenameDialog.value = false
renameNodeId.value = null
renameValue.value = ''
}
function deleteNode(nodeId: string) {
const node = treeStore.nodes.find(n => n.id === nodeId)
if (!node) return
const confirmMsg = node.type === 'folder'
? `确定要删除文件夹 "${node.name}" 及其所有内容吗?`
: `确定要删除连接 "${node.name}" 吗?`
if (confirm(confirmMsg)) {
treeStore.deleteNode(nodeId)
}
}
</script>
<template>
@@ -59,9 +176,11 @@ function createFolder() {
:node="node"
:level="0"
@click="handleNodeClick"
@contextmenu="handleNodeContextMenu"
/>
</div>
<!-- New Folder Dialog -->
<div
v-if="showNewFolderDialog"
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
@@ -92,5 +211,46 @@ function createFolder() {
</div>
</div>
</div>
<!-- Rename Dialog -->
<div
v-if="showRenameDialog"
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
@click.self="showRenameDialog = false"
>
<div class="bg-slate-800 rounded-lg p-4 w-80">
<h3 class="text-sm font-medium text-slate-100 mb-3">重命名</h3>
<input
v-model="renameValue"
type="text"
placeholder="新名称"
class="w-full px-3 py-2 rounded bg-slate-900 border border-slate-600 text-slate-100 text-sm focus:border-cyan-500 focus:outline-none"
@keyup.enter="confirmRename"
/>
<div class="flex gap-2 mt-4">
<button
@click="confirmRename"
class="flex-1 px-3 py-2 rounded bg-cyan-600 hover:bg-cyan-500 text-white text-sm"
>
确定
</button>
<button
@click="showRenameDialog = false"
class="flex-1 px-3 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-200 text-sm"
>
取消
</button>
</div>
</div>
</div>
<!-- Context Menu -->
<ContextMenu
:show="showContextMenu"
:x="contextMenuX"
:y="contextMenuY"
:items="contextMenuItems"
@close="showContextMenu = false"
/>
</div>
</template>

View File

@@ -13,6 +13,7 @@ interface Props {
const props = defineProps<Props>()
const emit = defineEmits<{
click: [nodeId: string]
contextmenu: [nodeId: string, event: MouseEvent]
}>()
const treeStore = useSessionTreeStore()
@@ -48,6 +49,10 @@ function handleClick() {
emit('click', props.node.id)
}
function handleContextMenu(event: MouseEvent) {
emit('contextmenu', props.node.id, event)
}
function handleDragStart(event: DragEvent) {
if (dragHandlers) {
event.dataTransfer!.effectAllowed = 'move'
@@ -93,6 +98,7 @@ function handleDragEnd() {
}"
:style="{ paddingLeft: `${level * 16 + 8}px` }"
@click="handleClick"
@contextmenu="handleContextMenu"
@dragstart="handleDragStart"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@@ -145,6 +151,7 @@ function handleDragEnd() {
:node="child"
:level="level + 1"
@click="emit('click', $event)"
@contextmenu="(nodeId, event) => emit('contextmenu', nodeId, event)"
/>
</template>
</div>

View File

@@ -0,0 +1,34 @@
import { onMounted, onUnmounted } from 'vue'
export interface KeyboardShortcut {
key: string
ctrl?: boolean
shift?: boolean
alt?: boolean
handler: (event: KeyboardEvent) => void
}
export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
function handleKeyDown(event: KeyboardEvent) {
for (const shortcut of shortcuts) {
const ctrlMatch = shortcut.ctrl === undefined || shortcut.ctrl === (event.ctrlKey || event.metaKey)
const shiftMatch = shortcut.shift === undefined || shortcut.shift === event.shiftKey
const altMatch = shortcut.alt === undefined || shortcut.alt === event.altKey
const keyMatch = event.key.toLowerCase() === shortcut.key.toLowerCase()
if (ctrlMatch && shiftMatch && altMatch && keyMatch) {
event.preventDefault()
shortcut.handler(event)
break
}
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeyDown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown)
})
}