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 { useSessionTreeStore } from '../stores/sessionTree'
|
||||||
import { useWorkspaceStore } from '../stores/workspace'
|
import { useWorkspaceStore } from '../stores/workspace'
|
||||||
import { useTreeDragDrop } from '../composables/useTreeDragDrop'
|
import { useTreeDragDrop } from '../composables/useTreeDragDrop'
|
||||||
|
import { useKeyboardShortcuts } from '../composables/useKeyboardShortcuts'
|
||||||
import SessionTreeNode from './SessionTreeNode.vue'
|
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 treeStore = useSessionTreeStore()
|
||||||
const workspaceStore = useWorkspaceStore()
|
const workspaceStore = useWorkspaceStore()
|
||||||
|
|
||||||
const showNewFolderDialog = ref(false)
|
const showNewFolderDialog = ref(false)
|
||||||
const newFolderName = ref('')
|
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 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
|
// Provide drag-drop handlers to child components
|
||||||
const dragHandlers = useTreeDragDrop()
|
const dragHandlers = useTreeDragDrop()
|
||||||
provide('dragHandlers', dragHandlers)
|
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) {
|
function handleNodeClick(nodeId: string) {
|
||||||
const node = treeStore.nodes.find(n => n.id === nodeId)
|
const node = treeStore.nodes.find(n => n.id === nodeId)
|
||||||
if (!node) return
|
if (!node) return
|
||||||
@@ -31,12 +104,56 @@ function handleNodeClick(nodeId: string) {
|
|||||||
treeStore.selectNode(nodeId)
|
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() {
|
function createFolder() {
|
||||||
if (!newFolderName.value.trim()) return
|
if (!newFolderName.value.trim()) return
|
||||||
treeStore.createFolder(newFolderName.value.trim())
|
treeStore.createFolder(newFolderName.value.trim())
|
||||||
newFolderName.value = ''
|
newFolderName.value = ''
|
||||||
showNewFolderDialog.value = false
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -59,9 +176,11 @@ function createFolder() {
|
|||||||
:node="node"
|
:node="node"
|
||||||
:level="0"
|
:level="0"
|
||||||
@click="handleNodeClick"
|
@click="handleNodeClick"
|
||||||
|
@contextmenu="handleNodeContextMenu"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- New Folder Dialog -->
|
||||||
<div
|
<div
|
||||||
v-if="showNewFolderDialog"
|
v-if="showNewFolderDialog"
|
||||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
@@ -92,5 +211,46 @@ function createFolder() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface Props {
|
|||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
click: [nodeId: string]
|
click: [nodeId: string]
|
||||||
|
contextmenu: [nodeId: string, event: MouseEvent]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const treeStore = useSessionTreeStore()
|
const treeStore = useSessionTreeStore()
|
||||||
@@ -48,6 +49,10 @@ function handleClick() {
|
|||||||
emit('click', props.node.id)
|
emit('click', props.node.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleContextMenu(event: MouseEvent) {
|
||||||
|
emit('contextmenu', props.node.id, event)
|
||||||
|
}
|
||||||
|
|
||||||
function handleDragStart(event: DragEvent) {
|
function handleDragStart(event: DragEvent) {
|
||||||
if (dragHandlers) {
|
if (dragHandlers) {
|
||||||
event.dataTransfer!.effectAllowed = 'move'
|
event.dataTransfer!.effectAllowed = 'move'
|
||||||
@@ -93,6 +98,7 @@ function handleDragEnd() {
|
|||||||
}"
|
}"
|
||||||
:style="{ paddingLeft: `${level * 16 + 8}px` }"
|
:style="{ paddingLeft: `${level * 16 + 8}px` }"
|
||||||
@click="handleClick"
|
@click="handleClick"
|
||||||
|
@contextmenu="handleContextMenu"
|
||||||
@dragstart="handleDragStart"
|
@dragstart="handleDragStart"
|
||||||
@dragover="handleDragOver"
|
@dragover="handleDragOver"
|
||||||
@dragleave="handleDragLeave"
|
@dragleave="handleDragLeave"
|
||||||
@@ -145,6 +151,7 @@ function handleDragEnd() {
|
|||||||
:node="child"
|
:node="child"
|
||||||
:level="level + 1"
|
:level="level + 1"
|
||||||
@click="emit('click', $event)"
|
@click="emit('click', $event)"
|
||||||
|
@contextmenu="(nodeId, event) => emit('contextmenu', nodeId, event)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</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