Skip to content

Commit 20c6aa4

Browse files
MyesteryDrJKL
authored andcommitted
feat: add viewport-aware positioning to prevent menu overflow
When right-clicking near the bottom of the viewport, the context menu now docks to the bottom edge instead of floating off-screen, ensuring it remains accessible and scrollable.
1 parent 727ce2e commit 20c6aa4

File tree

3 files changed

+129
-14
lines changed

3 files changed

+129
-14
lines changed

src/components/graph/selectionToolbox/NodeOptions.vue

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121
/>
2222
<input
2323
ref="searchInput"
24-
autofocus="false"
2524
v-model="searchQuery"
25+
autofocus="false"
2626
type="text"
2727
:placeholder="t('contextMenu.Search')"
2828
class="w-full rounded-lg border border-smoke-200 bg-interface-panel-surface py-2 pl-9 pr-3 text-sm text-text-primary placeholder-text-secondary focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark-theme:border-zinc-700"
@@ -74,6 +74,7 @@ import type {
7474
SubMenuOption
7575
} from '@/composables/graph/useMoreOptionsMenu'
7676
import { useSubmenuPositioning } from '@/composables/graph/useSubmenuPositioning'
77+
import { calculateMenuPosition } from '@/composables/graph/useViewportAwareMenuPositioning'
7778
7879
// import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
7980
@@ -179,19 +180,31 @@ const repositionPopover = () => {
179180
const btn = targetElement.value
180181
const overlayEl = resolveOverlayEl()
181182
if (!btn || !overlayEl) return
183+
182184
const rect = btn.getBoundingClientRect()
183-
const marginY = 8 // tailwind mt-2 ~ 0.5rem = 8px
184-
const left = isTriggeredByToolbox.value
185-
? rect.left + rect.width / 2
186-
: rect.right - rect.width / 4
187-
const top = isTriggeredByToolbox.value
188-
? rect.bottom + marginY
189-
: rect.top - marginY - 6
185+
190186
try {
191-
overlayEl.style.position = 'fixed'
192-
overlayEl.style.left = `${left}px`
193-
overlayEl.style.top = `${top}px`
194-
overlayEl.style.transform = 'translate(-50%, 0)'
187+
// Calculate viewport-aware position
188+
const style = calculateMenuPosition({
189+
triggerRect: rect,
190+
menuElement: overlayEl,
191+
isTriggeredByToolbox: isTriggeredByToolbox.value,
192+
marginY: 8
193+
})
194+
195+
// Apply positioning styles
196+
overlayEl.style.position = style.position
197+
overlayEl.style.left = style.left
198+
overlayEl.style.transform = style.transform
199+
200+
// Handle top vs bottom positioning
201+
if (style.top !== undefined) {
202+
overlayEl.style.top = style.top
203+
overlayEl.style.bottom = '' // Clear bottom if using top
204+
} else if (style.bottom !== undefined) {
205+
overlayEl.style.bottom = style.bottom
206+
overlayEl.style.top = '' // Clear top if using bottom
207+
}
195208
} catch (e) {
196209
console.warn('[NodeOptions] Failed to set overlay style', e)
197210
return

src/components/graph/selectionToolbox/SubmenuPopover.vue

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252

5353
<script setup lang="ts">
5454
import Popover from 'primevue/popover'
55-
import { computed, ref } from 'vue'
55+
import { computed, nextTick, ref } from 'vue'
5656
5757
import type {
5858
MenuOption,
@@ -76,14 +76,41 @@ const { getCurrentShape } = useNodeCustomization()
7676
7777
const popover = ref<InstanceType<typeof Popover>>()
7878
79-
const show = (event: Event, target?: HTMLElement) => {
79+
const show = async (event: Event, target?: HTMLElement) => {
8080
popover.value?.show(event, target)
81+
82+
// Wait for next tick to ensure the popover is rendered
83+
await nextTick()
84+
85+
// Apply viewport-aware positioning after popover is shown
86+
repositionSubmenu()
8187
}
8288
8389
const hide = () => {
8490
popover.value?.hide()
8591
}
8692
93+
const repositionSubmenu = () => {
94+
const overlayEl = (popover.value as any)?.$el as HTMLElement
95+
if (!overlayEl) return
96+
97+
// Get current position and dimensions
98+
const rect = overlayEl.getBoundingClientRect()
99+
const menuHeight = overlayEl.offsetHeight || overlayEl.scrollHeight
100+
const viewportHeight = window.innerHeight
101+
102+
// Check if menu would overflow viewport bottom
103+
const menuBottom = rect.top + menuHeight
104+
const wouldOverflow = menuBottom > viewportHeight
105+
106+
if (wouldOverflow) {
107+
// Dock to bottom of viewport while keeping horizontal position
108+
overlayEl.style.position = 'fixed'
109+
overlayEl.style.bottom = '0px'
110+
overlayEl.style.top = ''
111+
}
112+
}
113+
87114
defineExpose({
88115
show,
89116
hide
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
export interface MenuPositionStyle {
2+
position: 'fixed'
3+
left: string
4+
top?: string
5+
bottom?: string
6+
transform: string
7+
}
8+
9+
export interface MenuPositionOptions {
10+
/** The trigger element that opened the menu */
11+
triggerRect: DOMRect
12+
/** The menu overlay element */
13+
menuElement: HTMLElement
14+
/** Whether the menu was triggered by the toolbox button */
15+
isTriggeredByToolbox: boolean
16+
/** Margin from trigger element */
17+
marginY?: number
18+
}
19+
20+
/**
21+
* Calculates viewport-aware menu positioning that prevents overflow.
22+
* When a menu would overflow the bottom of the viewport, it docks to the bottom instead.
23+
*
24+
* @returns Positioning style properties to apply to the menu element
25+
*/
26+
export function calculateMenuPosition(
27+
options: MenuPositionOptions
28+
): MenuPositionStyle {
29+
const {
30+
triggerRect,
31+
menuElement,
32+
isTriggeredByToolbox,
33+
marginY = 8
34+
} = options
35+
36+
// Calculate horizontal position (same as before)
37+
const left = isTriggeredByToolbox
38+
? triggerRect.left + triggerRect.width / 2
39+
: triggerRect.right - triggerRect.width / 4
40+
41+
// Calculate initial top position
42+
const initialTop = isTriggeredByToolbox
43+
? triggerRect.bottom + marginY
44+
: triggerRect.top - marginY - 6
45+
46+
// Get menu dimensions
47+
const menuHeight = menuElement.offsetHeight || menuElement.scrollHeight
48+
const viewportHeight = window.innerHeight
49+
50+
// Calculate available space below the trigger point
51+
const spaceBelow = viewportHeight - initialTop
52+
53+
// Check if menu would overflow viewport bottom
54+
const wouldOverflow = menuHeight > spaceBelow
55+
56+
const baseStyle: MenuPositionStyle = {
57+
position: 'fixed',
58+
left: `${left}px`,
59+
transform: 'translate(-50%, 0)'
60+
}
61+
62+
if (wouldOverflow) {
63+
// Dock to bottom of viewport
64+
return {
65+
...baseStyle,
66+
bottom: '0px'
67+
}
68+
} else {
69+
// Position below trigger as normal
70+
return {
71+
...baseStyle,
72+
top: `${initialTop}px`
73+
}
74+
}
75+
}

0 commit comments

Comments
 (0)