Implement the context menu
This commit is contained in:
parent
aafcade099
commit
8ca88e2542
4 changed files with 231 additions and 8 deletions
12
TODO.md
12
TODO.md
|
|
@ -100,12 +100,12 @@ A step-by-step checklist for porting MatterControl's design features to a Vue +
|
|||
- [x] Distinguish single vs multi-selection visually
|
||||
|
||||
### Context Menu
|
||||
- [ ] Create right-click context menu component
|
||||
- [ ] Show context menu on object right-click
|
||||
- [ ] Add "Delete" option to context menu
|
||||
- [ ] Add "Duplicate" option to context menu
|
||||
- [ ] Add "Group" option to context menu
|
||||
- [ ] Close menu on click outside
|
||||
- [x] Create right-click context menu component
|
||||
- [x] Show context menu on object right-click
|
||||
- [x] Add "Delete" option to context menu
|
||||
- [x] Add "Duplicate" option to context menu
|
||||
- [x] Add "Group" option to context menu
|
||||
- [x] Close menu on click outside
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
107
src/App.vue
107
src/App.vue
|
|
@ -1,12 +1,37 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import * as THREE from 'three'
|
||||
import AppLayout from './components/AppLayout.vue'
|
||||
import Viewport from './components/Viewport.vue'
|
||||
import ContextMenu, { type MenuItem } from './components/ContextMenu.vue'
|
||||
import { useSceneStore, type SceneObject } from './stores/scene'
|
||||
|
||||
const sceneStore = useSceneStore()
|
||||
|
||||
const viewportRef = ref<InstanceType<typeof Viewport> | null>(null)
|
||||
const contextMenu = ref<{ x: number; y: number } | null>(null)
|
||||
|
||||
const contextMenuItems = computed<MenuItem[]>(() => {
|
||||
const count = sceneStore.selectedObjects.length
|
||||
if (count === 0) return []
|
||||
|
||||
const items: MenuItem[] = []
|
||||
|
||||
if (count === 1) {
|
||||
items.push({ label: 'Delete', action: 'delete' })
|
||||
items.push({ label: 'Duplicate', action: 'duplicate' })
|
||||
} else {
|
||||
items.push({ label: `Delete ${count} Objects`, action: 'delete' })
|
||||
items.push({ label: `Duplicate ${count} Objects`, action: 'duplicate' })
|
||||
items.push({ label: '', action: '', separator: true })
|
||||
items.push({ label: 'Group', action: 'group' })
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
function handlePick(object: SceneObject | null, event: MouseEvent) {
|
||||
contextMenu.value = null
|
||||
if (object) {
|
||||
if (event.shiftKey) {
|
||||
sceneStore.addToSelection(object.id)
|
||||
|
|
@ -20,11 +45,81 @@ function handlePick(object: SceneObject | null, event: MouseEvent) {
|
|||
}
|
||||
}
|
||||
|
||||
function handleContextMenu(object: SceneObject | null, event: MouseEvent) {
|
||||
// Background click - no menu
|
||||
if (!object) {
|
||||
contextMenu.value = null
|
||||
return
|
||||
}
|
||||
|
||||
// If clicking unselected object, select it (replacing current selection)
|
||||
if (!sceneStore.selectedIds.has(object.id)) {
|
||||
sceneStore.select(object.id)
|
||||
}
|
||||
// If clicking selected object, preserve current selection
|
||||
|
||||
contextMenu.value = { x: event.clientX, y: event.clientY }
|
||||
viewportRef.value?.setControlsEnabled(false)
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
contextMenu.value = null
|
||||
viewportRef.value?.setControlsEnabled(true)
|
||||
}
|
||||
|
||||
function handleMenuSelect(action: string) {
|
||||
if (action === 'delete') {
|
||||
deleteSelected()
|
||||
} else if (action === 'duplicate') {
|
||||
duplicateSelected()
|
||||
}
|
||||
closeContextMenu()
|
||||
}
|
||||
|
||||
function handleMenuClose() {
|
||||
closeContextMenu()
|
||||
}
|
||||
|
||||
function deleteSelected() {
|
||||
const ids = [...sceneStore.selectedIds]
|
||||
sceneStore.clearSelection()
|
||||
for (const id of ids) {
|
||||
sceneStore.removeObject(id)
|
||||
}
|
||||
}
|
||||
|
||||
function duplicateSelected() {
|
||||
const toDuplicate = [...sceneStore.selectedObjects]
|
||||
sceneStore.clearSelection()
|
||||
for (const obj of toDuplicate) {
|
||||
const geometry = obj.mesh.geometry.clone()
|
||||
const material = (obj.mesh.material as THREE.Material).clone()
|
||||
const newMesh = new THREE.Mesh(geometry, material)
|
||||
newMesh.position.copy(obj.mesh.position)
|
||||
newMesh.position.x += 1
|
||||
newMesh.rotation.copy(obj.mesh.rotation)
|
||||
newMesh.scale.copy(obj.mesh.scale)
|
||||
const newObj = sceneStore.addObject(newMesh, obj.name)
|
||||
sceneStore.addToSelection(newObj.id)
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'a') {
|
||||
event.preventDefault()
|
||||
sceneStore.selectAll()
|
||||
}
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
if (sceneStore.selectedObjects.length > 0) {
|
||||
deleteSelected()
|
||||
}
|
||||
}
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'd') {
|
||||
event.preventDefault()
|
||||
if (sceneStore.selectedObjects.length > 0) {
|
||||
duplicateSelected()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
|
@ -38,7 +133,15 @@ onUnmounted(() => {
|
|||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<Viewport @pick="handlePick" />
|
||||
<Viewport ref="viewportRef" @pick="handlePick" @contextmenu="handleContextMenu" />
|
||||
<ContextMenu
|
||||
v-if="contextMenu && contextMenuItems.length > 0"
|
||||
:x="contextMenu.x"
|
||||
:y="contextMenu.y"
|
||||
:items="contextMenuItems"
|
||||
@select="handleMenuSelect"
|
||||
@close="handleMenuClose"
|
||||
/>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
111
src/components/ContextMenu.vue
Normal file
111
src/components/ContextMenu.vue
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
export interface MenuItem {
|
||||
label: string
|
||||
action: string
|
||||
disabled?: boolean
|
||||
separator?: boolean
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
x: number
|
||||
y: number
|
||||
items: MenuItem[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [action: string]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const menuRef = ref<HTMLElement | null>(null)
|
||||
|
||||
function handleClick(item: MenuItem) {
|
||||
if (item.disabled) return
|
||||
emit('select', item.action)
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (menuRef.value && !menuRef.value.contains(event.target as Node)) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="menuRef"
|
||||
class="context-menu"
|
||||
:style="{ left: `${x}px`, top: `${y}px` }"
|
||||
>
|
||||
<template v-for="(item, index) in items" :key="index">
|
||||
<div v-if="item.separator" class="separator" />
|
||||
<button
|
||||
v-else
|
||||
class="menu-item"
|
||||
:class="{ disabled: item.disabled }"
|
||||
:disabled="item.disabled"
|
||||
@click="handleClick(item)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
min-width: 150px;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
padding: var(--space-xs) 0;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menu-item:hover:not(.disabled) {
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.menu-item.disabled {
|
||||
color: var(--color-text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.separator {
|
||||
height: 1px;
|
||||
background: var(--color-border);
|
||||
margin: var(--space-xs) 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -29,6 +29,7 @@ const props = withDefaults(
|
|||
|
||||
const emit = defineEmits<{
|
||||
pick: [object: SceneObject | null, event: MouseEvent]
|
||||
contextmenu: [object: SceneObject | null, event: MouseEvent]
|
||||
}>()
|
||||
|
||||
const sceneStore = useSceneStore()
|
||||
|
|
@ -205,6 +206,7 @@ function setupMouseHandlers() {
|
|||
|
||||
renderer.domElement.addEventListener('click', onCanvasClick)
|
||||
renderer.domElement.addEventListener('mousemove', onCanvasMouseMove)
|
||||
renderer.domElement.addEventListener('contextmenu', onCanvasContextMenu)
|
||||
}
|
||||
|
||||
function cleanupMouseHandlers() {
|
||||
|
|
@ -212,6 +214,7 @@ function cleanupMouseHandlers() {
|
|||
|
||||
renderer.domElement.removeEventListener('click', onCanvasClick)
|
||||
renderer.domElement.removeEventListener('mousemove', onCanvasMouseMove)
|
||||
renderer.domElement.removeEventListener('contextmenu', onCanvasContextMenu)
|
||||
}
|
||||
|
||||
function updateMouseCoordinates(event: MouseEvent) {
|
||||
|
|
@ -258,6 +261,12 @@ function onCanvasMouseMove(event: MouseEvent) {
|
|||
}
|
||||
}
|
||||
|
||||
function onCanvasContextMenu(event: MouseEvent) {
|
||||
event.preventDefault()
|
||||
const pickedObject = pickObject(event)
|
||||
emit('contextmenu', pickedObject, event)
|
||||
}
|
||||
|
||||
function setupResizeObserver() {
|
||||
if (!containerRef.value) return
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue