mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
333 lines
8.9 KiB
Svelte
333 lines
8.9 KiB
Svelte
<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> |