diff --git a/frontend/src/components/Common/Dock.tsx b/frontend/src/components/Common/Dock.tsx index ae2c505..b1b62c0 100644 --- a/frontend/src/components/Common/Dock.tsx +++ b/frontend/src/components/Common/Dock.tsx @@ -43,7 +43,7 @@ export function Dock({ exit="exit" transition={transitions.emphasized} className={cn( - "pointer-events-auto flex 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-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))]", className, )} > diff --git a/frontend/src/components/Flow/FlowDock.tsx b/frontend/src/components/Flow/FlowDock.tsx index c477f67..0d404df 100644 --- a/frontend/src/components/Flow/FlowDock.tsx +++ b/frontend/src/components/Flow/FlowDock.tsx @@ -121,7 +121,7 @@ export function FlowDock({ // gives the two panels a row to share, instead of one landing on the other.
diff --git a/frontend/src/components/Flow/FlowEditor.tsx b/frontend/src/components/Flow/FlowEditor.tsx index c5547aa..bee7b12 100644 --- a/frontend/src/components/Flow/FlowEditor.tsx +++ b/frontend/src/components/Flow/FlowEditor.tsx @@ -553,6 +553,24 @@ function FlowEditorInner({ [navigate, flowName], ) + /** + * Read an input where its label sits: what it is declared as and what it + * holds now. With no label to anchor to it opens the flow settings, where + * inputs are declared. + */ + const inspectInput = useCallback( + (id: string) => { + const declared = (latest.current.inputs ?? []).find( + (one) => `input:${qualify(flowName, one.spec?.name ?? "")}` === id, + ) + 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 } }) + }, + [flowName, openEndpoint], + ) + // React Flow measures a node once and keeps the size on it. An endpoint is // not in `canvasNodes`, so the measurement it reports back has nowhere to // land: without carrying it over by hand the endpoint arrives unmeasured on @@ -741,6 +759,14 @@ function FlowEditorInner({ ]) }, [key, direction, external, updateNodeInternals]) + // An input the canvas draws is read where its label sits, as clicking it + // does, so arriving on one waits for the layout to place the label. + const arrival = + focus?.startsWith("input:") && external.nodes.some((n) => n.id === focus) + ? focus + : null + const arrived = useRef(null) + // The canvas is not remounted per node — that would throw the session away // on every click in the brain graph — so arriving at a flow already open // only changes the address. Seeded above for the first arrival, set here for @@ -753,10 +779,10 @@ function FlowEditorInner({ if (landsOn(focus)) { setFlowPanelOpen(false) setSelectedId(focus) - } else { + } else if (!arrival) { openEndpoint(focus) } - }, [focus, openEndpoint]) + }, [focus, arrival, openEndpoint]) const selected = definitions.find((node) => node.id === selectedId) ?? null // A panel is the view you are working in, so it takes the room — but never @@ -782,7 +808,10 @@ function FlowEditorInner({ // selection on the early returns is what fitted the whole flow instead. // Another flow's label is centred once it has been found on this canvas. const target = selectedId?.startsWith("flow:") ? landing : selectedId - const focus = target && selectedId !== centred.current ? target : null + // A label found by the search is centred, and read once it is there. + const label = arrival && arrival !== arrived.current ? arrival : null + const focus = + label ?? (target && selectedId !== centred.current ? target : null) // A phone's sheet covers the canvas outright, and so does the expanded // editor: there is no viewport to aim. if (editorExpanded) return @@ -792,16 +821,23 @@ function FlowEditorInner({ const frame = requestAnimationFrame(() => { const view = panelOpen && !isMobile ? FIT_VIEW_PANEL : FIT_VIEW const duration = fitted.current ? 300 : 0 - fitView( + const fit = fitView( focus ? { ...view, nodes: [{ id: focus }], duration } : { ...view, duration }, ) fitted.current = true centred.current = selectedId + if (label) { + arrived.current = label + // The frame lets the canvas draw the view it was just given. + void fit.then(() => requestAnimationFrame(() => inspectInput(label))) + } }) return () => cancelAnimationFrame(frame) }, [ + arrival, + inspectInput, direction, definitions.length, external.nodes.length, @@ -1326,26 +1362,11 @@ function FlowEditorInner({ onNodesDelete={(deleted) => deleteNodes(deleted.filter(isDocumentNode).map((node) => node.id)) } - onNodeClick={(event, node) => { + onNodeClick={(_, node) => { // An endpoint is somewhere else's: opening its panel here would // offer to edit a node this flow does not contain. // An input is read where it sits; editing it is one press on. - const declared = node.id.startsWith("input:") - ? (flowDoc.inputs ?? []).find( - (one) => - `input:${qualify(flowName, one.spec?.name ?? "")}` === - node.id, - ) - : undefined - if (declared) { - const { left, top, width, height } = - event.currentTarget.getBoundingClientRect() - setInspectedInput({ - declared, - rect: { left, top, width, height }, - }) - return - } + if (node.id.startsWith("input:")) return inspectInput(node.id) if (!isDocumentNode(node)) { const wire = edges.find( (edge) => edge.source === node.id || edge.target === node.id, diff --git a/frontend/src/components/Flow/LiveEdge.tsx b/frontend/src/components/Flow/LiveEdge.tsx index 527c1ad..798da4f 100644 --- a/frontend/src/components/Flow/LiveEdge.tsx +++ b/frontend/src/components/Flow/LiveEdge.tsx @@ -210,6 +210,7 @@ function LiveEdgeComponent({ return at(source, route.from) && at(target, route.to) }) const [pulsing, setPulsing] = useState(false) + const lit = useRef(null) const lastTs = useRef(null) // Owned by the pulse rather than by the effect: a value from another // producer re-runs the effect too, and must not cancel a reset it does not @@ -259,18 +260,29 @@ function LiveEdgeComponent({ // No source at all is an older engine; pulse rather than go silent. if (from && from.id !== producerId) return setPulsing(true) + // A CSS animation starts when its class arrives, and while a pulse is still + // running the class is already on, so a second value within it would show + // nothing. Start the running one over by hand. + for (const animation of lit.current?.getAnimations({ subtree: true }) ?? + []) { + if ((animation as CSSAnimation).animationName === "edge-pulse") { + animation.currentTime = 0 + } + } clearTimeout(timer.current) timer.current = setTimeout(() => setPulsing(false), duration.pulse * 1000) }, [live?.ts, live?.source, live?.prior, producerId]) return ( <> - + + + {live !== undefined && zoom >= CHIP_MIN_ZOOM ? (
{ `${id} runs off the right edge`, ).toBeLessThanOrEqual(width) } + + // It is centred on `left-1/2`, where a shrink-wrapped box gets only the half + // of the screen to the right of that, and wraps into a pill as tall as it is + // wide. Whatever the buttons need, it is never confined to that half. + const dock = await page.getByTestId("add-node").locator("..").boundingBox() + expect( + dock!.width, + "the dock is squeezed into half the screen", + ).toBeGreaterThan((page.viewportSize()?.width ?? 0) / 2) }) test("a dashboard stacks instead of shrinking", async ({ page }) => { diff --git a/frontend/tests/search.spec.ts b/frontend/tests/search.spec.ts index 6d93d39..731dcf5 100644 --- a/frontend/tests/search.spec.ts +++ b/frontend/tests/search.spec.ts @@ -42,7 +42,7 @@ test("finds a node by name and opens its flow on it", async ({ page }) => { await expect(page).toHaveURL(new RegExp(`/flows/${flowName}\\?node=findme$`)) }) -test("finds a flow input and opens the settings declaring it", async ({ +test("finds a flow input and reads it where its label sits", async ({ page, }) => { await page.goto("/") @@ -54,6 +54,10 @@ test("finds a flow input and opens the settings declaring it", async ({ await expect(page).toHaveURL( new RegExp(`/flows/${flowName}\\?node=input%3A${flowName}\\.findme_input$`), ) + // What clicking the label shows, and the settings one press on. + await expect(page.getByTestId("input-inspector")).toBeVisible() + await expect(page.getByTestId("flow-panel")).toBeHidden() + await page.getByTestId("edit-input").click() await expect(page.getByTestId("flow-panel")).toBeVisible() })