Copy nodes, bind the keys, and keep publish within reach
The floating bars used to make way for a side panel, which hid Publish exactly when a node had just been edited. They keep their lane now and the panel is inset out of it; the enlarged code editor stays a floating surface between the two bars rather than covering them. Shortcuts are one small registry (`lib/shortcuts.ts`): undo, redo, ⌘K, copy, paste, and ⌘S — which publishes the flow, or applies the code when the editor has focus. Copying carries a node's own source through localStorage, so a paste works in another flow too. A flow is now named where a node is, at the top of its panel and only there, and both take effect on Confirm. Flow-level edits go through the undo stack with everything else, clicking the canvas puts the flow panel away, a failing node opens the logs filtered to its own traceback, and the panel carries its test id on a phone as well. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { X } from "lucide-react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import type { ReactNode } from "react"
|
||||
import { useEffect } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
|
||||
import { useIsMobile } from "@/hooks/useMobile"
|
||||
import { duration, easeEmphasized, easeStandard } from "@/lib/motion"
|
||||
@@ -27,6 +28,58 @@ const panelSlide = {
|
||||
export const PANEL_SECTION =
|
||||
"text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground"
|
||||
|
||||
/**
|
||||
* What the thing in this panel is called, and the one place to rename it.
|
||||
*
|
||||
* Renaming is an explicit act: the field keeps what is typed until it is
|
||||
* confirmed, so a half-typed name never reaches the canvas.
|
||||
*/
|
||||
export function PanelTitle({
|
||||
value,
|
||||
placeholder,
|
||||
label,
|
||||
onConfirm,
|
||||
}: {
|
||||
value: string
|
||||
placeholder?: string
|
||||
/** Names the field for screen readers. */
|
||||
label: string
|
||||
onConfirm: (next: string) => void
|
||||
}) {
|
||||
const [draft, setDraft] = useState(value)
|
||||
// Another thing to name is another field.
|
||||
useEffect(() => setDraft(value), [value])
|
||||
|
||||
const next = draft.trim()
|
||||
const changed = next !== value
|
||||
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
value={draft}
|
||||
placeholder={placeholder}
|
||||
aria-label={label}
|
||||
autoComplete="off"
|
||||
className="h-8 min-w-0 flex-1 text-sm font-medium"
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && changed) onConfirm(next)
|
||||
}}
|
||||
/>
|
||||
{changed ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 shrink-0"
|
||||
onClick={() => onConfirm(next)}
|
||||
data-testid="confirm-rename"
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -51,7 +104,7 @@ export function SidePanel({
|
||||
testId: string
|
||||
/** Remounts the contents when the thing being edited changes. */
|
||||
bodyKey: string
|
||||
/** Fill the content area instead of floating beside the canvas. */
|
||||
/** Take the width a code editor needs, still floating over the canvas. */
|
||||
expanded?: boolean
|
||||
header: ReactNode
|
||||
footer?: ReactNode
|
||||
@@ -101,6 +154,7 @@ export function SidePanel({
|
||||
<Sheet open={open} onOpenChange={(next) => !next && onClose()}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
data-testid={testId}
|
||||
// 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"
|
||||
@@ -131,11 +185,12 @@ export function SidePanel({
|
||||
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",
|
||||
"pointer-events-auto absolute z-20 flex flex-col overflow-hidden rounded-lg 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",
|
||||
? // Still a floating surface, only given the room code needs —
|
||||
// and the canvas chrome keeps its lanes above and below.
|
||||
"inset-x-4 bottom-16 top-16"
|
||||
: "inset-y-4 right-4 w-[400px]",
|
||||
)}
|
||||
>
|
||||
{contents}
|
||||
|
||||
Reference in New Issue
Block a user