Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
1809eff
right click vue nodes with contextmenu WIP
Myestery Oct 29, 2025
521b672
[feat] Add disabled state support for context menu items
Myestery Oct 31, 2025
b31e8d0
feat: add Extensions translation key to context menu
Myestery Nov 11, 2025
5aa6388
feat: add category type support to menu items
Myestery Nov 11, 2025
4eb5103
feat: implement menu ordering and extension categorization system
Myestery Nov 11, 2025
083fb56
feat: restructure context menu sections with proper ordering
Myestery Nov 11, 2025
2279888
feat: add search functionality to context menu
Myestery Nov 11, 2025
f827f48
fix: move Resize and Clone to structure operations section
Myestery Nov 11, 2025
727ce2e
WIP scroll
Myestery Nov 11, 2025
20c6aa4
feat: add viewport-aware positioning to prevent menu overflow
Myestery Nov 11, 2025
942d5bd
refactor: make position types internal to prevent unused export warnings
Myestery Nov 11, 2025
b89b6ba
refactor: remove unused menuOptionExists function
Myestery Nov 11, 2025
3a3940f
feat: add fuzzy search with debouncing to context menu
Myestery Nov 14, 2025
f8e005c
refactor: remove debug console logs from NodeOptions
Myestery Nov 15, 2025
3a83b79
Merge remote-tracking branch 'origin/main' into right-click-vue-node-…
Myestery Nov 18, 2025
929c8ef
fix searchbox color and add empty state
Myestery Nov 19, 2025
53d0614
fix: adjust badge height and update search placeholder for better UX
Myestery Nov 19, 2025
32cdb2c
fix: update search input autofocus behavior and add mobile viewport h…
Myestery Nov 19, 2025
b5a34b1
refactor: remove debug logging from context menu functions for cleane…
Myestery Nov 19, 2025
1c2f913
Merge branch 'main' into right-click-vue-node-contextmenu
Myestery Nov 19, 2025
94b0aa0
[feat] add shape option to node context menu and improve CSS positioning
Myestery Nov 20, 2025
ab1e207
refactor: Update submenu repositioning logic to use a direct content …
Myestery Nov 20, 2025
03c8cf8
fix: dock menu to top of viewport when trigger is above
Myestery Nov 21, 2025
07c5bc3
style: Replace `text-[12px]` with `text-xs` utility
Myestery Nov 21, 2025
e8e8ce3
feat: allow null values in context menu options and update related ty…
Myestery Nov 22, 2025
17ab44e
feat: Replace custom search input with component in NodeOptions.
Myestery Nov 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions src/components/graph/selectionToolbox/MenuOptionItem.vue
Original file line number Diff line number Diff line change
@@ -1,16 +1,27 @@
<template>
<div v-if="option.type === 'divider'" class="my-1 h-px bg-border-default" />
<div
v-else-if="option.type === 'category'"
class="px-3 py-1.5 text-xs font-medium text-text-secondary uppercase tracking-wide pointer-events-none"
>
{{ t(`contextMenu.${option.label || ''}`) }}
</div>
<div
v-else
role="button"
class="group flex cursor-pointer items-center gap-2 rounded px-3 py-1.5 text-left text-sm text-text-primary hover:bg-interface-menu-component-surface-hovered"
:class="[
'group flex items-center gap-2 rounded px-3 py-1.5 text-left text-sm',
option.disabled
? 'cursor-not-allowed pointer-events-none text-node-icon-disabled'
: 'cursor-pointer text-text-primary hover:bg-interface-menu-component-surface-hovered'
]"
@click="handleClick"
>
<i v-if="option.icon" :class="[option.icon, 'h-4 w-4']" />
<span class="flex-1">{{ option.label }}</span>
<span
v-if="option.shortcut"
class="flex h-3.5 min-w-3.5 items-center justify-center rounded bg-interface-menu-keybind-surface-default px-1 py-0 text-xxs"
class="flex h-3.5 min-w-3.5 items-center justify-center rounded bg-interface-menu-keybind-surface-default px-1 py-0 text-xs"
>
{{ option.shortcut }}
</span>
Expand All @@ -25,7 +36,7 @@
:value="t(option.badge)"
:class="
cn(
'h-4 gap-2.5 px-1 text-[9px] text-base-foreground uppercase rounded-4xl',
'h-3.5 gap-2.5 px-1 text-xs text-base-foreground uppercase rounded-4xl',
{
'bg-primary-background': option.badge === 'new',
'bg-secondary-background': option.badge === 'deprecated'
Expand Down Expand Up @@ -57,6 +68,9 @@ const props = defineProps<Props>()
const emit = defineEmits<Emits>()

const handleClick = (event: Event) => {
if (props.option.disabled) {
return
}
emit('click', props.option, event)
}
</script>
198 changes: 172 additions & 26 deletions src/components/graph/selectionToolbox/NodeOptions.vue
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The menu isn't keyboard navigable?
That's a pretty big accessibility concern.

Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,37 @@
}"
@show="onPopoverShow"
@hide="onPopoverHide"
@wheel="canvasInteractions.forwardEventToCanvas"
>
<div class="flex min-w-48 flex-col p-2">
<MenuOptionItem
v-for="(option, index) in menuOptions"
:key="option.label || `divider-${index}`"
:option="option"
@click="handleOptionClick"
/>
<!-- Search input (fixed at top) -->
<div class="mb-2 px-1">
<SearchBox
ref="searchInput"
v-model="searchQuery"
:autofocus="false"
:placeholder="t('contextMenu.Search')"
class="w-full bg-secondary-background text-text-primary"
@keydown.escape="clearSearch"
/>
</div>

<!-- Menu items (scrollable) -->
<div class="max-h-96 lg:max-h-[75vh] overflow-y-auto">
<MenuOptionItem
v-for="(option, index) in filteredMenuOptions"
:key="option.label || `divider-${index}`"
:option="option"
@click="handleOptionClick"
/>
</div>

<!-- empty state for search -->
<div
v-if="filteredMenuOptions.length === 0"
class="px-3 py-1.5 text-xs font-medium text-text-secondary uppercase tracking-wide pointer-events-none"
>
{{ t('g.noResults') }}
</div>
Comment on lines +39 to +55
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Empty‑state placement may appear after dividers

When there are no matches, the v-if="filteredMenuOptions.length === 0" block can render after the scrollable list, but existing divider options may still be present in filteredMenuOptions (depending on how MenuOptionItem treats them). Consider either:

  • Ensuring filteredMenuOptions is empty of non‑content items when showing “no results”, or
  • Making the empty state mutually exclusive with rendering any menu options.
🤖 Prompt for AI Agents
In src/components/graph/selectionToolbox/NodeOptions.vue around lines 45 to 61,
the "no results" empty-state can appear even when divider items remain in
filteredMenuOptions; change the logic so the empty-state is mutually exclusive
with any rendered menu items by computing a display list that filters out
non-content items (e.g., dividers) and use that list for both the v-for and the
v-if check (or alternatively ensure filteredMenuOptions is purged of divider
entries before rendering), so the empty message only shows when there are truly
no actionable options.

</div>
</Popover>

Expand All @@ -45,10 +67,18 @@
</template>

<script setup lang="ts">
import { useRafFn } from '@vueuse/core'
import {
breakpointsTailwind,
debouncedRef,
useBreakpoints,
useRafFn
} from '@vueuse/core'
import { useFuse } from '@vueuse/integrations/useFuse'
import Popover from 'primevue/popover'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'

import SearchBox from '@/components/input/SearchBox.vue'
import {
forceCloseMoreOptionsSignal,
moreOptionsOpen,
Expand All @@ -64,14 +94,21 @@ import type {
SubMenuOption
} from '@/composables/graph/useMoreOptionsMenu'
import { useSubmenuPositioning } from '@/composables/graph/useSubmenuPositioning'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { calculateMenuPosition } from '@/composables/graph/useViewportAwareMenuPositioning'

import MenuOptionItem from './MenuOptionItem.vue'
import SubmenuPopover from './SubmenuPopover.vue'

const { t } = useI18n()

const popover = ref<InstanceType<typeof Popover>>()
const targetElement = ref<HTMLElement | null>(null)
const searchInput = ref<InstanceType<typeof SearchBox> | null>(null)
const searchQuery = ref('')
const debouncedSearchQuery = debouncedRef(searchQuery, 300)
const isTriggeredByToolbox = ref<boolean>(true)
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobileViewport = breakpoints.smaller('md')
// Track open state ourselves so we can restore after drag/move
const isOpen = ref(false)
const wasOpenBeforeHide = ref(false)
Expand All @@ -83,7 +120,68 @@ const currentSubmenu = ref<string | null>(null)

const { menuOptions, menuOptionsWithSubmenu, bump } = useMoreOptionsMenu()
const { toggleSubmenu, hideAllSubmenus } = useSubmenuPositioning()
const canvasInteractions = useCanvasInteractions()
// const canvasInteractions = useCanvasInteractions()

// Prepare searchable menu options (exclude dividers and categories)
const searchableMenuOptions = computed(() =>
menuOptions.value.filter(
(option) => option.type !== 'divider' && option.type !== 'category'
)
)

// Set up fuzzy search with useFuse
const { results } = useFuse(debouncedSearchQuery, searchableMenuOptions, {
fuseOptions: {
keys: ['label'],
threshold: 0.4
},
matchAllWhenSearchEmpty: true
})

// Filter menu options based on fuzzy search results
const filteredMenuOptions = computed(() => {
const query = debouncedSearchQuery.value.trim()

if (!query) {
return menuOptions.value
}

// Extract matched items from Fuse results and create a Set of labels for fast lookup
const matchedItems = results.value.map((result) => result.item)

// Create a Set of matched labels for O(1) lookup
const matchedLabels = new Set(matchedItems.map((item) => item.label))

const filtered: MenuOption[] = []
let lastWasDivider = false

// Reconstruct with dividers based on original structure
for (const option of menuOptions.value) {
if (option.type === 'divider') {
lastWasDivider = true
continue
}

if (option.type === 'category') {
continue
}
Comment on lines +165 to +167
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: What happens if we have dividers adjacent to categories (not sure what order)?


// Check if this option was matched by fuzzy search (compare by label)
if (option.label && matchedLabels.has(option.label)) {
// Add divider before this item if the last item was separated by a divider
if (lastWasDivider && filtered.length > 0) {
const lastItem = filtered[filtered.length - 1]
if (lastItem.type !== 'divider') {
filtered.push({ type: 'divider' })
}
}
filtered.push(option)
lastWasDivider = false
}
}

return filtered
})

let lastLogTs = 0
const LOG_INTERVAL = 120 // ms
Expand Down Expand Up @@ -125,19 +223,29 @@ const repositionPopover = () => {
const btn = targetElement.value
const overlayEl = resolveOverlayEl()
if (!btn || !overlayEl) return

const rect = btn.getBoundingClientRect()
const marginY = 8 // tailwind mt-2 ~ 0.5rem = 8px
const left = isTriggeredByToolbox.value
? rect.left + rect.width / 2
: rect.right - rect.width / 4
const top = isTriggeredByToolbox.value
? rect.bottom + marginY
: rect.top - marginY - 6

try {
overlayEl.style.position = 'fixed'
overlayEl.style.left = `${left}px`
overlayEl.style.top = `${top}px`
overlayEl.style.transform = 'translate(-50%, 0)'
// Calculate viewport-aware position
const style = calculateMenuPosition({
triggerRect: rect,
menuElement: overlayEl,
isTriggeredByToolbox: isTriggeredByToolbox.value,
marginY: 8
})

// Apply positioning styles
overlayEl.style.cssText += `; left: ${style.left}; position: ${style.position}; transform: ${style.transform};`

// Handle top vs bottom positioning
if (style.top !== undefined) {
overlayEl.style.top = style.top
overlayEl.style.bottom = '' // Clear bottom if using top
} else if (style.bottom !== undefined) {
overlayEl.style.bottom = style.bottom
overlayEl.style.top = '' // Clear top if using bottom
}
} catch (e) {
console.warn('[NodeOptions] Failed to set overlay style', e)
return
Expand All @@ -156,7 +264,9 @@ function openPopover(
clickedFromToolbox?: boolean
): boolean {
const el = element || targetElement.value
if (!el || !el.isConnected) return false
if (!el || !el.isConnected) {
return false
}
targetElement.value = el
if (clickedFromToolbox !== undefined)
isTriggeredByToolbox.value = clickedFromToolbox
Expand Down Expand Up @@ -208,8 +318,30 @@ const toggle = (
element?: HTMLElement,
clickedFromToolbox?: boolean
) => {
if (isOpen.value) closePopover('manual')
else openPopover(event, element, clickedFromToolbox)
const targetEl = element || targetElement.value

if (isOpen.value) {
// If clicking on a different element while open, switch to it
if (targetEl && targetEl !== targetElement.value) {
// Update target and reposition, don't close and reopen
targetElement.value = targetEl
if (clickedFromToolbox !== undefined)
isTriggeredByToolbox.value = clickedFromToolbox
bump()
// Clear and refocus search for new context
searchQuery.value = ''
requestAnimationFrame(() => {
repositionPopover()
if (!isMobileViewport.value) {
searchInput.value?.focusInput()
}
})
} else {
closePopover('manual')
}
} else {
openPopover(event, element, clickedFromToolbox)
}
}

const hide = (reason: HideReason = 'manual') => closePopover(reason)
Expand Down Expand Up @@ -264,11 +396,23 @@ const setSubmenuRef = (key: string, el: any) => {
}
}

const clearSearch = () => {
searchQuery.value = ''
}

// Distinguish outside click (PrimeVue dismiss) from programmatic hides.
const onPopoverShow = () => {
overlayElCache = resolveOverlayEl()
// Clear search and focus input
searchQuery.value = ''
// Delay first reposition slightly to ensure DOM fully painted
requestAnimationFrame(() => repositionPopover())
requestAnimationFrame(() => {
repositionPopover()
// Focus the search input after popover is shown
if (!isMobileViewport.value) {
searchInput.value?.focusInput()
}
})
startSync()
}

Expand All @@ -280,6 +424,8 @@ const onPopoverHide = () => {
moreOptionsOpen.value = false
moreOptionsRestorePending.value = false
}
// Clear search when hiding
searchQuery.value = ''
overlayElCache = null
stopSync()
lastProgrammaticHideReason.value = null
Expand Down
Loading