211 lines
7.2 KiB
TypeScript
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>
|
|
)
|
|
}
|