Fix context menu: use @floating-ui/vue instead of reka-ui ContextMenu

reka-ui's ContextMenuTrigger calls event.preventDefault() after
await nextTick(), which is too late to suppress the native context
menu in Tauri's webview. Replace with a custom implementation using
@floating-ui/vue for positioning (already a transitive dependency)
and proper ARIA roles (role=menu/menuitem).

- e.preventDefault() called synchronously in our handler
- VirtualElement anchor at click coordinates
- flip/shift middleware for viewport-aware positioning
- onClickOutside + Escape to dismiss
- Submenu hover for 'Move to page'
- Remove redundant contextmenu handler from use-canvas-input
This commit is contained in:
Danila Poyarkov 2026-02-28 18:05:11 +03:00
parent ea946b04e6
commit 0498d661c0
2 changed files with 190 additions and 210 deletions

View file

@ -1,22 +1,27 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import {
ContextMenuRoot,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubTrigger,
ContextMenuSubContent,
ContextMenuPortal
} from 'reka-ui'
import { computed, ref, watch, nextTick } from 'vue'
import { onClickOutside, onKeyStroke } from '@vueuse/core'
import { useFloating, offset, flip, shift, type VirtualElement } from '@floating-ui/vue'
import { useEditorStore } from '../stores/editor'
const store = useEditorStore()
const pastePosition = ref<{ x: number; y: number } | null>(null)
const open = ref(false)
const menuRef = ref<HTMLElement | null>(null)
const submenuOpen = ref<string | null>(null)
const virtualAnchor = ref<VirtualElement>({
getBoundingClientRect: () => new DOMRect(0, 0, 0, 0)
})
const { floatingStyles } = useFloating(virtualAnchor, menuRef, {
placement: 'right-start',
middleware: [offset(2), flip(), shift({ padding: 8 })]
})
onClickOutside(menuRef, () => close(), { ignore: ['.context-submenu'] })
onKeyStroke('Escape', () => close())
const hasSelection = computed(() => {
void store.state.renderVersion
@ -48,15 +53,11 @@ const canCreateComponentSet = computed(() => {
})
})
const pages = computed(() => {
const otherPages = computed(() => {
void store.state.renderVersion
return store.graph.getPages()
return store.graph.getPages().filter((p) => p.id !== store.state.currentPageId)
})
const otherPages = computed(() =>
pages.value.filter((p) => p.id !== store.state.currentPageId)
)
const isVisible = computed(() => {
void store.state.renderVersion
if (!singleNode.value) return true
@ -69,181 +70,162 @@ const isLocked = computed(() => {
return singleNode.value.locked
})
function close() {
open.value = false
submenuOpen.value = null
}
function exec(fn: () => void) {
fn()
close()
}
function onContextMenu(e: MouseEvent) {
e.preventDefault()
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)
if (canvas) {
const rect = canvas.getBoundingClientRect()
const sx = e.clientX - rect.left
const sy = e.clientY - rect.top
const { x: cx, y: cy } = store.screenToCanvas(sx, sy)
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()
}
}
virtualAnchor.value = {
getBoundingClientRect: () => new DOMRect(e.clientX, e.clientY, 0, 0)
}
open.value = true
nextTick(() => menuRef.value?.focus())
}
function doCopy() {
document.execCommand('copy')
}
function doCut() {
document.execCommand('cut')
}
function doPaste() {
document.execCommand('paste')
}
watch(open, (val) => {
if (!val) submenuOpen.value = null
})
</script>
<template>
<ContextMenuRoot>
<ContextMenuTrigger as-child @contextmenu="onContextMenu">
<slot />
</ContextMenuTrigger>
<div @contextmenu="onContextMenu">
<slot />
</div>
<ContextMenuPortal>
<ContextMenuContent
class="context-menu"
:side-offset="2"
align="start"
<Teleport to="body">
<div
v-if="open"
ref="menuRef"
role="menu"
tabindex="-1"
class="context-menu"
:style="floatingStyles"
@keydown.escape="close"
>
<!-- Clipboard -->
<button role="menuitem" :disabled="!hasSelection" @click="exec(() => document.execCommand('copy'))">
<span>Copy</span><span class="shortcut">⌘C</span>
</button>
<button role="menuitem" :disabled="!hasSelection" @click="exec(() => document.execCommand('cut'))">
<span>Cut</span><span class="shortcut">⌘X</span>
</button>
<button role="menuitem" @click="exec(() => document.execCommand('paste'))">
<span>Paste here</span><span class="shortcut">⌘V</span>
</button>
<button role="menuitem" :disabled="!hasSelection" @click="exec(() => store.duplicateSelected())">
<span>Duplicate</span><span class="shortcut">⌘D</span>
</button>
<button role="menuitem" :disabled="!hasSelection" @click="exec(() => store.deleteSelected())">
<span>Delete</span><span class="shortcut">⌫</span>
</button>
<div class="separator" />
<!-- Move to page -->
<div
v-if="otherPages.length > 0 && hasSelection"
class="submenu-trigger"
@mouseenter="submenuOpen = 'move-page'"
@mouseleave="submenuOpen = null"
>
<!-- 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>
<button role="menuitem">
<span>Move to page</span><span class="shortcut">›</span>
</button>
<div v-if="submenuOpen === 'move-page'" role="menu" class="context-submenu">
<button
v-for="page in otherPages"
:key="page.id"
role="menuitem"
@click="exec(() => store.moveToPage(page.id))"
>
{{ page.name }}
</button>
</div>
</div>
<ContextMenuSeparator class="separator" />
<!-- Z-order -->
<button role="menuitem" :disabled="!hasSelection" @click="exec(() => store.bringToFront())">
<span>Bring to front</span><span class="shortcut">]</span>
</button>
<button role="menuitem" :disabled="!hasSelection" @click="exec(() => store.sendToBack())">
<span>Send to back</span><span class="shortcut">[</span>
</button>
<!-- 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>
<div class="separator" />
<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>
<!-- Grouping -->
<button role="menuitem" :disabled="multiCount < 2" @click="exec(() => store.groupSelected())">
<span>Group</span><span class="shortcut">⌘G</span>
</button>
<button v-if="isGroup" role="menuitem" @click="exec(() => store.ungroupSelected())">
<span>Ungroup</span><span class="shortcut">⇧⌘G</span>
</button>
<button v-if="hasSelection" role="menuitem" @click="exec(() => store.wrapInAutoLayout())">
<span>Add auto layout</span><span class="shortcut">⇧A</span>
</button>
<ContextMenuSeparator class="separator" />
<div 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>
<!-- Components -->
<button class="component-item" role="menuitem" :disabled="!hasSelection" @click="exec(() => store.createComponentFromSelection())">
<span>Create component</span><span class="shortcut">⌥⌘K</span>
</button>
<button v-if="canCreateComponentSet" class="component-item" role="menuitem" @click="exec(() => store.createComponentSetFromComponents())">
<span>Create component set</span><span class="shortcut">⇧⌘K</span>
</button>
<button v-if="isComponent" class="component-item" role="menuitem" @click="exec(() => store.createInstanceFromComponent(singleNode!.id))">
<span>Create instance</span>
</button>
<button v-if="isInstance" class="component-item" role="menuitem" @click="exec(() => store.goToMainComponent())">
<span>Go to main component</span>
</button>
<button v-if="isInstance" role="menuitem" @click="exec(() => store.detachInstance())">
<span>Detach instance</span><span class="shortcut">⌥⌘B</span>
</button>
<ContextMenuSeparator class="separator" />
<template v-if="hasSelection">
<div 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>
<button role="menuitem" @click="exec(() => store.toggleVisibility())">
<span>{{ isVisible ? 'Hide' : 'Show' }}</span><span class="shortcut">⇧⌘H</span>
</button>
<button role="menuitem" @click="exec(() => store.toggleLock())">
<span>{{ isLocked ? 'Unlock' : 'Lock' }}</span><span class="shortcut">⇧⌘L</span>
</button>
</template>
</div>
</Teleport>
</template>
<style scoped>
.context-menu {
position: fixed;
min-width: 220px;
padding: 4px;
background: var(--color-panel);
@ -251,55 +233,56 @@ function doPaste() {
border-radius: 8px;
box-shadow: 0 8px 30px rgb(0 0 0 / 0.4);
z-index: 100;
outline: none;
animation: contextFadeIn 0.12s ease-out;
}
@keyframes contextFadeIn {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
from { opacity: 0; transform: scale(0.96); }
to { opacity: 1; transform: scale(1); }
}
.context-item {
.context-menu > button,
.submenu-trigger > button,
.context-submenu > button {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
width: 100%;
padding: 6px 8px;
border: none;
border-radius: 4px;
font-size: 12px;
font-family: inherit;
color: var(--color-surface);
background: transparent;
cursor: pointer;
outline: none;
user-select: none;
text-align: left;
}
.context-item:hover,
.context-item[data-highlighted] {
.context-menu > button:hover:not(:disabled),
.submenu-trigger > button:hover,
.context-submenu > button:hover {
background: var(--color-hover);
}
.context-item[data-disabled] {
.context-menu > button:disabled {
color: var(--color-muted);
cursor: default;
pointer-events: none;
}
.context-item.component-item {
.context-menu > button.component-item {
color: #9747ff;
}
.context-item.component-item:hover,
.context-item.component-item[data-highlighted] {
.context-menu > button.component-item:hover:not(:disabled) {
background: rgb(151 71 255 / 0.12);
}
.context-item.component-item[data-disabled] {
.context-menu > button.component-item:disabled {
color: rgb(151 71 255 / 0.4);
}
@ -309,15 +292,25 @@ function doPaste() {
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);
}
.submenu-trigger {
position: relative;
}
.context-submenu {
position: absolute;
left: 100%;
top: -4px;
min-width: 160px;
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);
}
</style>

View file

@ -927,19 +927,6 @@ 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)