mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-09 16:05:42 +00:00
Add context menu for the canevas
This commit is contained in:
@@ -3,6 +3,14 @@
|
||||
import { fly } from 'svelte/transition'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import type { Snippet } from 'svelte'
|
||||
import {
|
||||
getContextMenuContainerClass,
|
||||
CONTEXT_MENU_ITEM_BASE_CLASS,
|
||||
CONTEXT_MENU_ITEM_HOVER_MELT_CLASS,
|
||||
CONTEXT_MENU_ITEM_DISABLED_CLASS,
|
||||
CONTEXT_MENU_DIVIDER_CLASS,
|
||||
CONTEXT_MENU_ANIMATION_CLASSES
|
||||
} from './contextMenuStyles'
|
||||
|
||||
export interface ContextMenuItem {
|
||||
id: string
|
||||
@@ -61,20 +69,20 @@
|
||||
|
||||
{#if $open}
|
||||
<div
|
||||
class="z-50 flex flex-col gap-1 min-w-[12rem] overflow-hidden rounded-md border bg-surface p-1 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
|
||||
class="{getContextMenuContainerClass()} {CONTEXT_MENU_ANIMATION_CLASSES}"
|
||||
use:melt={$menuElement}
|
||||
transition:fly={{ duration: 150, y: -10 }}
|
||||
>
|
||||
{#each items as menuItem (menuItem.id)}
|
||||
{#if menuItem.divider}
|
||||
<div class="my-1 h-px bg-border-light"></div>
|
||||
<div class={CONTEXT_MENU_DIVIDER_CLASS}></div>
|
||||
{:else}
|
||||
<div
|
||||
class={twMerge(
|
||||
'relative flex cursor-default select-none items-center rounded-md px-2 py-1.5 text-xs outline-none transition-colors',
|
||||
CONTEXT_MENU_ITEM_BASE_CLASS,
|
||||
menuItem.disabled
|
||||
? 'pointer-events-none opacity-50'
|
||||
: 'data-[highlighted]:bg-surface-hover'
|
||||
? CONTEXT_MENU_ITEM_DISABLED_CLASS
|
||||
: CONTEXT_MENU_ITEM_HOVER_MELT_CLASS
|
||||
)}
|
||||
use:melt={$item}
|
||||
onclick={() => handleItemClick(menuItem)}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Shared styles for context menu components
|
||||
* Ensures visual consistency across all context menu implementations
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base container styles for context menu
|
||||
* @param zIndex - Optional z-index override (default: 'z-50')
|
||||
*/
|
||||
export function getContextMenuContainerClass(zIndex: string = 'z-50'): string {
|
||||
return `${zIndex} flex flex-col gap-1 min-w-[12rem] overflow-hidden rounded-md border bg-surface p-1 shadow-md`
|
||||
}
|
||||
|
||||
/**
|
||||
* Base styles for context menu items
|
||||
*/
|
||||
export const CONTEXT_MENU_ITEM_BASE_CLASS =
|
||||
'relative flex cursor-default select-none items-center rounded-md px-2 py-1.5 text-xs outline-none transition-colors'
|
||||
|
||||
/**
|
||||
* Hover state styles for context menu items (standard CSS hover)
|
||||
*/
|
||||
export const CONTEXT_MENU_ITEM_HOVER_CLASS = 'hover:bg-surface-hover'
|
||||
|
||||
/**
|
||||
* Hover state styles for context menu items (Melt UI data attribute)
|
||||
*/
|
||||
export const CONTEXT_MENU_ITEM_HOVER_MELT_CLASS = 'data-[highlighted]:bg-surface-hover'
|
||||
|
||||
/**
|
||||
* Disabled state styles for context menu items
|
||||
*/
|
||||
export const CONTEXT_MENU_ITEM_DISABLED_CLASS = 'pointer-events-none opacity-50'
|
||||
|
||||
/**
|
||||
* Divider styles for context menu
|
||||
*/
|
||||
export const CONTEXT_MENU_DIVIDER_CLASS = 'my-1 h-px bg-border-light'
|
||||
|
||||
/**
|
||||
* Melt UI animation classes for context menu
|
||||
*/
|
||||
export const CONTEXT_MENU_ANIMATION_CLASSES =
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2'
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
import SelectionBoundingBox from './SelectionBoundingBox.svelte'
|
||||
import SelectionTool from './SelectionTool.svelte'
|
||||
import NodeContextMenu from './NodeContextMenu.svelte'
|
||||
import PaneContextMenu from './PaneContextMenu.svelte'
|
||||
import { SelectionManager } from './selectionUtils.svelte'
|
||||
import { ChangeTracker } from '$lib/svelte5Utils.svelte'
|
||||
import { NoteManager } from './noteManager.svelte'
|
||||
@@ -235,6 +236,9 @@
|
||||
// Runtime text height tracking for notes (not stored in FlowNote)
|
||||
let noteTextHeights = $state<Record<string, number>>({})
|
||||
|
||||
// Reference to pane context menu component
|
||||
let paneContextMenu: PaneContextMenu | undefined = $state(undefined)
|
||||
|
||||
// Selection manager - create one if not provided
|
||||
let selectionManager = selectionManagerProp || new SelectionManager()
|
||||
const selectedId = $derived(selectionManager.getSelectedId())
|
||||
@@ -753,6 +757,7 @@
|
||||
/>
|
||||
{/if}
|
||||
<InitialViewportFitter {nodes} triggerCount={initialBuildTrigger} />
|
||||
<PaneContextMenu {editMode} bind:this={paneContextMenu} />
|
||||
<SvelteFlow
|
||||
onpaneclick={() => {
|
||||
document.dispatchEvent(new Event('focus'))
|
||||
@@ -761,6 +766,9 @@
|
||||
}
|
||||
noteManager.clearNoteSelection()
|
||||
}}
|
||||
onpanecontextmenu={({ event }) => {
|
||||
paneContextMenu?.onPaneContextMenu(event)
|
||||
}}
|
||||
onnodedragstop={(event) => {
|
||||
const node = event.targetNode
|
||||
if (node && node.type === 'note') {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<script lang="ts">
|
||||
import { useSvelteFlow } from '@xyflow/svelte'
|
||||
import { StickyNote } from 'lucide-svelte'
|
||||
import { getNoteEditorContext } from './noteEditor.svelte'
|
||||
import { DEFAULT_NOTE_COLOR } from './noteColors'
|
||||
import { fly } from 'svelte/transition'
|
||||
import {
|
||||
getContextMenuContainerClass,
|
||||
CONTEXT_MENU_ITEM_BASE_CLASS,
|
||||
CONTEXT_MENU_ITEM_HOVER_CLASS
|
||||
} from '../common/contextmenu/contextMenuStyles'
|
||||
|
||||
interface Props {
|
||||
editMode?: boolean
|
||||
}
|
||||
|
||||
let { editMode = false }: Props = $props()
|
||||
|
||||
const { screenToFlowPosition } = useSvelteFlow()
|
||||
const noteEditorContext = getNoteEditorContext()
|
||||
|
||||
let contextMenuVisible = $state(false)
|
||||
let contextMenuPosition = $state<{ x: number; y: number }>({ x: 0, y: 0 })
|
||||
let pendingFlowPosition = $state<{ x: number; y: number } | null>(null)
|
||||
|
||||
function handlePaneContextMenu(event: MouseEvent) {
|
||||
// Only show context menu in edit mode
|
||||
if (!editMode || !noteEditorContext?.noteEditor) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
// Store screen coordinates for context menu positioning
|
||||
contextMenuPosition = {
|
||||
x: event.clientX,
|
||||
y: event.clientY
|
||||
}
|
||||
|
||||
// Convert to flow coordinates for note placement
|
||||
pendingFlowPosition = screenToFlowPosition({
|
||||
x: event.clientX,
|
||||
y: event.clientY
|
||||
})
|
||||
|
||||
contextMenuVisible = true
|
||||
}
|
||||
|
||||
function handleAddStickyNote() {
|
||||
if (noteEditorContext?.noteEditor && pendingFlowPosition) {
|
||||
noteEditorContext.noteEditor.addNote({
|
||||
text: '',
|
||||
position: pendingFlowPosition,
|
||||
size: { width: 300, height: 200 },
|
||||
color: DEFAULT_NOTE_COLOR,
|
||||
type: 'free',
|
||||
locked: false
|
||||
})
|
||||
}
|
||||
contextMenuVisible = false
|
||||
}
|
||||
|
||||
// Export the handler to be used by parent
|
||||
export function onPaneContextMenu(event: MouseEvent) {
|
||||
handlePaneContextMenu(event)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if contextMenuVisible}
|
||||
<!-- Context menu -->
|
||||
<div
|
||||
class="fixed {getContextMenuContainerClass('z-[9999]')}"
|
||||
style="left: {contextMenuPosition.x}px; top: {contextMenuPosition.y}px;"
|
||||
transition:fly={{ duration: 150, y: -10 }}
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
contextMenuVisible = false
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="{CONTEXT_MENU_ITEM_BASE_CLASS} {CONTEXT_MENU_ITEM_HOVER_CLASS}"
|
||||
onclick={handleAddStickyNote}
|
||||
type="button"
|
||||
>
|
||||
<StickyNote size={14} class="mr-2" />
|
||||
<span>Add sticky note</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Invisible click catcher to close context menu -->
|
||||
<div
|
||||
class="fixed inset-0 z-[9998]"
|
||||
role="presentation"
|
||||
onclick={() => {
|
||||
contextMenuVisible = false
|
||||
}}
|
||||
oncontextmenu={(e) => {
|
||||
e.preventDefault()
|
||||
contextMenuVisible = false
|
||||
}}
|
||||
></div>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user