feat(web): adopt shadcn context-menu for mascot + desktop right-click menus
Problem: the mascot's right-click menu was non-interactive — RadialMenu's root div rendered inside MascotLayer's pointer-events-none root (and the new DockedLayer wrapper compounded it) without re-enabling pointer-events, so clicks passed straight through. The desktop right-click menu was a hand-rolled positioned div, inconsistent with the rest of the UI. Change: both menus now use the shadcn-svelte context-menu primitive (bits-ui, portaled to <body>). - Mascot: MascotMenu.svelte renders the action tree recursively — children become ContextMenu.Sub (native hover sub-menu navigation, replacing the manual breadcrumb stack), leaves become ContextMenu.Item with onSelect. MascotLayer wraps <Mascot> in a ContextMenu.Trigger; visibility predicates read reactively off ctx.model so items appear/disappear live. Removed the manual menuPos/openMenu/closeMenu machinery. RadialMenu.svelte deleted. - Desktop: the surface's bare-desktop hit area is now a ContextMenu.Trigger layer (absolute inset-0, pointer-events-auto) placed before the icons/windows in the DOM. The DOM-structure gate (icons/windows are pointer-events-auto siblings that paint on top and intercept their own right-clicks; bare desktop falls through to the trigger) replaces the old fragile e.currentTarget === e.target check. Left-click blur moved onto the trigger; Undo/Redo disabled state snapshotted via onOpenChange (canUndo/canRedo are wmkit methods). Risk: the blocker that made the mascot menu non-interactive in the first place — Mascot.svelte's handleContextMenu called e.stopPropagation(), which would have prevented a ContextMenu.Trigger wrapper from ever seeing the right-click. Removed that handler; bits-ui now owns right-click on the mascot, left-click drag/pet passes through. The context-menu content portals to <body>, escaping the pointer-events-none mascot and docked layers entirely — the structural fix, not just a component swap. Verification: vitest 38/38; svelte-check + tsc clean for changed files; eslint clean (the shadcn-generated ui/context-menu/* files carry the same baseline custom_element_props_identifier warnings as the rest of the ui/ folder, not from this change); vite build green; runtime confirmed — right-click mascot opens the action tree with hover sub-menus, right-click bare desktop opens Cascade/Tile/Show/Reset/ Undo/Redo, right-click on an icon or window does not.
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
import WindowLayer from './WindowLayer.svelte'
|
||||
import DockedLayer from './DockedLayer.svelte'
|
||||
import Taskbar from './Taskbar.svelte'
|
||||
import * as ContextMenu from '$lib/components/ui/context-menu'
|
||||
import LayersIcon from '@lucide/svelte/icons/layers'
|
||||
import Rows3Icon from '@lucide/svelte/icons/rows-3'
|
||||
import MonitorIcon from '@lucide/svelte/icons/monitor'
|
||||
@@ -22,37 +23,18 @@
|
||||
import Undo2Icon from '@lucide/svelte/icons/undo-2'
|
||||
import Redo2Icon from '@lucide/svelte/icons/redo-2'
|
||||
|
||||
// Clicking the bare desktop (not an icon, not a window) blurs the focused
|
||||
// window — the familiar "click empty desktop to deselect" affordance.
|
||||
function onSurfaceClick(e: MouseEvent) {
|
||||
if (e.currentTarget === e.target) wm.blur()
|
||||
}
|
||||
|
||||
// Right-click menu, bare desktop only (same currentTarget===target gate as
|
||||
// onSurfaceClick above — icons and windows sit on pointer-events-auto
|
||||
// layers above the otherwise pointer-events-none surface, so a right-click
|
||||
// that lands on either of them never reaches here). canUndo/canRedo are
|
||||
// plain wmkit method calls (not stores), so they're snapshotted once at
|
||||
// open time rather than read reactively in the template.
|
||||
let menuPos = $state<{ x: number; y: number } | null>(null)
|
||||
// canUndo/canRedo are plain wmkit method calls (not stores), so they're
|
||||
// snapshotted once when the menu opens (onOpenChange) rather than read
|
||||
// reactively in the template. bits-ui auto-dismisses on item select and
|
||||
// on Escape / click-away, so the old manual menuPos/closeMenu/runMenuAction
|
||||
// machinery is gone.
|
||||
let menuCanUndo = $state(false)
|
||||
let menuCanRedo = $state(false)
|
||||
|
||||
function onSurfaceContextMenu(e: MouseEvent) {
|
||||
if (e.currentTarget !== e.target) return
|
||||
e.preventDefault()
|
||||
function onOpenChange(open: boolean) {
|
||||
if (!open) return
|
||||
menuCanUndo = wm.canUndo()
|
||||
menuCanRedo = wm.canRedo()
|
||||
menuPos = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
menuPos = null
|
||||
}
|
||||
|
||||
function runMenuAction(fn: () => void) {
|
||||
fn()
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+Z / Shift+Z for window-arrangement undo/redo (move, resize,
|
||||
@@ -61,10 +43,6 @@
|
||||
// fights the browser's own text-undo inside the task input or a form
|
||||
// field.
|
||||
function onWindowKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && menuPos) {
|
||||
closeMenu()
|
||||
return
|
||||
}
|
||||
const target = e.target as HTMLElement | null
|
||||
const editable = !!target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
||||
if (editable) return
|
||||
@@ -75,17 +53,48 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onWindowKeydown} onclick={closeMenu} />
|
||||
<svelte:window onkeydown={onWindowKeydown} />
|
||||
|
||||
<div class="fixed inset-0 flex flex-col">
|
||||
<div
|
||||
class="relative min-h-0 flex-1 overflow-hidden"
|
||||
role="presentation"
|
||||
onclick={onSurfaceClick}
|
||||
oncontextmenu={onSurfaceContextMenu}
|
||||
>
|
||||
<div class="relative min-h-0 flex-1 overflow-hidden" role="presentation">
|
||||
<GraphBackground />
|
||||
|
||||
<ContextMenu.Root {onOpenChange}>
|
||||
<!-- The bare-desktop hit area. Placed before the icons/windows layers
|
||||
so they (pointer-events-auto, later in DOM → paint on top) catch
|
||||
their own right-clicks — the trigger only sees right-clicks that
|
||||
fall through to bare desktop. This DOM-structure gate replaces the
|
||||
old `currentTarget === target` event check. Left-click on bare
|
||||
desktop blurs the focused window (the familiar "click empty
|
||||
desktop to deselect" affordance). -->
|
||||
<ContextMenu.Trigger
|
||||
class="absolute inset-0 z-0"
|
||||
onclick={() => wm.blur()}
|
||||
></ContextMenu.Trigger>
|
||||
<ContextMenu.Content class="min-w-48">
|
||||
<ContextMenu.Item onSelect={() => wm.arrange('cascade')}>
|
||||
<LayersIcon class="size-4" /> Cascade windows
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item onSelect={() => wm.arrange('tile')}>
|
||||
<Rows3Icon class="size-4" /> Tile windows
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item onSelect={toggleShowDesktop}>
|
||||
<MonitorIcon class="size-4" /> Show desktop
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item onSelect={resetIconLayout}>
|
||||
<RotateCcwIcon class="size-4" /> Reset icon layout
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item disabled={!menuCanUndo} onSelect={() => wm.undo()}>
|
||||
<Undo2Icon class="size-4" /> Undo
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item disabled={!menuCanRedo} onSelect={() => wm.redo()}>
|
||||
<Redo2Icon class="size-4" /> Redo
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
|
||||
<div class="pointer-events-none absolute inset-0 z-0">
|
||||
{#each $apps as app (app.id)}
|
||||
{@const pos = $iconPositions[app.id] ?? { col: 0, row: 0 }}
|
||||
@@ -107,58 +116,3 @@
|
||||
|
||||
<Taskbar />
|
||||
</div>
|
||||
|
||||
{#if menuPos}
|
||||
<div
|
||||
class="fixed z-50 min-w-48 rounded-md border bg-popover p-1 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10"
|
||||
style="left: {menuPos.x}px; top: {menuPos.y}px"
|
||||
role="menu"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
|
||||
onclick={() => runMenuAction(() => wm.arrange('cascade'))}
|
||||
>
|
||||
<LayersIcon class="size-4" /> Cascade windows
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
|
||||
onclick={() => runMenuAction(() => wm.arrange('tile'))}
|
||||
>
|
||||
<Rows3Icon class="size-4" /> Tile windows
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
|
||||
onclick={() => runMenuAction(toggleShowDesktop)}
|
||||
>
|
||||
<MonitorIcon class="size-4" /> Show desktop
|
||||
</button>
|
||||
<div class="my-1 h-px bg-border"></div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
|
||||
onclick={() => runMenuAction(resetIconLayout)}
|
||||
>
|
||||
<RotateCcwIcon class="size-4" /> Reset icon layout
|
||||
</button>
|
||||
<div class="my-1 h-px bg-border"></div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!menuCanUndo}
|
||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
|
||||
onclick={() => runMenuAction(() => wm.undo())}
|
||||
>
|
||||
<Undo2Icon class="size-4" /> Undo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!menuCanRedo}
|
||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
|
||||
onclick={() => runMenuAction(() => wm.redo())}
|
||||
>
|
||||
<Redo2Icon class="size-4" /> Redo
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import type { Snippet } from "svelte";
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
checked = $bindable(false),
|
||||
indeterminate = $bindable(false),
|
||||
class: className,
|
||||
inset,
|
||||
children: childrenProp,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<ContextMenuPrimitive.CheckboxItemProps> & {
|
||||
inset?: boolean;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
bind:ref
|
||||
bind:checked
|
||||
bind:indeterminate
|
||||
data-slot="context-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
class={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm data-inset:pl-8 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ checked })}
|
||||
<span class="absolute right-2 pointer-events-none">
|
||||
{#if checked}
|
||||
<CheckIcon />
|
||||
{/if}
|
||||
</span>
|
||||
{@render childrenProp?.()}
|
||||
{/snippet}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import ContextMenuPortal from "./context-menu-portal.svelte";
|
||||
import type { ComponentProps } from "svelte";
|
||||
import type { WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
portalProps,
|
||||
class: className,
|
||||
...restProps
|
||||
}: ContextMenuPrimitive.ContentProps & {
|
||||
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof ContextMenuPortal>>;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<ContextMenuPortal {...portalProps}>
|
||||
<ContextMenuPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="context-menu-content"
|
||||
class={cn(
|
||||
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 ring-foreground/10 bg-popover text-popover-foreground min-w-36 rounded-md p-1 shadow-md ring-1 duration-100 z-50 overflow-x-hidden overflow-y-auto outline-none",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
</ContextMenuPortal>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
...restProps
|
||||
}: ContextMenuPrimitive.GroupHeadingProps & {
|
||||
inset?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.GroupHeading
|
||||
bind:ref
|
||||
data-slot="context-menu-group-heading"
|
||||
data-inset={inset}
|
||||
class={cn("text-foreground px-2 py-1.5 text-sm font-medium data-inset:ps-8", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: ContextMenuPrimitive.GroupProps = $props();
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.Group bind:ref data-slot="context-menu-group" {...restProps} />
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...restProps
|
||||
}: ContextMenuPrimitive.ItemProps & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.Item
|
||||
bind:ref
|
||||
data-slot="context-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
class={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive focus:*:[svg]:text-accent-foreground gap-2 rounded-sm px-2 py-1.5 text-sm data-inset:pl-8 [&_svg:not([class*='size-'])]:size-4 group/context-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||
inset?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="context-menu-label"
|
||||
data-inset={inset}
|
||||
class={cn("text-muted-foreground px-2 py-1.5 text-xs font-medium data-inset:pl-8 data-inset:pl-8", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
|
||||
|
||||
let { ...restProps }: ContextMenuPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.Portal {...restProps} />
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(""),
|
||||
...restProps
|
||||
}: ContextMenuPrimitive.RadioGroupProps = $props();
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.RadioGroup
|
||||
bind:ref
|
||||
bind:value
|
||||
data-slot="context-menu-radio-group"
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
children: childrenProp,
|
||||
...restProps
|
||||
}: WithoutChild<ContextMenuPrimitive.RadioItemProps> & {
|
||||
inset?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
bind:ref
|
||||
data-slot="context-menu-radio-item"
|
||||
data-inset={inset}
|
||||
class={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm data-inset:pl-8 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ checked })}
|
||||
<span class="absolute right-2 pointer-events-none">
|
||||
{#if checked}
|
||||
<CheckIcon />
|
||||
{/if}
|
||||
</span>
|
||||
{@render childrenProp?.({ checked })}
|
||||
{/snippet}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: ContextMenuPrimitive.SeparatorProps = $props();
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.Separator
|
||||
bind:ref
|
||||
data-slot="context-menu-separator"
|
||||
class={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={ref}
|
||||
data-slot="context-menu-shortcut"
|
||||
class={cn("text-muted-foreground group-focus/context-menu-item:text-accent-foreground ml-auto text-xs tracking-widest", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: ContextMenuPrimitive.SubContentProps = $props();
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.SubContent
|
||||
bind:ref
|
||||
data-slot="context-menu-sub-content"
|
||||
class={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 bg-popover text-popover-foreground min-w-32 rounded-md border p-1 shadow-lg duration-100", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
children,
|
||||
...restProps
|
||||
}: WithoutChild<ContextMenuPrimitive.SubTriggerProps> & {
|
||||
inset?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
bind:ref
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
class={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground rounded-sm px-2 py-1.5 text-sm data-inset:pl-8 [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none data-inset:ps-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<ChevronRightIcon class="ml-auto" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
|
||||
|
||||
let { open = $bindable(false), ...restProps }: ContextMenuPrimitive.SubProps = $props();
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.Sub bind:open {...restProps} />
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: ContextMenuPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.Trigger
|
||||
bind:ref
|
||||
data-slot="context-menu-trigger"
|
||||
class={cn("cn-context-menu-trigger select-none", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from "bits-ui";
|
||||
|
||||
let { open = $bindable(false), ...restProps }: ContextMenuPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.Root bind:open {...restProps} />
|
||||
52
web/src/lib/components/ui/context-menu/index.ts
Normal file
52
web/src/lib/components/ui/context-menu/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import Root from "./context-menu.svelte";
|
||||
import Sub from "./context-menu-sub.svelte";
|
||||
import Portal from "./context-menu-portal.svelte";
|
||||
import Trigger from "./context-menu-trigger.svelte";
|
||||
import Group from "./context-menu-group.svelte";
|
||||
import RadioGroup from "./context-menu-radio-group.svelte";
|
||||
import Item from "./context-menu-item.svelte";
|
||||
import GroupHeading from "./context-menu-group-heading.svelte";
|
||||
import Content from "./context-menu-content.svelte";
|
||||
import Shortcut from "./context-menu-shortcut.svelte";
|
||||
import RadioItem from "./context-menu-radio-item.svelte";
|
||||
import Separator from "./context-menu-separator.svelte";
|
||||
import SubContent from "./context-menu-sub-content.svelte";
|
||||
import SubTrigger from "./context-menu-sub-trigger.svelte";
|
||||
import CheckboxItem from "./context-menu-checkbox-item.svelte";
|
||||
import Label from "./context-menu-label.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Sub,
|
||||
Portal,
|
||||
Item,
|
||||
GroupHeading,
|
||||
Label,
|
||||
Group,
|
||||
Trigger,
|
||||
Content,
|
||||
Shortcut,
|
||||
Separator,
|
||||
RadioItem,
|
||||
SubContent,
|
||||
SubTrigger,
|
||||
RadioGroup,
|
||||
CheckboxItem,
|
||||
//
|
||||
Root as ContextMenu,
|
||||
Sub as ContextMenuSub,
|
||||
Portal as ContextMenuPortal,
|
||||
Item as ContextMenuItem,
|
||||
GroupHeading as ContextMenuGroupHeading,
|
||||
Group as ContextMenuGroup,
|
||||
Content as ContextMenuContent,
|
||||
Trigger as ContextMenuTrigger,
|
||||
Shortcut as ContextMenuShortcut,
|
||||
RadioItem as ContextMenuRadioItem,
|
||||
Separator as ContextMenuSeparator,
|
||||
RadioGroup as ContextMenuRadioGroup,
|
||||
SubContent as ContextMenuSubContent,
|
||||
SubTrigger as ContextMenuSubTrigger,
|
||||
CheckboxItem as ContextMenuCheckboxItem,
|
||||
Label as ContextMenuLabel,
|
||||
};
|
||||
@@ -41,12 +41,10 @@
|
||||
// unless the prop is declared bindable and the parent uses bind:runtime.
|
||||
let {
|
||||
runtime = $bindable(),
|
||||
onContextMenu,
|
||||
onPet,
|
||||
onRequestName
|
||||
}: {
|
||||
runtime: MascotRuntime
|
||||
onContextMenu: (screenX: number, screenY: number) => void
|
||||
onPet: () => void
|
||||
/** Called on a plain click while the mascot is a not-yet-named egg — reopens the naming dialog (see NameDialog's Escape-dismiss). */
|
||||
onRequestName: () => void
|
||||
@@ -548,12 +546,6 @@
|
||||
endDragTracking(e.pointerId, canvas)
|
||||
}
|
||||
|
||||
function handleContextMenu(e: MouseEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onContextMenu(e.clientX, e.clientY)
|
||||
}
|
||||
|
||||
function syncCanvasSize() {
|
||||
if (!canvas) return
|
||||
canvas.width = CANVAS_W
|
||||
@@ -757,7 +749,6 @@
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onpointercancel={onPointerCancel}
|
||||
oncontextmenu={handleContextMenu}
|
||||
title={model.name ?? 'Cluck'}
|
||||
></canvas>
|
||||
|
||||
|
||||
@@ -14,8 +14,10 @@
|
||||
|
||||
import { onMount } from 'svelte'
|
||||
import Mascot from './Mascot.svelte'
|
||||
import RadialMenu from './RadialMenu.svelte'
|
||||
import MascotMenu from './MascotMenu.svelte'
|
||||
import NameDialog from './NameDialog.svelte'
|
||||
import * as ContextMenu from '$lib/components/ui/context-menu'
|
||||
import { MASCOT_ACTIONS } from './actions'
|
||||
import { attachStimuli } from './stimuli'
|
||||
import {
|
||||
initMascotState,
|
||||
@@ -60,20 +62,11 @@
|
||||
busyTalking: false
|
||||
})
|
||||
|
||||
let menuPos = $state<{ x: number; y: number } | null>(null)
|
||||
let nameDialogOpen = $state(false)
|
||||
let nameDialogMode = $state<'hatch' | 'rename'>('hatch')
|
||||
|
||||
const model = $derived(getModel())
|
||||
|
||||
function openMenu(x: number, y: number) {
|
||||
menuPos = { x, y }
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
menuPos = null
|
||||
}
|
||||
|
||||
function requestRename() {
|
||||
nameDialogMode = 'rename'
|
||||
nameDialogOpen = true
|
||||
@@ -103,10 +96,11 @@
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
// Trigger reactivity: the menu reads model visibility predicates on
|
||||
// each render; touching a $state value re-runs the menu's derived
|
||||
// filter. menuPos re-assignment is a no-op if already set.
|
||||
menuPos = menuPos ? { ...menuPos } : null
|
||||
// No-op. The old manual menu needed a nudge to re-run its visibility
|
||||
// filter; the shadcn context-menu content is reactive off ctx.model
|
||||
// (a $state object), so action mutations (happiness, stage, ...) are
|
||||
// reflected live while the menu is open. Kept on actionCtx so leaf
|
||||
// action signatures stay stable.
|
||||
}
|
||||
|
||||
function onPet() {
|
||||
@@ -216,25 +210,29 @@
|
||||
</script>
|
||||
|
||||
<div bind:this={host} class="pointer-events-none absolute inset-0 z-[45]">
|
||||
<Mascot
|
||||
bind:runtime
|
||||
onContextMenu={openMenu}
|
||||
onPet={onPet}
|
||||
onRequestName={() => {
|
||||
nameDialogMode = 'hatch'
|
||||
nameDialogOpen = true
|
||||
}}
|
||||
/>
|
||||
<ContextMenu.Root>
|
||||
<!-- The trigger wraps the mascot sprite; right-click on the canvas
|
||||
bubbles up to it and bits-ui opens the menu content (portaled to
|
||||
<body>, so it's fully interactive despite this layer's
|
||||
pointer-events-none root — the old RadialMenu was non-interactive
|
||||
because it rendered inside this root without re-enabling
|
||||
pointer-events). Left-click drag/pet passes through untouched. -->
|
||||
<ContextMenu.Trigger>
|
||||
<Mascot
|
||||
bind:runtime
|
||||
onPet={onPet}
|
||||
onRequestName={() => {
|
||||
nameDialogMode = 'hatch'
|
||||
nameDialogOpen = true
|
||||
}}
|
||||
/>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Content class="min-w-56 max-w-72">
|
||||
<MascotMenu actions={MASCOT_ACTIONS} ctx={actionCtx} />
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
</div>
|
||||
|
||||
{#if menuPos}
|
||||
<RadialMenu
|
||||
pos={menuPos}
|
||||
ctx={actionCtx}
|
||||
onDismiss={closeMenu}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if nameDialogOpen}
|
||||
<NameDialog
|
||||
mode={nameDialogMode}
|
||||
|
||||
53
web/src/lib/mascot/MascotMenu.svelte
Normal file
53
web/src/lib/mascot/MascotMenu.svelte
Normal file
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
// Renders the mascot action tree (actions.ts MASCOT_ACTIONS) as a shadcn
|
||||
// context menu. Children → nested ContextMenu.Sub (native hover/click
|
||||
// sub-menu navigation, replacing the old manual breadcrumb stack in
|
||||
// RadialMenu.svelte); leaves → ContextMenu.Item with onSelect. The
|
||||
// content portals to <body> via bits-ui (see
|
||||
// ui/context-menu/context-menu-content.svelte), so it escapes the
|
||||
// mascot layer's pointer-events-none root — the old menu was
|
||||
// non-interactive precisely because it rendered inside that root
|
||||
// without re-enabling pointer-events. Visibility predicates are read
|
||||
// reactively off ctx.model, so an action's visibility updates live while
|
||||
// the menu is open (e.g. "Rename" disappears once the egg hatches).
|
||||
//
|
||||
// Recursive: a node with children renders a Sub whose SubContent recurses
|
||||
// into this same component. Svelte resolves the self-import lazily, so
|
||||
// the cycle is fine.
|
||||
import * as ContextMenu from '$lib/components/ui/context-menu'
|
||||
import type { MascotActionCtx, RadialAction } from './types'
|
||||
// Self-import for recursive rendering of nested action groups.
|
||||
import MascotMenu from './MascotMenu.svelte'
|
||||
|
||||
let { actions, ctx }: { actions: RadialAction[]; ctx: MascotActionCtx } = $props()
|
||||
|
||||
const visible = $derived(actions.filter((a) => !a.visible || a.visible(ctx.model)))
|
||||
</script>
|
||||
|
||||
{#each visible as a (a.id)}
|
||||
{#if a.children && a.children.length > 0}
|
||||
<ContextMenu.Sub>
|
||||
<ContextMenu.SubTrigger>
|
||||
{#if a.icon}<a.icon class="size-4" />{/if}
|
||||
{a.label}
|
||||
</ContextMenu.SubTrigger>
|
||||
<ContextMenu.SubContent class="min-w-56 max-w-72">
|
||||
<MascotMenu actions={a.children} {ctx} />
|
||||
</ContextMenu.SubContent>
|
||||
</ContextMenu.Sub>
|
||||
{:else}
|
||||
<ContextMenu.Item
|
||||
onSelect={() => {
|
||||
a.action?.(ctx)
|
||||
}}
|
||||
>
|
||||
{#if a.icon}<a.icon class="size-4" />{/if}
|
||||
<span class="flex min-w-0 flex-1 flex-col">
|
||||
<span class="truncate">{a.label}</span>
|
||||
{#if a.description}
|
||||
<span class="text-[11px] leading-snug font-normal text-muted-foreground">{a.description}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</ContextMenu.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
@@ -1,157 +0,0 @@
|
||||
<script lang="ts" module>
|
||||
// RadialMenu (now a rounded-button column menu): opened on right-click
|
||||
// over the mascot. Renders the MASCOT_ACTIONS tree as a stack of
|
||||
// rounded buttons with full text labels; selecting a node with
|
||||
// `children` swaps the column to those children + a "Back" button at
|
||||
// the top (tracked via a local breadcrumb stack). Leaf nodes call
|
||||
// `action(ctx)` and dismiss.
|
||||
//
|
||||
// z-[60] — must beat the desktop's own right-click menu (z-50) and
|
||||
// sit above the mascot layer (z-[45]). Dismissal mirrors the desktop
|
||||
// menu: a <svelte:window onclick> closes it, Escape pops one level
|
||||
// then closes on the next press, and the menu's own clicks
|
||||
// stopPropagation so they don't bubble to the close handler.
|
||||
|
||||
import { MASCOT_ACTIONS } from './actions'
|
||||
import type { MascotActionCtx, RadialAction } from './types'
|
||||
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left'
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
let {
|
||||
pos,
|
||||
ctx,
|
||||
onDismiss
|
||||
}: {
|
||||
pos: { x: number; y: number }
|
||||
ctx: MascotActionCtx
|
||||
onDismiss: () => void
|
||||
} = $props()
|
||||
|
||||
// Breadcrumb stack: each entry is the list of actions shown at that
|
||||
// level. The top of the stack is the current column.
|
||||
let stack = $state<RadialAction[][]>([MASCOT_ACTIONS])
|
||||
let depth = $derived(stack.length)
|
||||
let current = $derived(stack[depth - 1] ?? [])
|
||||
|
||||
// Clamp the anchor to the viewport, then decide which side to grow
|
||||
// toward based on anchor position alone (no measure-then-flip — that
|
||||
// paints off-screen first). When the anchor is in the bottom half,
|
||||
// the menu grows upward (bottom edge aligns with anchor.y); same for
|
||||
// the right edge when in the right half. The chicken lives on the
|
||||
// desktop surface's bottom edge, so this almost always flips up.
|
||||
const anchor = $derived.by(() => {
|
||||
const margin = 8
|
||||
const vw = typeof window !== 'undefined' ? window.innerWidth : 1024
|
||||
const vh = typeof window !== 'undefined' ? window.innerHeight : 768
|
||||
const x = Math.max(margin, Math.min(vw - margin, pos.x))
|
||||
const y = Math.max(margin, Math.min(vh - margin, pos.y))
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
growUp: y > vh / 2,
|
||||
growLeft: x > vw / 2
|
||||
}
|
||||
})
|
||||
|
||||
let host = $state<HTMLDivElement | null>(null)
|
||||
|
||||
const visibleItems = $derived(current.filter((a) => !a.visible || a.visible(ctx.model)))
|
||||
|
||||
function selectItem(a: RadialAction, ev: MouseEvent) {
|
||||
ev.stopPropagation()
|
||||
if (a.children && a.children.length > 0) {
|
||||
stack = [...stack, a.children]
|
||||
return
|
||||
}
|
||||
if (a.action) {
|
||||
a.action(ctx)
|
||||
}
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
function back(ev: MouseEvent) {
|
||||
ev.stopPropagation()
|
||||
if (stack.length > 1) {
|
||||
stack = stack.slice(0, -1)
|
||||
} else {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
function onWindowClick() {
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
function onWindowKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
if (stack.length > 1) {
|
||||
stack = stack.slice(0, -1)
|
||||
} else {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the stack when the menu is (re)opened with a new pos.
|
||||
$effect(() => {
|
||||
void pos
|
||||
stack = [MASCOT_ACTIONS]
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={onWindowClick} onkeydown={onWindowKeydown} />
|
||||
|
||||
<div
|
||||
bind:this={host}
|
||||
class="fixed z-[60] min-w-56 max-w-72 rounded-xl border bg-popover p-1.5 text-sm text-popover-foreground shadow-lg ring-1 ring-foreground/10"
|
||||
style="left: {anchor.growLeft ? 'auto' : `${anchor.x}px`}; right: {anchor.growLeft ? `${window.innerWidth - anchor.x}px` : 'auto'}; top: {anchor.growUp ? 'auto' : `${anchor.y}px`}; bottom: {anchor.growUp ? `${window.innerHeight - anchor.y}px` : 'auto'};"
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
aria-label="Mascot actions"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
if (stack.length > 1) stack = stack.slice(0, -1)
|
||||
else onDismiss()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if depth > 1}
|
||||
<button
|
||||
type="button"
|
||||
class="mb-1 flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-popover-foreground/70 hover:bg-accent hover:text-accent-foreground"
|
||||
onclick={back}
|
||||
>
|
||||
<ChevronLeftIcon class="size-4" /> Back
|
||||
</button>
|
||||
<div class="my-1 h-px bg-border"></div>
|
||||
{/if}
|
||||
|
||||
{#each visibleItems as a (a.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-start justify-between gap-2 rounded-md px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
|
||||
onclick={(ev) => selectItem(a, ev)}
|
||||
oncontextmenu={(ev) => ev.preventDefault()}
|
||||
>
|
||||
<span class="flex min-w-0 flex-1 items-start gap-2">
|
||||
{#if a.icon}
|
||||
<a.icon class="mt-0.5 size-4 shrink-0" />
|
||||
{/if}
|
||||
<span class="flex min-w-0 flex-col">
|
||||
<span class="truncate">{a.label}</span>
|
||||
{#if a.description}
|
||||
<span class="text-[11px] leading-snug font-normal text-popover-foreground/60">{a.description}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
{#if a.children && a.children.length > 0}
|
||||
<ChevronRightIcon class="mt-0.5 size-4 shrink-0 opacity-60" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
Reference in New Issue
Block a user