Frames and groups
Put entities inside a frame to group them, collapse a frame to hide what it holds, and fold a set of nodes into one group node with ports.
A frame is an entity that holds other entities. You put an entity in a frame by setting its
parentId to the frame's id. Collapsing the frame hides everything inside it. Folding a set of
wired nodes into a group node puts a frame around them and moves each wire that crosses into or out
of the set onto a port on that frame.
Put entities in a frame
Give the frame type: 'frame', a width and a height. Then set parentId on each entity that
belongs inside it.
pipeline.tsximport type { Entity } from '@kushagradhawan/kookie-flow';const entities: Entity[] = [ // The frame comes first, so it paints behind the entities it holds. { id: 'prep', type: 'frame', position: { x: 400, y: 0 }, width: 600, height: 220, data: { label: 'Prepare' } }, { id: 'load', type: 'default', parentId: 'prep', position: { x: 432, y: 64 }, data: { label: 'Load' }, outputs: [{ id: 'image', name: 'Image', type: 'image' }], }, { id: 'resize', type: 'default', parentId: 'prep', position: { x: 728, y: 64 }, data: { label: 'Resize' }, inputs: [{ id: 'image', name: 'Image', type: 'image' }], },];A child's position is a point on the canvas, the same as any other entity's. It isn't an offset
from the frame, which is why the children above start at x: 432 inside a frame at x: 400.
parentId records that an entity belongs to a frame. It doesn't keep the entity inside the
frame's rectangle, and a child placed outside the frame still belongs to it.
Entities paint in array order when they first appear, so list a frame before its children. When someone presses a frame, the frame comes to the front and its children stay above it. While a frame is selected, though, it draws above every unselected entity, its own children included, so a solid frame body covers them until you select something else.
A frame is drawn as a node body with its label as the title, so the options in Entity styling apply to it. It can be resized down to 100 × 60.
Moving and deleting a frame
Dragging moves only what is selected. A frame dragged on its own leaves its children where they
are, so select the children with the frame to move them together. Lining up a selection with
alignSelection or distributeSelection moves a frame together with its contents, as
Layout and alignment describes.
Deleting a frame removes the frame alone. Its children stay on the canvas.
Frame fields
| Field | Type | Default | Meaning |
|---|---|---|---|
type | 'frame' | none | Makes the entity a frame. |
collapsed | boolean | false | Hides every entity inside the frame. |
extent | 'auto' | 'fixed' | none | Accepted, but the canvas doesn't read it. A frame never resizes itself. |
data.label | string | none | The title drawn at the top of the frame. |
data.description | string | none | Accepted, but not drawn. |
data.backgroundColor | string | none | Accepted, but not drawn. Use Entity styling. |
data.borderColor | string | none | Accepted, but not drawn. |
FrameEntity is Entity<FrameEntityData> with type: 'frame', and FrameEntityData is the
data shape in the table.
Size a frame to its children
A frame keeps the size you give it. When a child is dragged past the edge, it hangs outside the
frame. To fit the frame around its children, measure them with getGroupBounds on the
ref, and pass the result to onEntitiesChange as a move and a resize.
fit-frame.tsimport type { EntityChange, KookieFlowInstance } from '@kushagradhawan/kookie-flow';// Room above the children for the frame's title.const TITLE_ROOM = 40;export function fitFrame(flow: KookieFlowInstance, frameId: string): EntityChange[] { const bounds = flow.getGroupBounds(frameId); if (!bounds) return []; // The frame has no children. return [ { type: 'position', id: frameId, position: { x: bounds.x, y: bounds.y - TITLE_ROOM } }, { type: 'dimensions', id: frameId, dimensions: { width: bounds.width, height: bounds.height + TITLE_ROOM } }, ];}// if (flowRef.current) onEntitiesChange(fitFrame(flowRef.current, 'prep'));getGroupBounds measures a box around the frame's direct children and adds 24 units on every
side. The result is at least 100 wide and 80 tall. The padding doesn't include a title, so without
extra room the title can overlap a child at the top.
Nest frames
A frame can hold another frame. Set the inner frame's parentId to the outer frame's id, and set
each inner child's parentId to the inner frame.
Collapsing the outer frame hides the inner frame and everything in it. Collapsing the inner frame hides only what the inner frame holds.
Two ref methods list what a frame holds. getGroupChildren returns the entities whose parentId
is the frame. getGroupDescendants also returns their children, and their children's children,
at every depth. To move a frame and everything in it from code, move each descendant by the same
distance as the frame, and report the moves through onEntitiesChange. When the canvas's store is
the only copy of your graph, the store's moveGroup does this in one call, as the
store chapter describes.
Collapse and expand a frame
Set collapsed: true on a frame to hide what it holds, and set it back to false to show it
again. The frame stays where it is, at the size it has.
While a frame is collapsed, every entity inside it at any depth is hidden. A hidden entity isn't
drawn and can't be pressed, and a video inside a collapsed frame pauses. Wires that touch a hidden
entity aren't drawn either, including wires that cross the frame's edge to an entity outside it.
Hidden entities are still part of the graph: getEntities() and toObject() return them.
The canvas has no control that collapses a frame, so add your own. This example writes
collapsed into the graph's state from a button:
Arrow keys move between nodes. Press Enter to reach the focused node's controls, and Escape to come back here.
'use client';import { useEffect, useRef } from 'react';import { Button, Flex, Stack } from '@kushagradhawan/kookie-ui-react';import { KookieFlow, useGraph, type Entity, type KookieFlowInstance } from '@kushagradhawan/kookie-flow';const initialEntities: Entity[] = [ // First in the array, so the frame paints behind the entities it holds. { id: 'stage', type: 'frame', position: { x: 0, y: 0 }, width: 600, height: 220, data: { label: 'Stage' } }, { id: 'load', type: 'default', parentId: 'stage', position: { x: 32, y: 64 }, data: { label: 'Load' }, outputs: [{ id: 'image', name: 'Image', type: 'image' }], }, { id: 'blur', type: 'default', parentId: 'stage', position: { x: 328, y: 64 }, data: { label: 'Blur' }, inputs: [{ id: 'image', name: 'Image', type: 'image' }], },];export default function FramesExample() { const { entities, edges, setEntities, onEntitiesChange, onEdgesChange, onConnect } = useGraph({ initialEntities, initialEdges: [{ id: 'e1', source: 'load', sourceSocket: 'image', target: 'blur', targetSocket: 'image' }], }); const flowRef = useRef<KookieFlowInstance>(null); useEffect(() => { flowRef.current?.fitView({ padding: 40, maxZoom: 1 }); }, []); const toggle = () => { // Ask the canvas, then write the answer into your own state so the next render keeps it. const collapsed = !flowRef.current?.isGroupCollapsed('stage'); setEntities((all) => all.map((e) => (e.id === 'stage' ? { ...e, collapsed } : e))); }; return ( <Stack gap="3"> <Flex gap="2"> <Button size="2" onClick={toggle}>Collapse or expand</Button> </Flex> <div style={{ width: '100%', height: 360 }}> <KookieFlow ref={flowRef} entities={entities} edges={edges} onEntitiesChange={onEntitiesChange} onEdgesChange={onEdgesChange} onConnect={onConnect} /> </div> </Stack> );}Grouping methods on the ref
| Method | Returns | Reports the change | Meaning |
|---|---|---|---|
getGroupChildren(groupId) | Entity[] | – | The entities whose parentId is groupId. |
getGroupDescendants(groupId) | Entity[] | – | The children, their children, and so on at every depth. |
getGroupBounds(groupId) | { x, y, width, height } | null | – | A box around the direct children, or null when there are none. |
isGroupCollapsed(groupId) | boolean | – | Whether the canvas is hiding the frame's contents right now. |
collapseGroup(groupId) | void | No | Hides the contents. Does nothing if they're already hidden. |
expandGroup(groupId) | void | No | Shows the contents. Does nothing if they're already shown. |
toggleGroupCollapse(groupId) | void | No | Collapses an expanded frame or expands a collapsed one. Ignores an id that isn't a frame. |
collapseToSubgraph(entityIds, groupId) | void | Yes | Folds the entities into a group node. See the next section. |
expandSubgraph(groupId, childEntities, internalEdges, portMapping) | void | Yes | Unfolds a group node. See the next section. |
The three methods that report nothing change what the canvas shows and leave your state as it was. Keep a controlled graph in step explains what that means for your code.
Fold nodes into a group node
collapseToSubgraph(entityIds, groupId) turns a set of wired nodes into one node with ports. It
makes these changes:
- A frame. It has the id
groupIdand the labelGroup, and it's sized around the entities with 40 units to spare on every side. - New parents. Each listed entity's
parentIdbecomes the new frame. The entities stay on the canvas, inside the frame, and leave any frame they were in before. - No inside wires. Every wire that runs between two listed entities is removed.
- Ports. The frame gets one input socket for each socket inside that a wire from outside
reaches, and one output socket for each socket inside that feeds something outside. Each port
takes the name and type of the socket it stands for. Input ids are
gin-0,gin-1, and so on, and output ids aregout-0,gout-1, and so on. - Rewired edges. Each wire that crossed the boundary is replaced by a wire to or from the matching port.
The new frame isn't collapsed. Set collapsed: true on it to show the group as a single node.
expandSubgraph(groupId, childEntities, internalEdges, portMapping) reverses the fold. It removes
the frame and every wire on its ports, clears parentId on the entities it held, and adds
internalEdges back. Each wire that was on a port is reconnected to the socket inside that
portMapping names, and a wire on a port that portMapping doesn't list is dropped.
collapseToSubgraph returns nothing and deletes the wires between the folded entities, so record
what you need to unfold before you fold. computeCollapseToSubgraph runs the same calculation
without changing anything, and Graph queries covers it.
fold.tsimport { buildAdjacencyIndex, computeCollapseToSubgraph, type Edge, type KookieFlowInstance,} from '@kushagradhawan/kookie-flow';type PortMapping = Parameters<KookieFlowInstance['expandSubgraph']>[3];export type Fold = { groupId: string; internalEdges: Edge[]; portMapping: PortMapping };type Port = { id: string; originalEntityId: string; originalSocketId: string };const toMapping = ({ id, originalEntityId, originalSocketId }: Port) => ({ framePortId: id, originalEntityId, originalSocketId });export function fold(flow: KookieFlowInstance, entityIds: string[], groupId: string): Fold { const edges = flow.getEdges(); const plan = computeCollapseToSubgraph(entityIds, groupId, flow.getEntities(), buildAdjacencyIndex(edges)); const inside = new Set(entityIds); flow.collapseToSubgraph(entityIds, groupId); return { groupId, internalEdges: edges.filter((edge) => inside.has(edge.source) && inside.has(edge.target)), portMapping: { inputs: plan.frameInputs.map(toMapping), outputs: plan.frameOutputs.map(toMapping) }, };}export function unfold(flow: KookieFlowInstance, { groupId, internalEdges, portMapping }: Fold) { // The folded entities never left the canvas, so there are none to add back. flow.expandSubgraph(groupId, [], internalEdges, portMapping);}Pass entities in childEntities only when you removed them from the graph after folding. The
entities collapseToSubgraph parented are still in the graph, and passing them again adds a
second copy of each.
Keep a controlled graph in step
With useGraph, your state holds the graph and the canvas draws a copy of it. Whenever your
entities array changes, the canvas rebuilds its copy from your array, including which frames are
collapsed. The Controlled and uncontrolled state chapter covers this arrangement.
collapseGroup, expandGroup, and toggleGroupCollapse change only the canvas's copy and report
nothing. A frame collapsed this way opens again the next time your entities change, which a single
drag is enough to cause. onEntitiesChange from useGraph doesn't apply a collapse change
either. Write collapsed into your own state instead:
const setCollapsed = (frameId: string, collapsed: boolean) => setEntities((all) => all.map((e) => (e.id === frameId ? { ...e, collapsed } : e)));collapseToSubgraph and expandSubgraph report what they change, as a drag does. Your
onEntitiesChange receives add, remove, and parent changes, and your onEdgesChange receives
add and remove changes, so a graph managed by useGraph keeps the result with no extra code.