Skip to content
Closed
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
37 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
16f3e43
Merge branch 'main' into right-click-vue-node-contextmenu
Myestery Dec 1, 2025
5fcfe8e
[refactor] Replace NodeOptions with PrimeVue ContextMenu
Myestery Dec 2, 2025
19856bb
[refactor] Rename SubmenuPopover to ColorPickerMenu
Myestery Dec 2, 2025
06d3ea5
[chore] Remove unused positioning files and exports
Myestery Dec 2, 2025
dad2e8b
refactor: replace specific hover background colors with theme variabl…
Myestery Dec 2, 2025
01cca71
style: enhance shortcut display in the node context menu.
Myestery Dec 2, 2025
dd2f20a
feat: ensure context menu's `isOpen` state is correctly updated on sh…
Myestery Dec 2, 2025
7826e2f
test: add 500ms page timeout
Myestery Dec 2, 2025
f301223
fix: Correct 'Open in Mask Editor' text and remove redundant `isOpen`…
Myestery Dec 2, 2025
d6bc30c
feat: Constrain node context menu dimensions with overflow scrolling …
Myestery Dec 2, 2025
a8571f5
Merge branch 'main' into right-click-vue-node-contextmenu
Myestery Dec 2, 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-[12px]"
>
{{ 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-[12px] 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>
203 changes: 177 additions & 26 deletions src/components/graph/selectionToolbox/NodeOptions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,43 @@
}"
@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">
<div class="relative">
<i
class="icon-[lucide--search] absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-secondary"
/>
<input
ref="searchInput"
v-model="searchQuery"
:autofocus="false"
type="text"
:placeholder="t('contextMenu.Search')"
class="w-full rounded-lg border-0 focus:border py-2 pl-9 pr-3 text-sm text-text-primary placeholder-text-secondary focus:outline-none bg-secondary-background"
@keydown.escape="clearSearch"
/>
</div>
</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>
</div>
</Popover>

Expand All @@ -45,9 +73,16 @@
</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 {
forceCloseMoreOptionsSignal,
Expand All @@ -64,14 +99,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<HTMLInputElement | 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 +125,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
}

// 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 +228,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 +269,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 +323,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?.focus()
}
})
} else {
closePopover('manual')
}
} else {
openPopover(event, element, clickedFromToolbox)
}
}

const hide = (reason: HideReason = 'manual') => closePopover(reason)
Expand Down Expand Up @@ -264,11 +401,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?.focus()
}
})
startSync()
}

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