Clipboard and cloning
Copy, cut and paste a selection with useClipboard, or clone entities yourself to duplicate them and to copy a graph between tabs.
useClipboard copies, cuts and pastes whatever is selected on the canvas. A paste adds copies of
the entities and the edges between them, each with a new id, so you can paste the same selection
as many times as you like.
A paste is made from two store functions, cloneElements and addElements. You can call them
yourself to duplicate a selection without touching the clipboard, or to copy a graph between
browser tabs.
Copy, cut and paste a selection
Import useClipboard from the plugins entry, and call it in a component that you render inside
<KookieFlow>. The hook reads the canvas's store from context, so it throws an error anywhere
else.
flow-editor.tsx'use client';import { KookieFlow, type Edge, type Entity } from '@kushagradhawan/kookie-flow';import { useClipboard, useKeyboardShortcuts } from '@kushagradhawan/kookie-flow/plugins';const entities: Entity[] = [ { id: 'source', type: 'default', position: { x: 0, y: 0 }, data: { label: 'Source' }, outputs: [{ id: 'out', name: 'Value', type: 'float' }], }, { id: 'scale', type: 'default', position: { x: 280, y: 0 }, data: { label: 'Scale' }, inputs: [{ id: 'in', name: 'Value', type: 'float' }], },];const edges: Edge[] = [ { id: 'source-scale', source: 'source', sourceSocket: 'out', target: 'scale', targetSocket: 'in' },];function ClipboardShortcuts() { const { copy, cut, paste } = useClipboard(); useKeyboardShortcuts({ bindings: { 'mod+c': copy, 'mod+x': cut, 'mod+v': () => paste() }, }); return null;}export function FlowEditor() { return ( <div style={{ height: 480 }}> <KookieFlow entities={entities} edges={edges}> <ClipboardShortcuts /> </KookieFlow> </div> );}The canvas has no clipboard keys of its own, so ClipboardShortcuts binds them and renders
nothing. paste is wrapped so that it's called with no arguments, rather than with the keyboard
event.
useClipboard returns four functions, typed as UseClipboardReturn:
| Name | Type | What it does |
|---|---|---|
copy | () => void | Puts the selected entities, and every edge attached to them, on the clipboard. |
cut | () => void | Copies, then deletes the selected entities and edges. |
paste | (options?: PasteOptions) => CloneElementsResult | null | Adds copies of what's on the clipboard, selects them, and returns them. Returns null when the clipboard is empty. |
hasClipboardContent | () => boolean | Whether the clipboard holds at least one entity. |
The clipboard behaves in these ways:
- Each canvas has its own clipboard. It lives in memory, in that canvas's store. A second
KookieFlowon the page doesn't share it, and a reload empties it. - Copy needs at least one entity. With only edges selected,
copydoes nothing and the clipboard keeps what it held, whilecutdeletes the edges without copying them. - A paste reproduces the moment you copied. The clipboard holds the entities as they were when you copied them. Moving or deleting the originals afterwards changes nothing, and every paste of the same copy lands in the same place.
- A paste replaces the selection. The pasted entities and edges become the selection, so the next key acts on them.
- A pasted node is marked dirty. A reactive node then runs and goes to
onEvaluate, while a manual one waits until you run it. Text, comments, pictures, video, models and ink never run. The Evaluation chapter explains when a dirty node runs. - Nothing is reported.
copy,cutandpastechange the canvas without callingonEntitiesChangeoronEdgesChange.
The example passes entities and edges as constants, so the canvas keeps every change itself.
If your app holds the graph in state, as useGraph does, a paste disappears again unless you
report it. Read Add pasted elements to a controlled graph
before you use the clipboard with state.
hasClipboardContent reads the clipboard when you call it, and doesn't re-render your component
when the clipboard changes. Call it while you build something that renders on demand, such as the
Paste item in a context menu. For a button that enables itself after
the first copy, subscribe to the store with useFlowStore from a component inside
<KookieFlow>:
const canPaste = useFlowStore((state) => state.internalClipboard !== null);Bind the clipboard keys
useKeyboardShortcuts listens on the whole document by default. It skips text fields, but
anywhere else on your page mod+c copies the graph selection and cancels the browser's own copy,
even when someone has selected text in your sidebar. Listen on an element around the canvas
instead, so the keys only act while focus is inside it:
flow-editor.tsxfunction ClipboardShortcuts({ container }: { container: HTMLElement | null }) { const { copy, cut, paste } = useClipboard(); useKeyboardShortcuts({ bindings: { 'mod+c': copy, 'mod+x': cut, 'mod+v': () => paste() }, target: container, enabled: container !== null, }); return null;}export function FlowEditor() { const [container, setContainer] = useState<HTMLDivElement | null>(null); return ( <div ref={setContainer} style={{ height: 480 }}> <KookieFlow entities={entities} edges={edges}> <ClipboardShortcuts container={container} /> </KookieFlow> </div> );}The element is held in state rather than a ref, so the hook attaches its listener once the element exists. Pressing anywhere on the canvas focuses it, so the keys work as soon as someone clicks the graph. The Keyboard chapter covers the hook's other options and the keys the canvas binds itself.
Pasted media goes to the canvas
A mod+v binding behaves differently from every other binding. The hook never cancels the key.
It waits for the browser's paste event and checks what the event carries:
- If it carries a file, or text that is the URL of an image, video or model, your handler
doesn't run. The canvas takes the paste instead: it passes the files to
onFileDropif you've set it, and otherwise makes an entity from any media it recognises. - Otherwise, your handler runs. It also runs when the browser fires no
pasteevent at all.
So when someone copies a screenshot and presses the paste key on the canvas, they get the
screenshot rather than the nodes they copied last. preventDefault has no effect on a mod+v
binding. Dropping and pasting media describes what the canvas does
with the media.
Change what a paste produces
useClipboard takes three options, and paste takes the same three for a single paste. A value
you pass to paste wins for that paste, and a value you leave out falls back to the hook's.
| Name | Type | Default | What it does |
|---|---|---|---|
offset | { x: number; y: number } | { x: 50, y: 50 } | How far the paste lands from the copied positions, in the same units as an entity's position. |
transformData | (data: T) => T | none | Returns the data for each pasted entity. Without it, each copy gets a shallow copy of the original's data. |
preserveExternalConnections | boolean | false | Also recreates edges between a copied entity and an entity outside the copy, attached to that same outside entity. |
These are the UseClipboardOptions and PasteOptions types, exported from the plugins entry.
T is the hook's type parameter, so useClipboard<MyData>() types transformData for your
data, where MyData extends EntityData. The function runs on every entity in the paste, frames
and comments included, so check a field before you rely on it.
This component moves each paste further from the original, so pasting three times gives you
three visible copies rather than a stack. The count starts again after each copy or cut. The
component also clears the status on each copy. Without that, a node your app marked with
status: 'error' pastes still showing the error.
clipboard-shortcuts.tsxconst clearStatus = (data: EntityData): EntityData => ({ ...data, status: undefined, statusMessage: undefined,});function ClipboardShortcuts({ container }: { container: HTMLElement | null }) { const { copy, cut, paste } = useClipboard({ transformData: clearStatus }); const pastes = useRef(0); const copyAndReset = () => { pastes.current = 0; copy(); }; const cutAndReset = () => { pastes.current = 0; cut(); }; const pasteStepped = () => { pastes.current += 1; const step = 40 * pastes.current; paste({ offset: { x: step, y: step } }); }; useKeyboardShortcuts({ bindings: { 'mod+c': copyAndReset, 'mod+x': cutAndReset, 'mod+v': pasteStepped }, target: container, enabled: container !== null, }); return null;}Keep the edges to the rest of the graph
copy keeps every edge attached to the selection, so you can decide at paste time which edges
come along. With preserveExternalConnections, a copy of a node that reads from a shared source
reads from that same source.
The option works in both directions. An edge from a copied node to an entity outside the copy is recreated too, so that entity's input receives a second edge. To keep only the edges coming in, clone the elements yourself, as the next section shows.
Widget values
A paste copies each entity's data as the entity holds it. If someone changed a widget and your
app hasn't written the value back into the entity through onWidgetChange, the copy doesn't get
the new value. The Sockets and widgets chapter explains how widget values
reach the entity.
Duplicate without the clipboard
useClipboard calls functions on the flow store, and you can call them yourself. Read them from
useFlowStoreApi().getState():
| Name | What it does |
|---|---|
copySelectedToInternal() | What copy calls. |
cutSelectedToInternal() | What cut calls. |
pasteFromInternal(options?) | What paste calls. It takes PasteFromInternalOptions, which has the same three fields as PasteOptions. |
internalClipboard | The clipboard, as an InternalClipboard of { entities: Entity[]; edges: Edge[] }, or null before the first copy. |
cloneElements(entities, edges, options?) | Returns copies with new ids. It doesn't change the graph, the selection or the clipboard. |
addElements({ entities, edges }) | Adds entities and edges to the graph in one update, drawn above everything already there. |
Neither cloneElements nor addElements reports its changes. The
flow store chapter covers the rest of the store.
Clone the elements yourself when you want to duplicate a selection with one key and leave the clipboard alone, or when you want to choose exactly which edges come along. This function keeps the edges inside the selection and the edges feeding into it, and leaves out the edges leaving it, so nothing downstream gains a second input:
duplicate-selection.tsimport type { FlowStore } from '@kushagradhawan/kookie-flow';export function duplicateSelection(store: FlowStore) { const state = store.getState(); const selected = state.getSelectedEntities(); if (selected.length === 0) return; const ids = new Set(selected.map((entity) => entity.id)); // Edges inside the selection, and edges feeding into it. Edges leaving it stay behind. const edges = state.getConnectedEdges([...ids]).filter((edge) => ids.has(edge.target)); const copy = state.cloneElements(selected, edges, { offset: { x: 40, y: 40 }, preserveExternalConnections: true, }); // A child copied with its frame belongs to the copy of that frame. const entities = copy.entities.map((entity) => { const parentId = entity.parentId ? copy.idMap.get(entity.parentId) : undefined; return parentId ? { ...entity, parentId } : entity; }); state.addElements({ entities, edges: copy.edges }); state.selectEntities(entities.map((entity) => entity.id)); state.selectEdges(copy.edges.map((edge) => edge.id));}Bind it the same way as the clipboard keys, for example
'mod+d': () => duplicateSelection(store) with store from useFlowStoreApi().
cloneElements sets a new id, a moved position, the new data and selected: false on each
copy, and copies every other field unchanged. That includes parentId, so a copied child still
belongs to the frame the original was in. A paste works the same way: paste a frame together with
its children, and the pasted children still belong to the original frame.
The parentId step in duplicateSelection points each child at the copy of its frame when the
frame was copied too. A child copied without its frame stays in the original frame. The
Frames and groups chapter explains how parentId works.
Clone options
cloneElements takes CloneElementsOptions. This type, CloneElementsResult, InternalClipboard
and PasteFromInternalOptions are exported from @kushagradhawan/kookie-flow.
| Name | Type | Default | What it does |
|---|---|---|---|
offset | XYPosition | { x: 50, y: 50 } | Added to each entity's position. |
transformData | (data: T) => T | none | Returns the data for each copy. Without it, each copy gets a shallow copy of the original's data. |
generateId | () => string | an id of the form kf-<time>-<counter> | Makes every new id, for entities and edges alike. |
preserveExternalConnections | boolean | false | Keeps an edge with one end outside the cloned entities, still attached to the outside entity. Without it, that edge is dropped. |
paste doesn't accept generateId. If your app needs its own id format, clone with
cloneElements.
What a clone returns
cloneElements and paste both return a CloneElementsResult:
| Name | Type | What it holds |
|---|---|---|
entities | Entity[] | The copies, with new ids, moved positions and selected: false. |
edges | Edge[] | The copied edges, with new ids. Each end that was on a copied entity points at its copy. |
idMap | Map<string, string> | Each original entity id, mapped to the id of its copy. Edge ids aren't in it. |
Copy between tabs and apps
The clipboard only exists in memory, so another tab, another window or another app can't read it. To copy between them, write the selection to the system clipboard as JSON, and clone it when it comes back:
system-clipboard.tsimport type { Edge, Entity, FlowStore } from '@kushagradhawan/kookie-flow';const FORMAT = 'my-app/graph';interface CopiedGraph { format: typeof FORMAT; entities: Entity[]; edges: Edge[];}export async function copyToSystem(store: FlowStore) { const { getSelectedEntities, getConnectedEdges } = store.getState(); const entities = getSelectedEntities(); if (entities.length === 0) return; // Only edges with both ends in the copy: the other end may not exist where it's pasted. const ids = new Set(entities.map((entity) => entity.id)); const edges = getConnectedEdges([...ids]).filter( (edge) => ids.has(edge.source) && ids.has(edge.target) ); const copied: CopiedGraph = { format: FORMAT, entities, edges }; await navigator.clipboard.writeText(JSON.stringify(copied));}function readGraph(text: string): CopiedGraph | null { try { const value: CopiedGraph | null = JSON.parse(text); return value?.format === FORMAT ? value : null; } catch { // Not JSON, so it isn't a graph this app copied. return null; }}export async function pasteFromSystem(store: FlowStore) { const copied = readGraph(await navigator.clipboard.readText()); if (!copied) return; const { cloneElements, addElements, selectEntities, selectEdges } = store.getState(); const pasted = cloneElements(copied.entities, copied.edges); addElements({ entities: pasted.entities, edges: pasted.edges }); selectEntities(pasted.entities.map((entity) => entity.id)); selectEdges(pasted.edges.map((edge) => edge.id));}Bind copyToSystem and pasteFromSystem to mod+c and mod+v in place of useClipboard. The
format field lets readGraph ignore ordinary text that someone copied from elsewhere.
Check these limits before you rely on the system clipboard:
- Media from files may not travel. When someone pastes or drops an image, video or model
file onto a canvas that has no
onFileDrop, the entity points at an object URL, which only loads in the tab that created it. To copy media between tabs, upload the file inonFileDropand store a URL that works anywhere. - Your data has to survive JSON. Anything in
datathatJSON.stringifycan't represent, such as a function or aMap, doesn't arrive. - The paste keeps its coordinates. It lands at the copied positions plus the offset, which in another tab can be outside the view.
- The browser controls access. Pages can only use
navigator.clipboardover HTTPS or on localhost, and a browser can ask the person for permission before the page reads it.
Add pasted elements to a controlled graph
In a controlled graph, your app holds entities and edges in state, and KookieFlow replaces
its own copy with your props every time they change. Nothing in this chapter reports its changes,
so a paste appears on the canvas and then disappears at the next change your app applies, such as
a drag.
Report what changed. paste returns what it added, so pass that to your change handlers as add
changes. A cut deletes the selection, so read the selection first, then report the removals:
flow-editor.tsx'use client';import { useState } from 'react';import { KookieFlow, useFlowStoreApi, useGraph, type EdgeChange, type EntityChange,} from '@kushagradhawan/kookie-flow';import { useClipboard, useKeyboardShortcuts } from '@kushagradhawan/kookie-flow/plugins';import { initialEntities } from './initial-graph';interface ClipboardShortcutsProps { container: HTMLElement | null; onEntitiesChange: (changes: EntityChange[]) => void; onEdgesChange: (changes: EdgeChange[]) => void;}function ClipboardShortcuts({ container, onEntitiesChange, onEdgesChange }: ClipboardShortcutsProps) { const store = useFlowStoreApi(); const { copy, cut, paste } = useClipboard(); const pasteAndReport = () => { const pasted = paste(); if (!pasted) return; onEntitiesChange(pasted.entities.map((entity) => ({ type: 'add', entity }))); onEdgesChange(pasted.edges.map((edge) => ({ type: 'add', edge }))); }; const cutAndReport = () => { // Read the selection before the cut clears it. const { selectedEntityIds, selectedEdgeIds, getConnectedEdges } = store.getState(); const entityIds = [...selectedEntityIds]; const edgeIds = new Set(selectedEdgeIds); for (const edge of getConnectedEdges(entityIds)) edgeIds.add(edge.id); cut(); onEdgesChange([...edgeIds].map((id) => ({ type: 'remove', id }))); onEntitiesChange(entityIds.map((id) => ({ type: 'remove', id }))); }; useKeyboardShortcuts({ bindings: { 'mod+c': copy, 'mod+x': cutAndReport, 'mod+v': pasteAndReport }, target: container, enabled: container !== null, }); return null;}export function FlowEditor() { const [container, setContainer] = useState<HTMLDivElement | null>(null); const graph = useGraph({ initialEntities, history: true }); return ( <div ref={setContainer} style={{ height: 480 }}> <KookieFlow entities={graph.entities} edges={graph.edges} onEntitiesChange={graph.onEntitiesChange} onEdgesChange={graph.onEdgesChange} onConnect={graph.onConnect} > <ClipboardShortcuts container={container} onEntitiesChange={graph.onEntitiesChange} onEdgesChange={graph.onEdgesChange} /> </KookieFlow> </div> );}A few details in this component matter:
- Report the edges of a cut. Deleting an entity deletes its edges, but a
removechange for an entity doesn't remove its edges fromuseGraph's state. The cut handler reports the selected edges and every edge attached to a selected entity. - One key press is one undo step. With
useGraph({ history: true }), the two reports that one handler makes count as a single step. The Undo and redo chapter explains how steps are recorded. - Report a clone the same way. After
addElements, report the entities and edges you passed to it asaddchanges. - A copy carries its type's defaults. A copied entity holds the sockets, size, label and preview that its entity type filled in. Once you report it, your state holds those fields on the entity, and a later change to the type doesn't reach that copy.
The Controlled and uncontrolled state chapter explains when the canvas takes your props and what it reports.