Compare commits

...

2 Commits

Author SHA1 Message Date
GitHub Action
751da03084 [automated] Apply ESLint and Oxfmt fixes 2026-03-12 15:14:47 +00:00
CodeRabbit Fixer
e6cfb868c0 fix: extract useCanvasMenuPositionSync composable to deduplicate context menu positioning logic (#9795)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 16:11:17 +01:00
2 changed files with 98 additions and 82 deletions

View File

@@ -39,11 +39,12 @@
</template>
<script setup lang="ts">
import { useElementBounding, useEventListener, useRafFn } from '@vueuse/core'
import { useEventListener } from '@vueuse/core'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useCanvasMenuPositionSync } from '@/composables/graph/useCanvasMenuPositionSync'
import {
registerNodeOptionsInstance,
useMoreOptionsMenu
@@ -52,7 +53,6 @@ import type {
MenuOption,
SubMenuOption
} from '@/composables/graph/useMoreOptionsMenu'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import ColorPickerMenu from './selectionToolbox/ColorPickerMenu.vue'
@@ -67,67 +67,11 @@ const colorPickerMenu = ref<InstanceType<typeof ColorPickerMenu>>()
const isOpen = ref(false)
const { menuOptions, bump } = useMoreOptionsMenu()
const canvasStore = useCanvasStore()
// World position (canvas coordinates) where menu was opened
const worldPosition = ref({ x: 0, y: 0 })
// Get canvas bounding rect reactively
const lgCanvas = canvasStore.getCanvas()
const { left: canvasLeft, top: canvasTop } = useElementBounding(lgCanvas.canvas)
// Track last canvas transform to detect actual changes
let lastScale = 0
let lastOffsetX = 0
let lastOffsetY = 0
// Update menu position based on canvas transform
const updateMenuPosition = () => {
if (!isOpen.value) return
const menuInstance = contextMenu.value as unknown as {
container?: HTMLElement
}
const menuEl = menuInstance?.container
if (!menuEl) return
const { scale, offset } = lgCanvas.ds
// Only update if canvas transform actually changed
if (
scale === lastScale &&
offset[0] === lastOffsetX &&
offset[1] === lastOffsetY
) {
return
}
lastScale = scale
lastOffsetX = offset[0]
lastOffsetY = offset[1]
// Convert world position to screen position
const screenX = (worldPosition.value.x + offset[0]) * scale + canvasLeft.value
const screenY = (worldPosition.value.y + offset[1]) * scale + canvasTop.value
// Update menu position
menuEl.style.left = `${screenX}px`
menuEl.style.top = `${screenY}px`
}
// Sync with canvas transform using requestAnimationFrame
const { resume: startSync, pause: stopSync } = useRafFn(updateMenuPosition, {
immediate: false
})
// Start/stop syncing based on menu visibility
watchEffect(() => {
if (isOpen.value) {
startSync()
} else {
stopSync()
}
})
const { setWorldPositionFromEvent } = useCanvasMenuPositionSync(
contextMenu,
isOpen
)
// Close on touch outside to handle mobile devices where click might be swallowed
useEventListener(
@@ -207,25 +151,7 @@ const menuItems = computed<ExtendedMenuItem[]>(() =>
// Show context menu
function show(event: MouseEvent) {
bump()
// Convert screen position to world coordinates
// Screen position relative to canvas = event position - canvas offset
const screenX = event.clientX - canvasLeft.value
const screenY = event.clientY - canvasTop.value
// Convert to world coordinates using canvas transform
const { scale, offset } = lgCanvas.ds
worldPosition.value = {
x: screenX / scale - offset[0],
y: screenY / scale - offset[1]
}
// Initialize last* values to current transform to prevent updateMenuPosition
// from overwriting PrimeVue's flip-adjusted position on the first RAF tick
lastScale = scale
lastOffsetX = offset[0]
lastOffsetY = offset[1]
setWorldPositionFromEvent(event)
isOpen.value = true
contextMenu.value?.show(event)
}

View File

@@ -0,0 +1,90 @@
import { useElementBounding, useRafFn } from '@vueuse/core'
import type ContextMenu from 'primevue/contextmenu'
import type { Ref } from 'vue'
import { ref, watchEffect } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
/**
* Keeps a PrimeVue ContextMenu anchored to a world-space position while the
* canvas is panned/zoomed. Performs dirty-checking each RAF tick so the DOM
* is only touched when the transform actually changes.
*/
export function useCanvasMenuPositionSync(
contextMenu: Ref<InstanceType<typeof ContextMenu> | undefined>,
isOpen: Ref<boolean>
) {
const canvasStore = useCanvasStore()
const lgCanvas = canvasStore.getCanvas()
const { left: canvasLeft, top: canvasTop } = useElementBounding(
lgCanvas.canvas
)
const worldPosition = ref({ x: 0, y: 0 })
let lastScale = 0
let lastOffsetX = 0
let lastOffsetY = 0
function updateMenuPosition() {
if (!isOpen.value) return
const menuInstance = contextMenu.value as unknown as {
container?: HTMLElement
}
const menuEl = menuInstance?.container
if (!menuEl) return
const { scale, offset } = lgCanvas.ds
if (
scale === lastScale &&
offset[0] === lastOffsetX &&
offset[1] === lastOffsetY
) {
return
}
lastScale = scale
lastOffsetX = offset[0]
lastOffsetY = offset[1]
const screenX =
(worldPosition.value.x + offset[0]) * scale + canvasLeft.value
const screenY =
(worldPosition.value.y + offset[1]) * scale + canvasTop.value
menuEl.style.left = `${screenX}px`
menuEl.style.top = `${screenY}px`
}
const { resume: startSync, pause: stopSync } = useRafFn(updateMenuPosition, {
immediate: false
})
watchEffect(() => {
if (isOpen.value) {
startSync()
} else {
stopSync()
}
})
/** Convert a mouse event's screen position to world coordinates and store it. */
function setWorldPositionFromEvent(event: MouseEvent) {
const screenX = event.clientX - canvasLeft.value
const screenY = event.clientY - canvasTop.value
const { scale, offset } = lgCanvas.ds
worldPosition.value = {
x: screenX / scale - offset[0],
y: screenY / scale - offset[1]
}
lastScale = scale
lastOffsetX = offset[0]
lastOffsetY = offset[1]
}
return { setWorldPositionFromEvent }
}