Implement the context menu

This commit is contained in:
Nettika 2026-01-29 01:49:48 -08:00
parent aafcade099
commit 8ca88e2542
No known key found for this signature in database
4 changed files with 231 additions and 8 deletions

12
TODO.md
View file

@ -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
---

View file

@ -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>

View 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>

View file

@ -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