The shared duration tokens had been stretched to slow the pulses down, which also slowed every panel and dock animation and left `motion.ts` out of step with the CSS. Both are back to 200/300 ms, and the pulses use a new `--duration-pulse` (500 ms): a message arriving is a signal, not a state change, and it is the only thing here that should linger. The edge pulse read its own 300 ms constant, so it now takes the token through `motion.ts` and the two sides cannot drift again. The code editor also expands: the same node panel fills the content region beside the sidebar, the canvas chrome steps aside rather than floating on top, and the settings above the editor keep a readable width.
147 lines
4.0 KiB
TypeScript
147 lines
4.0 KiB
TypeScript
import { X } from "lucide-react"
|
|
import { AnimatePresence, motion } from "motion/react"
|
|
import type { ReactNode } from "react"
|
|
import { useEffect } from "react"
|
|
|
|
import { Button } from "@/components/ui/button"
|
|
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
|
|
import { useIsMobile } from "@/hooks/useMobile"
|
|
import { duration, easeEmphasized, easeStandard } from "@/lib/motion"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
/** Same grammar as the shared `slideUp`, on the axis this panel travels. */
|
|
const panelSlide = {
|
|
hidden: { opacity: 0, x: 16 },
|
|
visible: {
|
|
opacity: 1,
|
|
x: 0,
|
|
transition: { duration: duration.base, ease: easeEmphasized },
|
|
},
|
|
exit: {
|
|
opacity: 0,
|
|
x: 16,
|
|
transition: { duration: duration.fast, ease: easeStandard },
|
|
},
|
|
}
|
|
|
|
export const PANEL_SECTION =
|
|
"text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground"
|
|
|
|
/**
|
|
* The editor's settings panel: floating over the canvas so the graph stays
|
|
* visible and running behind it, a full-screen sheet where there is no room
|
|
* for that.
|
|
*
|
|
* Node settings and flow settings share it, so the two read as one surface.
|
|
*/
|
|
export function SidePanel({
|
|
open,
|
|
label,
|
|
testId,
|
|
bodyKey,
|
|
expanded = false,
|
|
header,
|
|
footer,
|
|
children,
|
|
onClose,
|
|
}: {
|
|
open: boolean
|
|
/** Names the panel for screen readers. */
|
|
label: string
|
|
testId: string
|
|
/** Remounts the contents when the thing being edited changes. */
|
|
bodyKey: string
|
|
/** Fill the content area instead of floating beside the canvas. */
|
|
expanded?: boolean
|
|
header: ReactNode
|
|
footer?: ReactNode
|
|
children: ReactNode
|
|
onClose: () => void
|
|
}) {
|
|
const isMobile = useIsMobile()
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
const onKey = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") onClose()
|
|
}
|
|
window.addEventListener("keydown", onKey)
|
|
return () => window.removeEventListener("keydown", onKey)
|
|
}, [open, onClose])
|
|
|
|
const contents = (
|
|
<>
|
|
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-3">
|
|
{header}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="shrink-0 text-muted-foreground"
|
|
onClick={onClose}
|
|
aria-label="Close"
|
|
>
|
|
<X />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
|
{children}
|
|
</div>
|
|
|
|
{footer ? (
|
|
<div className="shrink-0 border-t border-border px-4 py-3">
|
|
{footer}
|
|
</div>
|
|
) : null}
|
|
</>
|
|
)
|
|
|
|
if (isMobile) {
|
|
return (
|
|
<Sheet open={open} onOpenChange={(next) => !next && onClose()}>
|
|
<SheetContent
|
|
side="right"
|
|
// The panel header carries its own close button, and opening should
|
|
// not drop the caret into the first field.
|
|
className="flex h-dvh w-full max-w-none flex-col gap-0 rounded-none p-0 [&>button:last-of-type]:hidden"
|
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
|
>
|
|
<SheetTitle className="sr-only">{label}</SheetTitle>
|
|
{open ? (
|
|
<div key={bodyKey} className="contents">
|
|
{contents}
|
|
</div>
|
|
) : null}
|
|
</SheetContent>
|
|
</Sheet>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{open ? (
|
|
<motion.aside
|
|
key={bodyKey}
|
|
variants={panelSlide}
|
|
initial="hidden"
|
|
animate="visible"
|
|
exit="exit"
|
|
role="complementary"
|
|
aria-label={label}
|
|
data-testid={testId}
|
|
data-expanded={expanded || undefined}
|
|
className={cn(
|
|
"pointer-events-auto absolute z-20 flex flex-col overflow-hidden border border-border bg-card/80 shadow-e2 backdrop-blur-md",
|
|
expanded
|
|
? // Fills the content region, which already starts after the sidebar.
|
|
"inset-0 rounded-none"
|
|
: "inset-y-4 right-4 w-[400px] rounded-lg",
|
|
)}
|
|
>
|
|
{contents}
|
|
</motion.aside>
|
|
) : null}
|
|
</AnimatePresence>
|
|
)
|
|
}
|