Flow canvas: label and edge popovers follow pan and zoom, the editors' dock is a full-width bar on a phone
- InputInspector hangs off the label's element and EdgeInspector off a point in flow coordinates (ViewportPortal), both re-measured every frame while open and hidden once the anchor leaves the view. A label that leaves the canvas closes its popover. - Dock: below md a bar across the width, flush with the bottom edge (bg-card, top border, safe-area inset, no shadow); from md the floating pill as before. The flow dock drops the Run caption and the dashboard editor clears the bar with its own padding, so the editing state fits one row from 360px. - Tests: the search spec checks the popover rides along with a pan; the mobile spec checks both docks are a full-width bar flush with the bottom.
This commit is contained in:
@@ -22,11 +22,14 @@ import { slideUp, transitions } from "@/lib/motion"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* The action bar floating at the bottom of an editor canvas, flow or dashboard.
|
||||
* The action bar at the bottom of an editor canvas, flow or dashboard.
|
||||
*
|
||||
* Capped and wrapping: the canvas shell clips, so an uncapped row would put the
|
||||
* buttons at its ends out of reach on a phone rather than merely look wrong.
|
||||
* See DESIGN-GUIDELINES.md → Responsive.
|
||||
* A phone has no room to float: there it is a bar across the full width, flush
|
||||
* with the bottom edge, with the buttons packed to fit one row. From `md` it is
|
||||
* a pill floating over the canvas, capped and wrapping, since the canvas shell
|
||||
* clips and an uncapped row would put the buttons at its ends out of reach.
|
||||
* The caller places it, with the same split. See DESIGN-GUIDELINES.md →
|
||||
* Responsive.
|
||||
*/
|
||||
export function Dock({
|
||||
className,
|
||||
@@ -43,7 +46,7 @@ export function Dock({
|
||||
exit="exit"
|
||||
transition={transitions.emphasized}
|
||||
className={cn(
|
||||
"pointer-events-auto flex w-max max-w-[calc(100vw-2rem)] flex-wrap items-center justify-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md pb-[max(0.25rem,env(safe-area-inset-bottom))]",
|
||||
"pointer-events-auto flex w-full flex-wrap items-center justify-around border-t border-border bg-card pt-1 pb-[max(0.25rem,env(safe-area-inset-bottom))] md:w-max md:max-w-[calc(100vw-2rem)] md:justify-center md:gap-1 md:rounded-full md:border md:bg-card/80 md:px-1.5 md:py-1 md:shadow-e2 md:backdrop-blur-md",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -801,10 +801,11 @@ export function DashboardEditor({
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: see above. */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 overflow-hidden px-4 pb-24 pt-20 transition-[padding] duration-200",
|
||||
// Clear of the dock: a bar on a phone, a pill from `md`.
|
||||
"absolute inset-0 overflow-hidden px-4 pb-[calc(3.5rem+env(safe-area-inset-bottom))] pt-20 transition-[padding] duration-200 md:pb-24",
|
||||
panelOpen && "md:pr-[27rem]",
|
||||
// Clear of the row of faces above the dock.
|
||||
edit && round && "pb-40",
|
||||
edit && round && "pb-40 md:pb-40",
|
||||
)}
|
||||
data-testid="dashboard-canvas"
|
||||
onClick={(event) => {
|
||||
@@ -835,7 +836,7 @@ export function DashboardEditor({
|
||||
arranging never mounts the lock, so the controls are live. */}
|
||||
{edit ? null : <LockNotice dashboard={draft} />}
|
||||
|
||||
<Dock className="absolute bottom-4 left-1/2 z-10 -translate-x-1/2">
|
||||
<Dock className="absolute inset-x-0 bottom-0 z-10 md:inset-x-auto md:bottom-4 md:left-1/2 md:-translate-x-1/2">
|
||||
{edit ? (
|
||||
<>
|
||||
{/* A canvas can be shrunk to less than what is arranged on it,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { ViewportPortal } from "@xyflow/react"
|
||||
import { ArrowRight, RotateCcw, Trash2 } from "lucide-react"
|
||||
|
||||
import { FlowsService } from "@/client"
|
||||
@@ -37,6 +38,7 @@ export type InspectedEdge = {
|
||||
to: string
|
||||
/** The producing node's id, which is what replaying the message goes through. */
|
||||
sourceId: string
|
||||
/** Where it was clicked, in flow coordinates, so it stays on the edge. */
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
@@ -75,12 +77,18 @@ export function EdgeInspector({
|
||||
|
||||
return (
|
||||
<Popover open onOpenChange={(open) => !open && onClose()}>
|
||||
<PopoverAnchor
|
||||
style={{ position: "fixed", left: edge.x, top: edge.y }}
|
||||
className="size-0"
|
||||
/>
|
||||
{/* In the viewport, so the pan and zoom carry it. Re-measured every
|
||||
frame while open, and out of sight once the point is. */}
|
||||
<ViewportPortal>
|
||||
<PopoverAnchor
|
||||
style={{ position: "absolute", left: edge.x, top: edge.y }}
|
||||
className="pointer-events-none size-0"
|
||||
/>
|
||||
</ViewportPortal>
|
||||
<PopoverContent
|
||||
align="center"
|
||||
updatePositionStrategy="always"
|
||||
hideWhenDetached
|
||||
className="w-72 p-3"
|
||||
data-testid="edge-inspector"
|
||||
>
|
||||
|
||||
@@ -61,8 +61,9 @@ export const FIT_VIEW_PANEL = {
|
||||
* saved, the flow's own settings, and putting it live or throwing it away —
|
||||
* so the top of the canvas is left to say which flow this is.
|
||||
*
|
||||
* It wraps rather than overflows, and drops the fit control on a phone; see
|
||||
* DESIGN-GUIDELINES.md → Responsive.
|
||||
* On a phone it is a bar across the bottom edge, which drops the fit control
|
||||
* and the caption on Run to leave the row room; otherwise it floats and wraps
|
||||
* rather than overflows. See DESIGN-GUIDELINES.md → Responsive.
|
||||
*/
|
||||
export function FlowDock({
|
||||
flow,
|
||||
@@ -121,7 +122,7 @@ export function FlowDock({
|
||||
// gives the two panels a row to share, instead of one landing on the other.
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute bottom-4 left-1/2 z-10 flex w-max max-w-[calc(100vw-2rem)] -translate-x-1/2 flex-col items-center",
|
||||
"pointer-events-none absolute inset-x-0 bottom-0 z-10 flex flex-col items-center md:inset-x-auto md:bottom-4 md:left-1/2 md:w-max md:max-w-[calc(100vw-2rem)] md:-translate-x-1/2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -178,6 +179,8 @@ export function FlowDock({
|
||||
variant="ghost"
|
||||
size="sm-touch"
|
||||
className={cn(
|
||||
// Narrower on a phone, where the bar is the width of the screen.
|
||||
"max-md:has-[>svg]:px-1.5",
|
||||
faults.length > 0
|
||||
? "text-destructive"
|
||||
: issuesOpen
|
||||
@@ -246,8 +249,11 @@ export function FlowDock({
|
||||
<Button
|
||||
variant="brand"
|
||||
size="sm-touch"
|
||||
// A phone's bar has the width for the glyph, not the caption.
|
||||
className="w-11 md:w-auto"
|
||||
onClick={onRun}
|
||||
disabled={running || !enabled}
|
||||
aria-label="Run"
|
||||
data-testid="run-flow"
|
||||
>
|
||||
{running ? (
|
||||
@@ -255,7 +261,7 @@ export function FlowDock({
|
||||
) : (
|
||||
<Play className="size-4" />
|
||||
)}
|
||||
Run
|
||||
<span className="hidden md:inline">Run</span>
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -270,7 +270,7 @@ function FlowEditorInner({
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const { showErrorToast, showSuccessToast } = useCustomToast()
|
||||
const { fitView } = useReactFlow()
|
||||
const { fitView, screenToFlowPosition } = useReactFlow()
|
||||
const updateNodeInternals = useUpdateNodeInternals()
|
||||
const { resolvedTheme } = useTheme()
|
||||
|
||||
@@ -565,8 +565,7 @@ function FlowEditorInner({
|
||||
)
|
||||
const label = document.querySelector(`.react-flow__node[data-id="${id}"]`)
|
||||
if (!declared || !label) return openEndpoint(id)
|
||||
const { left, top, width, height } = label.getBoundingClientRect()
|
||||
setInspectedInput({ declared, rect: { left, top, width, height } })
|
||||
setInspectedInput({ declared, label })
|
||||
},
|
||||
[flowName, openEndpoint],
|
||||
)
|
||||
@@ -748,6 +747,15 @@ function FlowEditorInner({
|
||||
[renderedNodes, externalNodes, positions],
|
||||
)
|
||||
|
||||
// A label that has left the canvas, because its input is gone or a dashboard
|
||||
// now accounts for it, leaves the popover nothing to hang off.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: the nodes are what take a label away.
|
||||
useEffect(() => {
|
||||
if (inspectedInput && !inspectedInput.label.isConnected) {
|
||||
setInspectedInput(null)
|
||||
}
|
||||
}, [shownNodes, inspectedInput])
|
||||
|
||||
// Editing ports adds and removes handles, and flipping direction moves them
|
||||
// to the other side. React Flow measures those once, so it has to be told,
|
||||
// or an edge to a brand-new handle never gets drawn.
|
||||
@@ -1399,8 +1407,7 @@ function FlowEditorInner({
|
||||
from: label(edge.source),
|
||||
to: label(edge.target),
|
||||
sourceId: edge.source,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
...screenToFlowPosition({ x: event.clientX, y: event.clientY }),
|
||||
})
|
||||
}}
|
||||
onConnect={onConnect}
|
||||
|
||||
@@ -13,8 +13,8 @@ import { ValuePreview } from "./ValuePreview"
|
||||
export type InspectedInput = {
|
||||
/** The declared input, as the flow document holds it. */
|
||||
declared: FlowInput_Input
|
||||
/** The label clicked, in viewport pixels, which the popover hangs off. */
|
||||
rect: { left: number; top: number; width: number; height: number }
|
||||
/** The label's element: the popover hangs off it wherever the canvas goes. */
|
||||
label: Element
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,13 +43,14 @@ export function InputInspector({
|
||||
|
||||
return (
|
||||
<Popover open onOpenChange={(open) => !open && onClose()}>
|
||||
<PopoverAnchor
|
||||
style={{ position: "fixed", ...input.rect }}
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
<PopoverAnchor virtualRef={{ current: input.label }} />
|
||||
{/* Re-measured every frame while open, so it rides along with a pan or a
|
||||
zoom, and out of sight once the label is. */}
|
||||
<PopoverContent
|
||||
align="center"
|
||||
collisionPadding={8}
|
||||
updatePositionStrategy="always"
|
||||
hideWhenDetached
|
||||
className="w-72 p-3"
|
||||
data-testid="input-inspector"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user