Name a port apart from its message in the node panel, two lines per port
The port name follows the message until it is typed by hand, and clearing it follows again. The second line holds the message, so a list port's item type no longer squeezes the name.
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { Activity, Maximize2, Minimize2, X } from "lucide-react"
|
||||
import {
|
||||
Activity,
|
||||
CornerDownRight,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
type ComponentProps,
|
||||
lazy,
|
||||
@@ -244,6 +250,14 @@ function MessageNameInput({
|
||||
)
|
||||
}
|
||||
|
||||
/** Why a port cannot be called `name`, or null when it can. */
|
||||
function portProblem(name: string, others: string[]): string | null {
|
||||
if (!IDENTIFIER.test(name))
|
||||
return "A port name is letters, digits and _, and does not start with a digit."
|
||||
if (others.includes(name)) return `Another port here is called ${name}.`
|
||||
return null
|
||||
}
|
||||
|
||||
function PortList({
|
||||
title,
|
||||
specs,
|
||||
@@ -252,6 +266,7 @@ function PortList({
|
||||
suggestions,
|
||||
onChange,
|
||||
onRenamed,
|
||||
onPortRenamed,
|
||||
onRemoved,
|
||||
streamable = false,
|
||||
}: {
|
||||
@@ -262,6 +277,8 @@ function PortList({
|
||||
suggestions: string[]
|
||||
onChange: (next: MessageSpec[]) => void
|
||||
onRenamed?: (previous: string, next: string) => void
|
||||
/** A port taking another name, reported just before the list that has it. */
|
||||
onPortRenamed?: (from: string, to: string) => void
|
||||
/** The port about to be dropped, reported just before the shorter list. */
|
||||
onRemoved?: (spec: MessageSpec) => void
|
||||
/** Outputs only: a port a node publishes on repeatedly while it runs. */
|
||||
@@ -269,6 +286,11 @@ function PortList({
|
||||
}) {
|
||||
// The port just added, so its name field can take focus.
|
||||
const [freshIndex, setFreshIndex] = useState<number | null>(null)
|
||||
// A port name is kept once its field is left, so what is typed lives here
|
||||
// until then. Only one field has focus at a time, so one draft is enough.
|
||||
const [naming, setNaming] = useState<{ index: number; text: string } | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
const update = (index: number, patch: Partial<MessageSpec>) => {
|
||||
const next = specs.map((spec, i) =>
|
||||
@@ -298,116 +320,162 @@ function PortList({
|
||||
<p className="text-sm text-muted-foreground">{emptyHint}</p>
|
||||
) : null}
|
||||
|
||||
{specs.map((spec, index) => (
|
||||
// The curve reads as its own thing rather than as part of the row
|
||||
// above it, so it gets a little air.
|
||||
<div key={`port-${index}`} className="grid min-w-0 gap-1.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<MessageNameInput
|
||||
value={spec.name ?? ""}
|
||||
suggestions={suggestions}
|
||||
placeholder={`name in ${flow}`}
|
||||
autoFocus={index === freshIndex}
|
||||
onChange={(name) => update(index, { name, port: "" })}
|
||||
onRenamed={onRenamed}
|
||||
/>
|
||||
<Select
|
||||
value={spec.dtype ?? "float"}
|
||||
onValueChange={(value) =>
|
||||
update(index, { dtype: value as DType })
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="!h-8 w-[104px] text-sm"
|
||||
aria-label="Type"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DTYPES.map((dtype) => (
|
||||
<SelectItem key={dtype} value={dtype}>
|
||||
{dtype}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{spec.dtype === "list" ? (
|
||||
{specs.map((spec, index) => {
|
||||
// A port follows its message until it is named by hand; the field
|
||||
// then holds that name, and is empty while there is none.
|
||||
const own = ownsName(spec) ? (spec.port ?? "") : ""
|
||||
const typed = naming?.index === index ? naming.text.trim() : null
|
||||
const problem = typed
|
||||
? portProblem(
|
||||
typed,
|
||||
specs.filter((_, i) => i !== index).map(portName),
|
||||
)
|
||||
: null
|
||||
return (
|
||||
// The curve reads as its own thing rather than as part of the row
|
||||
// above it, so it gets a little air.
|
||||
<div key={`port-${index}`} className="grid min-w-0 gap-1.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Input
|
||||
value={naming?.index === index ? naming.text : own}
|
||||
placeholder={portName({ name: spec.name }) || "port"}
|
||||
aria-label="Port name"
|
||||
aria-invalid={Boolean(problem)}
|
||||
title="The name the node sees this port by. Empty follows the message."
|
||||
className="h-8 flex-1 font-mono text-sm"
|
||||
onChange={(event) =>
|
||||
setNaming({ index, text: event.target.value })
|
||||
}
|
||||
onBlur={() => {
|
||||
if (typed !== null && !problem && typed !== own) {
|
||||
const to = typed || portName({ name: spec.name })
|
||||
if (to !== portName(spec))
|
||||
onPortRenamed?.(portName(spec), to)
|
||||
update(index, { port: typed })
|
||||
}
|
||||
setNaming(null)
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
value={spec.item ?? "record"}
|
||||
value={spec.dtype ?? "float"}
|
||||
onValueChange={(value) =>
|
||||
update(index, { item: value as DType })
|
||||
update(index, { dtype: value as DType })
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="!h-8 w-[92px] text-sm"
|
||||
aria-label="Item type"
|
||||
title="What each item of the list is"
|
||||
className="!h-8 w-[104px] text-sm"
|
||||
aria-label="Type"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ITEM_DTYPES.map((dtype) => (
|
||||
{DTYPES.map((dtype) => (
|
||||
<SelectItem key={dtype} value={dtype}>
|
||||
{dtype}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={spec.interval ? String(spec.interval) : ""}
|
||||
placeholder="∞"
|
||||
aria-label="Deliver at most every n seconds"
|
||||
title="Deliver at most every n seconds; empty is every time"
|
||||
className="h-8 w-14 text-sm"
|
||||
onChange={(event) =>
|
||||
update(index, { interval: Number(event.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
{streamable ? (
|
||||
{spec.dtype === "list" ? (
|
||||
<Select
|
||||
value={spec.item ?? "record"}
|
||||
onValueChange={(value) =>
|
||||
update(index, { item: value as DType })
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="!h-8 w-[92px] text-sm"
|
||||
aria-label="Item type"
|
||||
title="What each item of the list is"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ITEM_DTYPES.map((dtype) => (
|
||||
<SelectItem key={dtype} value={dtype}>
|
||||
{dtype}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
<Button
|
||||
variant={spec.stream ? "secondary" : "ghost"}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Streaming output"
|
||||
aria-pressed={spec.stream ?? false}
|
||||
title={
|
||||
"Published repeatedly while the node runs: a curve rather than " +
|
||||
"a result. A run keeps every value it takes."
|
||||
}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
!spec.stream && "text-muted-foreground",
|
||||
)}
|
||||
onClick={() => update(index, { stream: !spec.stream })}
|
||||
className="text-muted-foreground"
|
||||
aria-label="Remove port"
|
||||
onClick={() => {
|
||||
onRemoved?.(spec)
|
||||
onChange(specs.filter((_, i) => i !== index))
|
||||
}}
|
||||
>
|
||||
<Activity />
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<CornerDownRight
|
||||
aria-hidden
|
||||
className="size-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<MessageNameInput
|
||||
value={spec.name ?? ""}
|
||||
suggestions={suggestions}
|
||||
placeholder={`message in ${flow}`}
|
||||
autoFocus={index === freshIndex}
|
||||
onChange={(name) =>
|
||||
update(index, {
|
||||
name,
|
||||
port: ownsName(spec) ? spec.port : "",
|
||||
})
|
||||
}
|
||||
onRenamed={onRenamed}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={spec.interval ? String(spec.interval) : ""}
|
||||
placeholder="∞"
|
||||
aria-label="Deliver at most every n seconds"
|
||||
title="Deliver at most every n seconds; empty is every time"
|
||||
className="h-8 w-14 text-sm"
|
||||
onChange={(event) =>
|
||||
update(index, { interval: Number(event.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
{streamable ? (
|
||||
<Button
|
||||
variant={spec.stream ? "secondary" : "ghost"}
|
||||
size="icon-sm"
|
||||
aria-label="Streaming output"
|
||||
aria-pressed={spec.stream ?? false}
|
||||
title={
|
||||
"Published repeatedly while the node runs: a curve rather than " +
|
||||
"a result. A run keeps every value it takes."
|
||||
}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
!spec.stream && "text-muted-foreground",
|
||||
)}
|
||||
onClick={() => update(index, { stream: !spec.stream })}
|
||||
>
|
||||
<Activity />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{problem ? (
|
||||
<p className="text-xs text-destructive">{problem}</p>
|
||||
) : null}
|
||||
{spec.name ? (
|
||||
<MessageSparkline
|
||||
flow={flow}
|
||||
name={spec.name}
|
||||
dtype={spec.dtype ?? undefined}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
aria-label="Remove port"
|
||||
onClick={() => {
|
||||
onRemoved?.(spec)
|
||||
onChange(specs.filter((_, i) => i !== index))
|
||||
}}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
{spec.name ? (
|
||||
<MessageSparkline
|
||||
flow={flow}
|
||||
name={spec.name}
|
||||
dtype={spec.dtype ?? undefined}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1129,6 +1197,13 @@ const SCAFFOLD_SHAPE = (() => {
|
||||
const portName = (spec: MessageSpec) =>
|
||||
spec.port || (spec.name ?? "").split(".").pop() || ""
|
||||
|
||||
/**
|
||||
* Whether a port was named by hand rather than after its message. Nothing
|
||||
* stores that: a port whose name is not the one its message gives it is one.
|
||||
*/
|
||||
const ownsName = (spec: MessageSpec) =>
|
||||
Boolean(spec.port) && spec.port !== portName({ name: spec.name })
|
||||
|
||||
/**
|
||||
* A `process` that takes this node's inputs and settings, and returns its
|
||||
* outputs.
|
||||
@@ -1189,9 +1264,10 @@ function PanelBody({
|
||||
})
|
||||
|
||||
const [code, setCode] = useState<string | null>(null)
|
||||
// The port the X is dropping, noted just before the shorter list arrives:
|
||||
// both are one edit, and a second one would only overwrite the first.
|
||||
const dropped = useRef<string | null>(null)
|
||||
// The port the X is dropping or its name field renaming, noted just before
|
||||
// the list that says so arrives: both are one edit, and a second one would
|
||||
// only overwrite the first.
|
||||
const portMove = useRef<[string, string | null] | null>(null)
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pending = useRef<string | null>(null)
|
||||
const save = useRef(onSaveSource)
|
||||
@@ -1248,6 +1324,9 @@ function PanelBody({
|
||||
|
||||
const renamePort = (previous: string, next: string) => {
|
||||
onRenameMessage(previous, next)
|
||||
// A port named by hand keeps its name through a new message.
|
||||
const spec = node.provides?.find((entry) => entry.name === next)
|
||||
if (spec && ownsName(spec)) return
|
||||
// Only a finished rename moves a value; per keystroke it would delete it
|
||||
// on the first character typed.
|
||||
const moved = repoint(
|
||||
@@ -1309,16 +1388,19 @@ function PanelBody({
|
||||
emptyHint="Nothing yet. Add a message this node publishes."
|
||||
suggestions={suggestions.provides}
|
||||
onChange={(provides) => {
|
||||
const port = dropped.current
|
||||
dropped.current = null
|
||||
editNode(repoint({ ...node, provides }, port ?? "", null))
|
||||
const [from, to] = portMove.current ?? ["", null]
|
||||
portMove.current = null
|
||||
editNode(repoint({ ...node, provides }, from, to))
|
||||
}}
|
||||
streamable
|
||||
// Only the publishing side names a message; an input is as often
|
||||
// re-pointed at a different one as it is renamed.
|
||||
onRenamed={renamePort}
|
||||
onPortRenamed={(from, to) => {
|
||||
portMove.current = [from, to]
|
||||
}}
|
||||
onRemoved={(spec) => {
|
||||
dropped.current = portName(spec)
|
||||
portMove.current = [portName(spec), null]
|
||||
}}
|
||||
/>
|
||||
<ParamsForm
|
||||
|
||||
Reference in New Issue
Block a user