Files
Moku/src/lib/ui/chrome/ContextMenu.svelte
T
2026-05-23 22:16:40 -04:00

333 lines
8.9 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
interface MenuItem {
label: string
icon?: any
onClick: () => void
danger?: boolean
disabled?: boolean
separator?: never
children?: MenuEntry[]
}
interface MenuSeparator {
separator: true
}
type MenuEntry = MenuItem | MenuSeparator
interface Props {
x: number
y: number
items: MenuEntry[]
onClose: () => void
}
let { x, y, items, onClose }: Props = $props()
let focused = $state(-1)
let el = $state<HTMLDivElement | undefined>(undefined)
let measured = $state(false)
let pos = $state({ left: 0, top: 0 })
let subOpen = $state(-1)
let subEls = $state<(HTMLDivElement | null)[]>([])
const actionable = $derived(
items
.map((_, index) => index)
.filter((index) => !('separator' in items[index]) && !(items[index] as MenuItem).disabled)
)
$effect(() => {
if (actionable.length && focused === -1) focused = actionable[0]
})
function getZoom(): number {
const raw = parseFloat(document.documentElement.style.zoom || '1') || 1
return raw > 10 ? raw / 100 : raw
}
$effect(() => {
if (!el) return
const zoom = getZoom()
const style = getComputedStyle(document.documentElement)
const sidebarWidth = parseFloat(style.getPropertyValue('--sidebar-width')) || 52
const titlebarHeight = parseFloat(style.getPropertyValue('--titlebar-height')) || 36
const viewportWidth = window.innerWidth / zoom
const viewportHeight = window.innerHeight / zoom
const screenX = x / zoom - sidebarWidth / zoom
const screenY = y / zoom - titlebarHeight / zoom
const menuWidth = el.offsetWidth
const menuHeight = el.offsetHeight
pos = {
left: Math.max(4, screenX + menuWidth > viewportWidth ? screenX - menuWidth : screenX),
top: Math.max(4, screenY + menuHeight > viewportHeight ? screenY - menuHeight : screenY),
}
measured = true
})
$effect(() => {
if (subOpen < 0) return
const submenu = subEls[subOpen]
if (!submenu) return
requestAnimationFrame(() => {
const zoom = getZoom()
const viewportWidth = window.innerWidth / zoom
const rect = submenu.getBoundingClientRect()
if (rect.right / zoom > viewportWidth) submenu.classList.add('sub-flip')
else submenu.classList.remove('sub-flip')
})
})
function handlePointerOutside(target: EventTarget | null) {
const inMain = el?.contains(target as Node)
const inSubmenu = subOpen >= 0 && subEls[subOpen]?.contains(target as Node)
if (!inMain && !inSubmenu) onClose()
}
function onKey(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.stopPropagation()
if (subOpen >= 0) subOpen = -1
else onClose()
return
}
if (event.key === 'ArrowDown') {
event.preventDefault()
const current = actionable.indexOf(focused)
focused = actionable[(current + 1) % actionable.length] ?? actionable[0]
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
const current = actionable.indexOf(focused)
focused = actionable[(current - 1 + actionable.length) % actionable.length] ?? actionable[0]
return
}
if (event.key === 'ArrowRight' && focused >= 0) {
const item = items[focused] as MenuItem
if (item.children?.length) subOpen = focused
return
}
if (event.key === 'ArrowLeft') {
subOpen = -1
return
}
if (event.key === 'Enter' && focused >= 0) {
event.preventDefault()
const item = items[focused] as MenuItem
if (item.children?.length) {
subOpen = focused
return
}
if (!item.disabled) {
item.onClick()
onClose()
}
}
}
$effect(() => {
const onMouseDown = (event: MouseEvent) => handlePointerOutside(event.target)
const onTouchStart = (event: TouchEvent) => handlePointerOutside(event.target)
document.addEventListener('mousedown', onMouseDown, true)
document.addEventListener('touchstart', onTouchStart, true)
document.addEventListener('keydown', onKey, true)
return () => {
document.removeEventListener('mousedown', onMouseDown, true)
document.removeEventListener('touchstart', onTouchStart, true)
document.removeEventListener('keydown', onKey, true)
}
})
</script>
<div bind:this={el} class="menu" role="menu" tabindex="-1" style={`left:${pos.left}px;top:${pos.top}px;visibility:${measured ? 'visible' : 'hidden'}`} oncontextmenu={(event) => event.preventDefault()}>
{#each items as item, index (index)}
{#if 'separator' in item}
<div class="sep"></div>
{:else}
{@const menuItem = item as MenuItem}
{@const hasSubmenu = !!menuItem.children?.length}
<div class="item-wrap">
<button
class="item"
class:danger={menuItem.danger}
class:disabled={menuItem.disabled}
class:focused={focused === index}
class:has-sub={hasSubmenu}
disabled={menuItem.disabled}
onclick={() => {
if (menuItem.disabled) return
if (hasSubmenu) {
subOpen = subOpen === index ? -1 : index
return
}
menuItem.onClick()
onClose()
}}
onmouseenter={() => {
if (menuItem.disabled) return
focused = index
subOpen = hasSubmenu ? index : -1
}}
onmouseleave={() => {
focused = -1
}}
>
<span class="icon" class:icon-danger={menuItem.danger}>
{#if menuItem.icon}
<menuItem.icon size={13} weight="light" />
{/if}
</span>
<span class="label">{menuItem.label}</span>
{#if hasSubmenu}
<span class="sub-arrow"></span>
{/if}
</button>
{#if hasSubmenu && subOpen === index}
<div bind:this={subEls[index]} class="menu submenu" role="menu" tabindex="-1" onmouseenter={() => { subOpen = index }}>
{#each menuItem.children as child, childIndex (childIndex)}
{#if 'separator' in child}
<div class="sep"></div>
{:else}
{@const childItem = child as MenuItem}
<button class="item" class:danger={childItem.danger} class:disabled={childItem.disabled} disabled={childItem.disabled} onclick={() => {
if (childItem.disabled) return
childItem.onClick()
onClose()
}}>
<span class="icon" class:icon-danger={childItem.danger}>
{#if childItem.icon}
<childItem.icon size={13} weight="light" />
{/if}
</span>
<span class="label">{childItem.label}</span>
</button>
{/if}
{/each}
</div>
{/if}
</div>
{/if}
{/each}
</div>
<style>
.menu {
position: fixed;
z-index: 200;
min-width: 190px;
border: 1px solid var(--border-base);
border-radius: var(--radius-lg);
background: var(--bg-raised);
padding: var(--sp-1);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08), 0 4px 12px rgba(0, 0, 0, 0.35), 0 16px 40px rgba(0, 0, 0, 0.25);
animation: scaleIn 0.1s ease both;
transform-origin: top left;
}
.item-wrap {
position: relative;
}
.submenu {
position: absolute;
left: 100%;
top: 0;
z-index: 201;
animation: scaleIn 0.08s ease both;
transform-origin: top left;
}
:global(.submenu.sub-flip) {
left: auto;
right: 100%;
transform-origin: top right;
}
.item {
display: flex;
align-items: center;
gap: var(--sp-2);
width: 100%;
padding: 5px var(--sp-2);
border-radius: var(--radius-md);
font-size: var(--text-sm);
color: var(--text-secondary);
text-align: left;
background: none;
border: none;
outline: none;
transition: background var(--t-fast), color var(--t-fast);
}
.item:hover:not(.disabled),
.item.focused:not(.disabled) {
background: var(--bg-overlay);
color: var(--text-primary);
}
.item.danger {
color: var(--color-error);
}
.item.danger:hover:not(.disabled),
.item.danger.focused:not(.disabled) {
background: var(--color-error-bg);
}
.item.disabled {
opacity: 0.3;
cursor: default;
pointer-events: none;
}
.icon {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
color: var(--text-faint);
border-radius: var(--radius-sm);
}
.icon-danger {
color: var(--color-error);
opacity: 0.7;
}
.label {
flex: 1;
line-height: 1.3;
}
.sub-arrow {
font-size: 14px;
color: var(--text-faint);
line-height: 1;
margin-left: auto;
padding-left: var(--sp-1);
}
.sep {
height: 1px;
background: var(--border-dim);
margin: 3px var(--sp-1);
}
</style>