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:
109
frontend/src/components/ContextMenu.vue
Normal file
109
frontend/src/components/ContextMenu.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
34
frontend/src/composables/useKeyboardShortcuts.ts
Normal file
34
frontend/src/composables/useKeyboardShortcuts.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user