Add right-click context menu with full Figma-style actions

- CanvasContextMenu: reka-ui ContextMenu with dark theme matching editor
- Clipboard: Copy, Cut, Paste here, Duplicate, Delete
- Z-order: Bring to front (]), Send to back ([)
- Move to page: submenu listing all other pages
- Grouping: Group (⌘G), Ungroup (⇧⌘G), Add auto layout (⇧A)
- Components: Create component (⌥⌘K), Create component set (⇧⌘K),
  Create instance, Go to main component, Detach instance (⌥⌘B)
  — component items styled in purple
- Visibility: Show/Hide (⇧⌘H), Lock/Unlock (⇧⌘L)
- Context-aware: items show/hide based on selection type and count
- Right-click selects node under cursor before showing menu
- Store: bringToFront, sendToBack, toggleVisibility, toggleLock,
  moveToPage, renameNode operations
- Keyboard: ], [, ⇧⌘H, ⇧⌘L shortcuts
This commit is contained in:
Danila Poyarkov 2026-02-28 17:58:55 +03:00
parent 3c5795f90b
commit ea946b04e6
5 changed files with 428 additions and 0 deletions

View file

@ -0,0 +1,323 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import {
ContextMenuRoot,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubTrigger,
ContextMenuSubContent,
ContextMenuPortal
} from 'reka-ui'
import { useEditorStore } from '../stores/editor'
const store = useEditorStore()
const pastePosition = ref<{ x: number; y: number } | null>(null)
const hasSelection = computed(() => {
void store.state.renderVersion
return store.state.selectedIds.size > 0
})
const singleNode = computed(() => {
void store.state.renderVersion
if (store.state.selectedIds.size !== 1) return null
const id = [...store.state.selectedIds][0]
return store.graph.getNode(id) ?? null
})
const multiCount = computed(() => {
void store.state.renderVersion
return store.state.selectedIds.size
})
const isInstance = computed(() => singleNode.value?.type === 'INSTANCE')
const isComponent = computed(() => singleNode.value?.type === 'COMPONENT')
const isGroup = computed(() => singleNode.value?.type === 'GROUP')
const canCreateComponentSet = computed(() => {
void store.state.renderVersion
if (store.state.selectedIds.size < 2) return false
return [...store.state.selectedIds].every((id) => {
const n = store.graph.getNode(id)
return n?.type === 'COMPONENT'
})
})
const pages = computed(() => {
void store.state.renderVersion
return store.graph.getPages()
})
const otherPages = computed(() =>
pages.value.filter((p) => p.id !== store.state.currentPageId)
)
const isVisible = computed(() => {
void store.state.renderVersion
if (!singleNode.value) return true
return singleNode.value.visible
})
const isLocked = computed(() => {
void store.state.renderVersion
if (!singleNode.value) return false
return singleNode.value.locked
})
function onContextMenu(e: MouseEvent) {
const canvas = (e.currentTarget as HTMLElement).querySelector('canvas')
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const sx = e.clientX - rect.left
const sy = e.clientY - rect.top
pastePosition.value = store.screenToCanvas(sx, sy)
}
function doCopy() {
document.execCommand('copy')
}
function doCut() {
document.execCommand('cut')
}
function doPaste() {
document.execCommand('paste')
}
</script>
<template>
<ContextMenuRoot>
<ContextMenuTrigger as-child @contextmenu="onContextMenu">
<slot />
</ContextMenuTrigger>
<ContextMenuPortal>
<ContextMenuContent
class="context-menu"
:side-offset="2"
align="start"
>
<!-- Clipboard -->
<ContextMenuItem class="context-item" :disabled="!hasSelection" @select="doCopy">
<span>Copy</span>
<span class="shortcut">⌘C</span>
</ContextMenuItem>
<ContextMenuItem class="context-item" :disabled="!hasSelection" @select="doCut">
<span>Cut</span>
<span class="shortcut">⌘X</span>
</ContextMenuItem>
<ContextMenuItem class="context-item" @select="doPaste">
<span>Paste here</span>
<span class="shortcut">⌘V</span>
</ContextMenuItem>
<ContextMenuItem class="context-item" :disabled="!hasSelection" @select="store.duplicateSelected()">
<span>Duplicate</span>
<span class="shortcut">⌘D</span>
</ContextMenuItem>
<ContextMenuItem class="context-item" :disabled="!hasSelection" @select="store.deleteSelected()">
<span>Delete</span>
<span class="shortcut">⌫</span>
</ContextMenuItem>
<ContextMenuSeparator class="separator" />
<!-- Z-order -->
<ContextMenuSub v-if="otherPages.length > 0">
<ContextMenuSubTrigger class="context-item" :disabled="!hasSelection">
<span>Move to page</span>
<span class="submenu-arrow">›</span>
</ContextMenuSubTrigger>
<ContextMenuPortal>
<ContextMenuSubContent class="context-menu">
<ContextMenuItem
v-for="page in otherPages"
:key="page.id"
class="context-item"
@select="store.moveToPage(page.id)"
>
{{ page.name }}
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuPortal>
</ContextMenuSub>
<ContextMenuItem class="context-item" :disabled="!hasSelection" @select="store.bringToFront()">
<span>Bring to front</span>
<span class="shortcut">]</span>
</ContextMenuItem>
<ContextMenuItem class="context-item" :disabled="!hasSelection" @select="store.sendToBack()">
<span>Send to back</span>
<span class="shortcut">[</span>
</ContextMenuItem>
<ContextMenuSeparator class="separator" />
<!-- Grouping -->
<ContextMenuItem
class="context-item"
:disabled="multiCount < 2"
@select="store.groupSelected()"
>
<span>Group</span>
<span class="shortcut">⌘G</span>
</ContextMenuItem>
<ContextMenuItem
v-if="isGroup"
class="context-item"
@select="store.ungroupSelected()"
>
<span>Ungroup</span>
<span class="shortcut">⇧⌘G</span>
</ContextMenuItem>
<ContextMenuItem
v-if="hasSelection && multiCount === 1"
class="context-item"
@select="store.wrapInAutoLayout()"
>
<span>Add auto layout</span>
<span class="shortcut">⇧A</span>
</ContextMenuItem>
<ContextMenuSeparator class="separator" />
<!-- Components -->
<ContextMenuItem
class="context-item component-item"
:disabled="!hasSelection"
@select="store.createComponentFromSelection()"
>
<span>Create component</span>
<span class="shortcut">⌥⌘K</span>
</ContextMenuItem>
<ContextMenuItem
v-if="canCreateComponentSet"
class="context-item component-item"
@select="store.createComponentSetFromComponents()"
>
<span>Create component set</span>
<span class="shortcut">⇧⌘K</span>
</ContextMenuItem>
<ContextMenuItem
v-if="isComponent"
class="context-item component-item"
@select="store.createInstanceFromComponent(singleNode!.id)"
>
<span>Create instance</span>
</ContextMenuItem>
<ContextMenuItem
v-if="isInstance"
class="context-item component-item"
@select="store.goToMainComponent()"
>
<span>Go to main component</span>
</ContextMenuItem>
<ContextMenuItem
v-if="isInstance"
class="context-item"
@select="store.detachInstance()"
>
<span>Detach instance</span>
<span class="shortcut">⌥⌘B</span>
</ContextMenuItem>
<ContextMenuSeparator v-if="hasSelection" class="separator" />
<!-- Visibility/Lock -->
<ContextMenuItem v-if="hasSelection" class="context-item" @select="store.toggleVisibility()">
<span>{{ isVisible ? 'Hide' : 'Show' }}</span>
<span class="shortcut">⇧⌘H</span>
</ContextMenuItem>
<ContextMenuItem v-if="hasSelection" class="context-item" @select="store.toggleLock()">
<span>{{ isLocked ? 'Unlock' : 'Lock' }}</span>
<span class="shortcut">⇧⌘L</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenuPortal>
</ContextMenuRoot>
</template>
<style scoped>
.context-menu {
min-width: 220px;
padding: 4px;
background: var(--color-panel);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow: 0 8px 30px rgb(0 0 0 / 0.4);
z-index: 100;
animation: contextFadeIn 0.12s ease-out;
}
@keyframes contextFadeIn {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
.context-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 6px 8px;
border-radius: 4px;
font-size: 12px;
color: var(--color-surface);
cursor: pointer;
outline: none;
user-select: none;
}
.context-item:hover,
.context-item[data-highlighted] {
background: var(--color-hover);
}
.context-item[data-disabled] {
color: var(--color-muted);
cursor: default;
pointer-events: none;
}
.context-item.component-item {
color: #9747ff;
}
.context-item.component-item:hover,
.context-item.component-item[data-highlighted] {
background: rgb(151 71 255 / 0.12);
}
.context-item.component-item[data-disabled] {
color: rgb(151 71 255 / 0.4);
}
.shortcut {
font-size: 11px;
color: var(--color-muted);
flex-shrink: 0;
}
.submenu-arrow {
font-size: 14px;
color: var(--color-muted);
flex-shrink: 0;
}
.separator {
height: 1px;
margin: 4px 0;
background: var(--color-border);
}
</style>

View file

@ -4,6 +4,7 @@ import { ref, computed } from 'vue'
import { useCanvas } from '../composables/use-canvas'
import { useCanvasInput } from '../composables/use-canvas-input'
import { useEditorStore } from '../stores/editor'
import CanvasContextMenu from './CanvasContextMenu.vue'
const store = useEditorStore()
const canvasRef = ref<HTMLCanvasElement | null>(null)
@ -66,6 +67,7 @@ function onTextKeyDown(e: KeyboardEvent) {
</script>
<template>
<CanvasContextMenu>
<div class="relative flex-1 min-w-0 min-h-0 overflow-hidden">
<canvas ref="canvasRef" :style="{ cursor }" class="block size-full" />
<textarea
@ -79,4 +81,5 @@ function onTextKeyDown(e: KeyboardEvent) {
autofocus
/>
</div>
</CanvasContextMenu>
</template>

View file

@ -927,6 +927,19 @@ export function useCanvasInput(
})
}
function onContextMenu(e: MouseEvent) {
const { cx, cy } = getCoords(e)
const hit = store.graph.hitTest(cx, cy, store.state.currentPageId)
if (hit) {
if (!store.state.selectedIds.has(hit.id)) {
store.select([hit.id])
}
} else {
store.clearSelection()
}
}
useEventListener(canvasRef, 'contextmenu', onContextMenu)
useEventListener(canvasRef, 'dblclick', onDblClick)
useEventListener(canvasRef, 'mousedown', onMouseDown)
useEventListener(canvasRef, 'mousemove', onMouseMove)

View file

@ -58,6 +58,16 @@ export function useKeyboard(store: EditorStore) {
store.createComponentSetFromComponents()
return
}
if (e.key === 'H' || e.key === 'h') {
e.preventDefault()
store.toggleVisibility()
return
}
if (e.key === 'L' || e.key === 'l') {
e.preventDefault()
store.toggleLock()
return
}
}
if (e.metaKey || e.ctrlKey) {
@ -105,6 +115,17 @@ export function useKeyboard(store: EditorStore) {
return
}
if (e.key === ']') {
e.preventDefault()
store.bringToFront()
return
}
if (e.key === '[') {
e.preventDefault()
store.sendToBack()
return
}
if (e.key === 'Backspace' || e.key === 'Delete') {
store.deleteSelected()
}

View file

@ -1097,6 +1097,68 @@ export function createEditorStore() {
requestRender()
}
function bringToFront() {
for (const id of state.selectedIds) {
const node = graph.getNode(id)
if (!node?.parentId) continue
const parent = graph.getNode(node.parentId)
if (!parent) continue
const idx = parent.childIds.indexOf(id)
if (idx === parent.childIds.length - 1) continue
parent.childIds = parent.childIds.filter((cid) => cid !== id)
parent.childIds.push(id)
}
requestRender()
}
function sendToBack() {
for (const id of state.selectedIds) {
const node = graph.getNode(id)
if (!node?.parentId) continue
const parent = graph.getNode(node.parentId)
if (!parent) continue
const idx = parent.childIds.indexOf(id)
if (idx === 0) continue
parent.childIds = parent.childIds.filter((cid) => cid !== id)
parent.childIds.unshift(id)
}
requestRender()
}
function toggleVisibility() {
for (const id of state.selectedIds) {
const node = graph.getNode(id)
if (!node) continue
graph.updateNode(id, { visible: !node.visible })
}
requestRender()
}
function toggleLock() {
for (const id of state.selectedIds) {
const node = graph.getNode(id)
if (!node) continue
graph.updateNode(id, { locked: !node.locked })
}
requestRender()
}
function moveToPage(pageId: string) {
const targetPage = graph.getNode(pageId)
if (!targetPage || targetPage.type !== 'CANVAS') return
const ids = [...state.selectedIds]
for (const id of ids) {
graph.reparentNode(id, pageId)
}
clearSelection()
requestRender()
}
function renameNode(id: string, name: string) {
graph.updateNode(id, { name })
requestRender()
}
function createShape(
type: NodeType,
x: number,
@ -1548,6 +1610,12 @@ export function createEditorStore() {
createInstanceFromComponent,
detachInstance,
goToMainComponent,
bringToFront,
sendToBack,
toggleVisibility,
toggleLock,
moveToPage,
renameNode,
createShape,
adoptNodesIntoSection,
duplicateSelected,