Files
zui/frontend/src/app/recollections/layout/RecollectionEditViewMenus.tsx
2026-03-29 01:22:33 +01:00

211 lines
7.2 KiB
TypeScript

/**
* Shared Edit and View menus for recollections. Always rendered in the menubar;
* uses the active slot (flux or logos by pathname) for undo, redo, and Flux-only actions.
*/
import React, { useEffect, useMemo } from 'react'
import {
Menubar,
MenubarContent,
MenubarItem,
MenubarMenu,
MenubarSeparator,
MenubarTrigger,
} from '@/components/ui/menubar'
import { Kbd, KbdGroup } from '@/components/ui/kbd'
import { useRecollectionActions } from './RecollectionActionsContext'
import { ClipboardPaste, Copy, CopyPlus, Redo2, Undo2, Play, Square } from 'lucide-react'
import { useRunStore } from '@/lib/graph/runStore'
const UNDO_KEYS = { key: 'z', shiftKey: false }
const REDO_KEYS = { key: 'z', shiftKey: true }
function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
const mod = ev.ctrlKey || ev.metaKey
return ev.key.toLowerCase() === want.key && !!mod && !!ev.shiftKey === want.shiftKey
}
export function RecollectionEditViewMenus() {
const { activeSlot, flux, isFluxActive } = useRecollectionActions()
const runStatus = useRunStore((s) => s.status)
const resetRun = useRunStore((s) => s.reset)
const isDirty = useRunStore((s) => s.dirty)
const fluxSlot = isFluxActive ? flux : null
const isRunning = runStatus === 'running' || runStatus === 'pending'
const hasFinished = runStatus === 'completed' || runStatus === 'failed'
// Keyboard shortcut: Cmd+Enter to run
useEffect(() => {
if (!fluxSlot?.onRun) return
const onKeyDown = (ev: KeyboardEvent) => {
const mod = ev.ctrlKey || ev.metaKey
if (mod && ev.key === 'Enter' && !isRunning) {
ev.preventDefault()
ev.stopPropagation()
fluxSlot.onRun?.()
}
}
window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true)
}, [fluxSlot?.onRun, isRunning])
useEffect(() => {
if (!activeSlot) return
const onKeyDown = (ev: KeyboardEvent) => {
if (matchKey(ev, UNDO_KEYS) && activeSlot.canUndo) {
ev.preventDefault()
ev.stopPropagation()
activeSlot.undo()
} else if (matchKey(ev, REDO_KEYS) && activeSlot.canRedo) {
ev.preventDefault()
ev.stopPropagation()
activeSlot.redo()
}
}
window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true)
}, [activeSlot])
const hasFluxOnly =
fluxSlot &&
(fluxSlot.onDuplicate != null || fluxSlot.onCopy != null || fluxSlot.onPaste != null)
const menus = useMemo(
() => (
<Menubar className="h-9 shrink-0 rounded-none border-0 bg-transparent p-0 shadow-none">
<MenubarMenu>
<MenubarTrigger className="h-9 font-normal text-muted-foreground">Edit</MenubarTrigger>
<MenubarContent>
<MenubarItem
onClick={() => activeSlot?.undo()}
disabled={!activeSlot?.canUndo}
className="gap-2"
>
<Undo2 className="h-4 w-4" />
Undo
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd> + Z</Kbd>
</KbdGroup>
</span>
</MenubarItem>
<MenubarItem
onClick={() => activeSlot?.redo()}
disabled={!activeSlot?.canRedo}
className="gap-2"
>
<Redo2 className="h-4 w-4" />
Redo
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd> + + Z</Kbd>
</KbdGroup>
</span>
</MenubarItem>
{hasFluxOnly && fluxSlot && (
<>
<MenubarSeparator />
{fluxSlot.onDuplicate != null && (
<MenubarItem
onClick={fluxSlot.onDuplicate}
disabled={!fluxSlot.canDuplicate}
className="gap-2"
>
<CopyPlus className="h-4 w-4" />
Duplicate
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd>D</Kbd>
</KbdGroup>
</span>
</MenubarItem>
)}
{fluxSlot.onCopy != null && (
<MenubarItem
onClick={fluxSlot.onCopy}
disabled={!fluxSlot.canCopy}
className="gap-2"
>
<Copy className="h-4 w-4" />
Copy
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd>C</Kbd>
</KbdGroup>
</span>
</MenubarItem>
)}
{fluxSlot.onPaste != null && (
<MenubarItem onClick={fluxSlot.onPaste} className="gap-2">
<ClipboardPaste className="h-4 w-4" />
Paste
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd>V</Kbd>
</KbdGroup>
</span>
</MenubarItem>
)}
</>
)}
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger className="h-9 font-normal text-muted-foreground">View</MenubarTrigger>
<MenubarContent>
{fluxSlot?.onFitView && (
<MenubarItem onClick={fluxSlot.onFitView} className="gap-2">
Fit View
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd>0</Kbd>
</KbdGroup>
</span>
</MenubarItem>
)}
</MenubarContent>
</MenubarMenu>
</Menubar>
),
[activeSlot, fluxSlot, hasFluxOnly]
)
return (
<div className="flex items-center gap-1">
{menus}
{fluxSlot?.onRun && (
<button
type="button"
onClick={fluxSlot.onRun}
disabled={isRunning}
className={`relative flex h-7 items-center gap-1.5 rounded-md border px-2.5 text-xs font-medium shadow-sm transition-colors disabled:cursor-not-allowed ${
isRunning
? 'border-blue-500/40 bg-blue-500/10 text-blue-600 dark:text-blue-400'
: isDirty
? 'border-primary/50 bg-primary/10 text-primary hover:bg-primary/20'
: 'border-border/60 bg-card text-foreground hover:bg-accent'
}`}
aria-label={isRunning ? 'Run in progress' : isDirty ? 'Changes pending — run graph (⌘↵)' : 'Run graph (⌘↵)'}
title="⌘↵"
>
{isRunning ? (
<>
<Square className="size-3 fill-current" />
<span>Running</span>
</>
) : (
<>
<Play className="size-3 fill-current" />
<span>Run</span>
</>
)}
{isDirty && !isRunning && (
<span className="absolute -top-1 -right-1 size-2 rounded-full bg-primary" />
)}
</button>
)}
</div>
)
}