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:
parent
ea946b04e6
commit
0498d661c0
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue