Files
windmill/frontend/src/lib/components/apps/editor/GridEditorMenu.svelte
2025-06-16 14:36:26 +02:00

243 lines
6.5 KiB
Svelte

<script module lang="ts">
import { writable } from 'svelte/store'
import Popover from '../../Popover.svelte'
import { handleCut, handleCopy } from './component/componentCallbacks.svelte'
interface ContextMenuRegistry {
id: string
close: () => void
}
// Using a Svelte store for global state management
const openedContextMenus = writable<Set<ContextMenuRegistry>>(new Set())
</script>
<script lang="ts">
import { getModifierKey } from '$lib/utils'
import {
Anchor,
ArrowDownFromLine,
Copy,
Expand,
ExternalLink,
Paintbrush2,
Scissors,
Trash
} from 'lucide-svelte'
import { createEventDispatcher, getContext } from 'svelte'
import type { AppEditorContext, AppViewerContext } from '../types'
import DeleteComponent from './settingsPanel/DeleteComponent.svelte'
import { secondaryMenuLeft } from './settingsPanel/secondaryMenu'
import { clickOutside } from '$lib/utils'
import Portal from '$lib/components/Portal.svelte'
import { twMerge } from 'tailwind-merge'
let contextMenuVisible = $state(false)
let menuX = $state(0)
let menuY = $state(0)
function handleRightClick(event: MouseEvent) {
event.preventDefault()
contextMenuVisible = true
menuX = event.clientX
menuY = event.clientY
openedContextMenus.update((menus) => {
menus.forEach((menu) => menu.id !== id && menu.close())
menus.clear()
menus.add({ id, close: () => (contextMenuVisible = false) })
return menus
})
}
function closeContextMenu() {
contextMenuVisible = false
openedContextMenus.update((menus) => {
menus.clear()
return menus
})
}
function handleClickOutside(event: MouseEvent) {
if (contextMenuVisible) {
closeContextMenu()
}
}
interface Props {
locked?: boolean
fullHeight?: boolean
id: string
children?: import('svelte').Snippet
}
let { locked = false, fullHeight = false, id, children }: Props = $props()
const { selectedComponent, focusedGrid, componentControl, app } = getContext<AppViewerContext>(
'AppViewerContext'
) as AppViewerContext
const { movingcomponents, stylePanel, jobsDrawerOpen, history } = getContext<AppEditorContext>(
'AppEditorContext'
) as AppEditorContext
const ctx = {
history,
app,
selectedComponent,
focusedGrid,
componentControl,
movingcomponents,
jobsDrawerOpen
}
const dispatch = createEventDispatcher()
let deleteComponent: DeleteComponent | undefined = $state(undefined)
const menuItems = [
{
label: () => 'Cut',
onClick: () => {
handleCut(new KeyboardEvent('keydown'), ctx)
},
icon: Scissors,
shortcut: `${getModifierKey()}X`,
disabled: $movingcomponents?.includes($selectedComponent?.[0] ?? '')
},
{
label: () => 'Copy',
onClick: () => {
handleCopy(new KeyboardEvent('keydown'), ctx)
},
icon: Copy,
shortcut: `${getModifierKey()}C`
},
{
label: () => (fullHeight ? 'Undo fill height' : 'Fill height'),
onClick: () => {
dispatch('fillHeight')
},
icon: ArrowDownFromLine,
tooltip: {
text: 'When set to full height, a component will extend its height to fill the entire parent container (or canvas).',
link: 'https://www.windmill.dev/docs/apps/app_configuration_settings/app_styling#full-height'
}
},
{
label: () => 'Expand',
onClick: () => {
dispatch('expand')
},
icon: Expand,
tooltip: {
text: "Clicking the expand button maximizes the component's width and height, respecting other components' position.",
link: 'https://www.windmill.dev/docs/apps/canvas#expand-a-component'
}
},
{
label: () => (locked ? 'Unlock' : 'Lock'),
onClick: () => {
dispatch('lock')
},
icon: Anchor,
tooltip: {
text: 'Lock the component to prevent it from being repositioned by other components.',
link: 'https://www.windmill.dev/docs/apps/canvas#lock-the-position-of-a-component'
}
},
{
label: () => 'Show style panel',
onClick: () => {
secondaryMenuLeft?.toggle(stylePanel(), { type: 'style' })
},
icon: Paintbrush2,
disabled: $secondaryMenuLeft.isOpen,
tooltip: {
text: 'Use style panel to define custom CSS and Tailwind classes for the components.',
link: 'https://www.windmill.dev/docs/apps/app_configuration_settings/app_styling'
}
},
{
label: () => 'Delete',
onClick: () => {
deleteComponent?.removeGridElement()
},
icon: Trash,
shortcut: `Del`,
color: 'red'
}
]
</script>
<DeleteComponent bind:this={deleteComponent} />
<svelte:window onclick={handleClickOutside} />
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div onclick={closeContextMenu} oncontextmenu={handleRightClick} class="h-full w-full">
<!-- svelte-ignore a11y_no_static_element_interactions -->
{@render children?.()}
{#if contextMenuVisible}
<Portal name="grid-editor">
<div style="position: fixed; top: {menuY}px; left: {menuX}px; z-index:6000;">
<div class="rounded-md bg-surface border shadow-md divide-y w-64">
<div class="p-1" use:clickOutside={false}>
<!-- svelte-ignore a11y_click_events_have_key_events -->
{#each menuItems as item}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<Popover
notClickable
placement="right"
popupClass="z-[7000]"
disablePopup={!item.tooltip}
appearTimeout={800}
>
{#snippet text()}
{item.tooltip?.text}
{#if item.tooltip?.link}
<a href={item.tooltip.link} target="_blank" class="text-blue-300 text-xs">
<div class="flex flex-row gap-2 mt-4">
See documentation
<ExternalLink size="16" />
</div>
</a>
{/if}
{/snippet}
<button
class={twMerge(
'flex items-center p-2 hover:bg-surface-hover cursor-pointer transition-all rounded-md w-full',
item.color === 'red' && 'text-red-500',
item.color === 'green' && 'text-green-500',
item.color === 'blue' && 'text-blue-500',
item.disabled && 'opacity-50 cursor-not-allowed'
)}
onclick={() => {
item.onClick()
closeContextMenu()
}}
disabled={item.disabled}
>
<item.icon class="w-4 h-4" />
<span class="ml-2 text-xs">{item.label()}</span>
{#if item.shortcut}
<span class="ml-auto text-xs text-gray-400">
{item.shortcut}
</span>
{/if}
</button>
</Popover>
{/each}
</div>
</div>
</div>
</Portal>
{/if}
</div>