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:
11
web/package-lock.json
generated
11
web/package-lock.json
generated
@@ -18,7 +18,8 @@
|
|||||||
"uplot": "^1.6.32"
|
"uplot": "^1.6.32"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lucide/svelte": "^1.23.0",
|
"@internationalized/date": "^3.12.2",
|
||||||
|
"@lucide/svelte": "^1.25.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
"@tailwindcss/vite": "^4.3.2",
|
"@tailwindcss/vite": "^4.3.2",
|
||||||
"@tsconfig/svelte": "^5.0.0",
|
"@tsconfig/svelte": "^5.0.0",
|
||||||
@@ -870,7 +871,6 @@
|
|||||||
"integrity": "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==",
|
"integrity": "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@swc/helpers": "^0.5.0"
|
"@swc/helpers": "^0.5.0"
|
||||||
}
|
}
|
||||||
@@ -921,9 +921,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@lucide/svelte": {
|
"node_modules/@lucide/svelte": {
|
||||||
"version": "1.23.0",
|
"version": "1.25.0",
|
||||||
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.23.0.tgz",
|
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.25.0.tgz",
|
||||||
"integrity": "sha512-3LQbKXx9vId6Nx4E2Nu2qwgJfdmr5+CVeVJbxe5cy+HcnCRd9QVVtZXqvgBYAV1OJrPmQAf9/3gJWLCpASC/Ng==",
|
"integrity": "sha512-v9m+dD68jxVnqkU3K59mG/RSRFlPGzmKCGSyMfnXcaGv9jODDQMyQkcp1CGvk3Y/cUj9v7f8rw1n//K0B53xGQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -1378,7 +1378,6 @@
|
|||||||
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
|
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.8.0"
|
"tslib": "^2.8.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,8 @@
|
|||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lucide/svelte": "^1.23.0",
|
"@internationalized/date": "^3.12.2",
|
||||||
|
"@lucide/svelte": "^1.25.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
"@tailwindcss/vite": "^4.3.2",
|
"@tailwindcss/vite": "^4.3.2",
|
||||||
"@tsconfig/svelte": "^5.0.0",
|
"@tsconfig/svelte": "^5.0.0",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
import WindowLayer from './WindowLayer.svelte'
|
import WindowLayer from './WindowLayer.svelte'
|
||||||
import DockedLayer from './DockedLayer.svelte'
|
import DockedLayer from './DockedLayer.svelte'
|
||||||
import Taskbar from './Taskbar.svelte'
|
import Taskbar from './Taskbar.svelte'
|
||||||
|
import * as ContextMenu from '$lib/components/ui/context-menu'
|
||||||
import LayersIcon from '@lucide/svelte/icons/layers'
|
import LayersIcon from '@lucide/svelte/icons/layers'
|
||||||
import Rows3Icon from '@lucide/svelte/icons/rows-3'
|
import Rows3Icon from '@lucide/svelte/icons/rows-3'
|
||||||
import MonitorIcon from '@lucide/svelte/icons/monitor'
|
import MonitorIcon from '@lucide/svelte/icons/monitor'
|
||||||
@@ -22,37 +23,18 @@
|
|||||||
import Undo2Icon from '@lucide/svelte/icons/undo-2'
|
import Undo2Icon from '@lucide/svelte/icons/undo-2'
|
||||||
import Redo2Icon from '@lucide/svelte/icons/redo-2'
|
import Redo2Icon from '@lucide/svelte/icons/redo-2'
|
||||||
|
|
||||||
// Clicking the bare desktop (not an icon, not a window) blurs the focused
|
// canUndo/canRedo are plain wmkit method calls (not stores), so they're
|
||||||
// window — the familiar "click empty desktop to deselect" affordance.
|
// snapshotted once when the menu opens (onOpenChange) rather than read
|
||||||
function onSurfaceClick(e: MouseEvent) {
|
// reactively in the template. bits-ui auto-dismisses on item select and
|
||||||
if (e.currentTarget === e.target) wm.blur()
|
// on Escape / click-away, so the old manual menuPos/closeMenu/runMenuAction
|
||||||
}
|
// machinery is gone.
|
||||||
|
|
||||||
// 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)
|
|
||||||
let menuCanUndo = $state(false)
|
let menuCanUndo = $state(false)
|
||||||
let menuCanRedo = $state(false)
|
let menuCanRedo = $state(false)
|
||||||
|
|
||||||
function onSurfaceContextMenu(e: MouseEvent) {
|
function onOpenChange(open: boolean) {
|
||||||
if (e.currentTarget !== e.target) return
|
if (!open) return
|
||||||
e.preventDefault()
|
|
||||||
menuCanUndo = wm.canUndo()
|
menuCanUndo = wm.canUndo()
|
||||||
menuCanRedo = wm.canRedo()
|
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,
|
// 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
|
// fights the browser's own text-undo inside the task input or a form
|
||||||
// field.
|
// field.
|
||||||
function onWindowKeydown(e: KeyboardEvent) {
|
function onWindowKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape' && menuPos) {
|
|
||||||
closeMenu()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const target = e.target as HTMLElement | null
|
const target = e.target as HTMLElement | null
|
||||||
const editable = !!target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
const editable = !!target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
||||||
if (editable) return
|
if (editable) return
|
||||||
@@ -75,17 +53,48 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={onWindowKeydown} onclick={closeMenu} />
|
<svelte:window onkeydown={onWindowKeydown} />
|
||||||
|
|
||||||
<div class="fixed inset-0 flex flex-col">
|
<div class="fixed inset-0 flex flex-col">
|
||||||
<div
|
<div class="relative min-h-0 flex-1 overflow-hidden" role="presentation">
|
||||||
class="relative min-h-0 flex-1 overflow-hidden"
|
|
||||||
role="presentation"
|
|
||||||
onclick={onSurfaceClick}
|
|
||||||
oncontextmenu={onSurfaceContextMenu}
|
|
||||||
>
|
|
||||||
<GraphBackground />
|
<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">
|
<div class="pointer-events-none absolute inset-0 z-0">
|
||||||
{#each $apps as app (app.id)}
|
{#each $apps as app (app.id)}
|
||||||
{@const pos = $iconPositions[app.id] ?? { col: 0, row: 0 }}
|
{@const pos = $iconPositions[app.id] ?? { col: 0, row: 0 }}
|
||||||
@@ -107,58 +116,3 @@
|
|||||||
|
|
||||||
<Taskbar />
|
<Taskbar />
|
||||||
</div>
|
</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.
|
// unless the prop is declared bindable and the parent uses bind:runtime.
|
||||||
let {
|
let {
|
||||||
runtime = $bindable(),
|
runtime = $bindable(),
|
||||||
onContextMenu,
|
|
||||||
onPet,
|
onPet,
|
||||||
onRequestName
|
onRequestName
|
||||||
}: {
|
}: {
|
||||||
runtime: MascotRuntime
|
runtime: MascotRuntime
|
||||||
onContextMenu: (screenX: number, screenY: number) => void
|
|
||||||
onPet: () => 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). */
|
/** 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
|
onRequestName: () => void
|
||||||
@@ -548,12 +546,6 @@
|
|||||||
endDragTracking(e.pointerId, canvas)
|
endDragTracking(e.pointerId, canvas)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleContextMenu(e: MouseEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
onContextMenu(e.clientX, e.clientY)
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncCanvasSize() {
|
function syncCanvasSize() {
|
||||||
if (!canvas) return
|
if (!canvas) return
|
||||||
canvas.width = CANVAS_W
|
canvas.width = CANVAS_W
|
||||||
@@ -757,7 +749,6 @@
|
|||||||
onpointermove={onPointerMove}
|
onpointermove={onPointerMove}
|
||||||
onpointerup={onPointerUp}
|
onpointerup={onPointerUp}
|
||||||
onpointercancel={onPointerCancel}
|
onpointercancel={onPointerCancel}
|
||||||
oncontextmenu={handleContextMenu}
|
|
||||||
title={model.name ?? 'Cluck'}
|
title={model.name ?? 'Cluck'}
|
||||||
></canvas>
|
></canvas>
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,10 @@
|
|||||||
|
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import Mascot from './Mascot.svelte'
|
import Mascot from './Mascot.svelte'
|
||||||
import RadialMenu from './RadialMenu.svelte'
|
import MascotMenu from './MascotMenu.svelte'
|
||||||
import NameDialog from './NameDialog.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 { attachStimuli } from './stimuli'
|
||||||
import {
|
import {
|
||||||
initMascotState,
|
initMascotState,
|
||||||
@@ -60,20 +62,11 @@
|
|||||||
busyTalking: false
|
busyTalking: false
|
||||||
})
|
})
|
||||||
|
|
||||||
let menuPos = $state<{ x: number; y: number } | null>(null)
|
|
||||||
let nameDialogOpen = $state(false)
|
let nameDialogOpen = $state(false)
|
||||||
let nameDialogMode = $state<'hatch' | 'rename'>('hatch')
|
let nameDialogMode = $state<'hatch' | 'rename'>('hatch')
|
||||||
|
|
||||||
const model = $derived(getModel())
|
const model = $derived(getModel())
|
||||||
|
|
||||||
function openMenu(x: number, y: number) {
|
|
||||||
menuPos = { x, y }
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeMenu() {
|
|
||||||
menuPos = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestRename() {
|
function requestRename() {
|
||||||
nameDialogMode = 'rename'
|
nameDialogMode = 'rename'
|
||||||
nameDialogOpen = true
|
nameDialogOpen = true
|
||||||
@@ -103,10 +96,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function refresh() {
|
function refresh() {
|
||||||
// Trigger reactivity: the menu reads model visibility predicates on
|
// No-op. The old manual menu needed a nudge to re-run its visibility
|
||||||
// each render; touching a $state value re-runs the menu's derived
|
// filter; the shadcn context-menu content is reactive off ctx.model
|
||||||
// filter. menuPos re-assignment is a no-op if already set.
|
// (a $state object), so action mutations (happiness, stage, ...) are
|
||||||
menuPos = menuPos ? { ...menuPos } : null
|
// reflected live while the menu is open. Kept on actionCtx so leaf
|
||||||
|
// action signatures stay stable.
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPet() {
|
function onPet() {
|
||||||
@@ -216,25 +210,29 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div bind:this={host} class="pointer-events-none absolute inset-0 z-[45]">
|
<div bind:this={host} class="pointer-events-none absolute inset-0 z-[45]">
|
||||||
|
<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
|
<Mascot
|
||||||
bind:runtime
|
bind:runtime
|
||||||
onContextMenu={openMenu}
|
|
||||||
onPet={onPet}
|
onPet={onPet}
|
||||||
onRequestName={() => {
|
onRequestName={() => {
|
||||||
nameDialogMode = 'hatch'
|
nameDialogMode = 'hatch'
|
||||||
nameDialogOpen = true
|
nameDialogOpen = true
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</ContextMenu.Trigger>
|
||||||
|
<ContextMenu.Content class="min-w-56 max-w-72">
|
||||||
|
<MascotMenu actions={MASCOT_ACTIONS} ctx={actionCtx} />
|
||||||
|
</ContextMenu.Content>
|
||||||
|
</ContextMenu.Root>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if menuPos}
|
|
||||||
<RadialMenu
|
|
||||||
pos={menuPos}
|
|
||||||
ctx={actionCtx}
|
|
||||||
onDismiss={closeMenu}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if nameDialogOpen}
|
{#if nameDialogOpen}
|
||||||
<NameDialog
|
<NameDialog
|
||||||
mode={nameDialogMode}
|
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