Events & JavaScript API

Copy Markdown View Source

LiveFlow splits the work between the browser and the server: the LiveFlow hook handles every interaction on the client and reports the results to your LiveView as lf:* events; your LiveView updates the LiveFlow.State and re-renders. This guide lists every event in both directions, the DOM events you can dispatch from HEEx, and the JavaScript API exposed on the flow element.

How Events Reach Your LiveView

The hook calls pushEvent, so events go to the LiveView that renders the Flow component (not to the component itself). Handle the ones you need and keep a catch-all for the rest:

alias LiveFlow.Changes.{EdgeChange, NodeChange}

def handle_event("lf:node_change", %{"changes" => changes}, socket) do
  {:noreply, assign(socket, flow: NodeChange.apply_changes(socket.assigns.flow, changes))}
end

def handle_event("lf:edge_change", %{"changes" => changes}, socket) do
  {:noreply, assign(socket, flow: EdgeChange.apply_changes(socket.assigns.flow, changes))}
end

# Events you don't use (lf:drag_move, lf:cursor_move, ...)
def handle_event("lf:" <> _event, _params, socket), do: {:noreply, socket}

# Node clicks arrive as a message
def handle_info({:lf_node_click, _node_id}, socket), do: {:noreply, socket}

Getting Started shows a complete set of handlers. All payload keys are strings, positions are in flow coordinates (%{"x" => x, "y" => y}) unless noted otherwise.

Events Sent to Your LiveView

Nodes

EventPayloadWhen
lf:node_change%{"changes" => [change]}Nodes moved, measured, resized or deleted with their delete button
lf:drag_move%{"changes" => [position change]}While dragging, throttled (~50 ms). Meant for collaboration; don't store it
{:lf_node_click, id}handle_info/2 messageA node was clicked (not after a drag or a connection)

Change maps in lf:node_change:

"type"KeysNotes
"position""id", "position", "dragging""dragging" => true when a drag starts, false on drop. A drag cancelled with Escape sends the start positions with "aborted" => true. Child nodes report positions relative to their parent
"dimensions""id", "width", "height"Measured size of a node (first render and every size change)
"resize""id", "width", "height", "position", "resizing"From LiveFlow.Components.NodeResizer
"remove""id"A node's delete button was clicked

LiveFlow.Changes.NodeChange.apply_changes/2 handles all of them.

Edges

EventPayloadWhen
lf:edge_change%{"changes" => [%{"type" => "remove", "id" => id}]}An edge's delete button was clicked
lf:edge_label_change%{"id", "label"}A label was edited (double click); "label" is nil when cleared
lf:insert_on_edge%{"edge_id"}The "+" button of an edge with data: %{insertable: true} was clicked
lf:reconnect_edge%{"edge_id", "end", "node_id", "handle_id"}An end ("source" or "target") of a reconnectable edge was dropped on a valid handle
lf:delete_reconnect_edge%{"edge_id", "end", "position"}That end was dropped on the pane

LiveFlow.State.reconnect_edge/3 accepts the lf:reconnect_edge params as they come.

Connections

EventPayloadWhen
lf:connect_end%{"source", "source_handle", "target", "target_handle"}A connection was completed (one event per source with multi_connection)
lf:connect_cancel%{"node_id", "handle_id", "handle_type", "position", "reason"}A connection ended without connecting: "reason" is "pane", "invalid" (also sends "target_node_id" / "target_handle_id") or "abort" (Escape)
lf:connect_start%{"node_id", "handle_id", "handle_type"}A connection started. Opt-in: connection_events: true

lf:connect_end is a request: nothing is added until you add the edge, usually after validating it with LiveFlow.Validation.Connection.validate_and_create/3. See Interaction.

Selection, Deletion and Viewport

EventPayloadWhen
lf:selection_change%{"nodes" => [id], "edges" => [id]}The selection changed (click, selection box, Ctrl/Cmd+A, Escape, setSelection)
lf:delete_selected%{}A delete key (delete_key_code) was pressed with a selection
lf:viewport_change%{"x", "y", "zoom"}The viewport moved (throttled; the final value is always sent)

Keyboard Commands

The hook only sends these; the behavior is yours to implement (for example with LiveFlow.History and LiveFlow.Clipboard, see Interaction).

EventKeysPayload
lf:undoCtrl/Cmd+Z%{}
lf:redoCtrl/Cmd+Shift+Z, Ctrl/Cmd+Y%{}
lf:copy / lf:cut / lf:duplicateCtrl/Cmd+C / X / D (with a selection)%{}
lf:pasteCtrl/Cmd+V%{"position" => %{"x", "y"}} when the pointer is over the flow, %{} otherwise

Keys typed in inputs, textareas and content-editable elements are ignored, and with several flows on a page only the focused (or last used) flow reacts.

Pane, Drag & Drop and Context Menus

EventPayloadWhen
lf:drop%{"data", "position"}An element with application/liveflow drag data was dropped on the flow. "data" is the parsed JSON (or the raw string); the position is snapped when snap_to_grid is on
lf:pane_click%{"position"}The pane was clicked without dragging. Opt-in: pane_click_events: true
lf:node_context_menu%{"id", "x", "y", "position", "selection"}Right click on a node. Opt-in: context_menu: true
lf:edge_context_menu%{"id", "x", "y", "position"}Right click on an edge. Opt-in: context_menu: true
lf:pane_context_menu%{"x", "y", "position"}Right click on the pane. Opt-in: context_menu: true

In the context menu events "x"/"y" are pixels relative to the flow container (to position a menu inside it) and "position" is in flow coordinates.

Layout and Collaboration

EventPayloadWhen
lf:request_layout%{"direction"}JS.dispatch("lf:auto-layout") was triggered; answer with lf:layout_data
lf:cursor_move%{"x", "y"}The pointer moved over the flow (throttled). Only with cursors: true

lf:container_resize is sent to the Flow component itself (minimap and virtualization) and never reaches your LiveView.

Events You Push to the Client

Send these with Phoenix.LiveView.push_event/3. Events marked with flow_id accept an optional flow_id (the Flow component id): with several flows on the page, only that one reacts.

EventPayloadEffect
lf:fit_view%{padding, duration, nodes: [id], bounds, min_zoom, max_zoom, flow_id}Fit the view to all nodes, some nodes or a rect
lf:zoom_in / lf:zoom_out%{duration, flow_id}Zoom one step around the center
lf:zoom_to%{zoom, duration, flow_id}Zoom to a level
lf:pan_to%{x, y, duration, flow_id}Animate the viewport translation to x/y (screen pixels, like viewport.x/y)
lf:set_viewport%{x, y, zoom, flow_id}Set the viewport immediately
lf:animate_positionsbuilt by LiveFlow.animate_positions/5Animate nodes to new positions, then push them back as lf:node_change
lf:layout_databuilt by LiveFlow.Layout.prepare_layout_data/2Run the ELK layout (needs mix live_flow.gen.elk, see Layout)
lf:tree_layout_databuilt by LiveFlow.Layout.prepare_tree_layout_data/2Run the built-in JS tree layout
lf:export_svg / lf:export_png%{filename, padding, scale, flow_id}Download an SVG/PNG of the flow (see Export)
lf:download_file%{content, filename, type}Download a text file; requires setupDownloadHandler()

Prefer the Elixir helpers where they exist:

# Fit the view, computing the bounds on the server (works with virtualization)
send_update(LiveFlow.Components.Flow, id: "my-flow", fit_view: %{nodes: ["a", "b"], padding: 0.2})

# Animate to a server-side layout
positions = LiveFlow.Layout.tree(flow, direction: :lr)
socket = LiveFlow.animate_positions(socket, "my-flow", flow, positions, fit_view: true)

LiveFlow.Collaboration pushes lf:remote_cursor, lf:cursor_leave and lf:remote_drag for you; see Collaboration.

DOM Events

Some actions must run in the browser (they read the current client viewport or start a download, which needs the user's click). Dispatch them on the flow element with Phoenix.LiveView.JS.dispatch/2:

EventdetailEffect
lf:zoom-in / lf:zoom-out-Zoom one step
lf:fit-viewfit view options (padding, duration, nodes, ...)Fit the view
lf:export-svg%{filename:, padding:}Download an SVG
lf:export-png%{filename:, padding:, scale:}Download a PNG
lf:auto-layout%{direction: "RIGHT"}Push lf:request_layout to your LiveView
<button phx-click={JS.dispatch("lf:fit-view", to: "#my-flow", detail: %{padding: 0.2})}>
  Fit view
</button>
<button phx-click={JS.dispatch("lf:export-png", to: "#my-flow", detail: %{scale: 2})}>
  Download PNG
</button>

JavaScript API

When the hook mounts, the flow element gets a liveflow property (the equivalent of React Flow's useReactFlow()), and the element dispatches an lf:ready event whose detail is the same object. lf:ready doesn't bubble: listen on the flow element.

MethodReturns
getViewport(){x, y, zoom}
setViewport({x, y, zoom}, {duration})Moves the viewport (missing keys keep their value)
fitView(options)Fits the view (same options as fit_view_options)
zoomIn({duration}) / zoomOut({duration})Zooms one step
zoomTo(zoom, {duration})Zooms to a level
screenToFlowPosition({x, y}, {snapToGrid})Flow position of a client (viewport) point
flowToScreenPosition({x, y})Client position of a flow point
getNodes()Rendered nodes: {id, x, y, width, height, selected, selectable, draggable, deletable, type, parentId} with absolute positions
getNode(id)One of those, or null
getIntersectingNodes({x, y, width, height}, {partial})Rendered nodes overlapping a flow rect (partial: false requires full containment)
getSelection(){nodes: [id], edges: [id]}
setSelection({nodes, edges})Replaces the selection and pushes lf:selection_change
elementThe flow element

The API reads the browser state, so it only sees rendered nodes (with virtualization: true, nodes outside the viewport are not rendered). Use it from a hook, for example to turn a pointer position into flow coordinates:

<div id="stamp" phx-hook=".Stamp" data-flow="#my-flow">Click the flow</div>
<script :type={Phoenix.LiveView.ColocatedHook} name=".Stamp">
  export default {
    mounted() {
      const flow = document.querySelector(this.el.dataset.flow)
      flow.addEventListener("dblclick", (event) => {
        const position = flow.liveflow.screenToFlowPosition({x: event.clientX, y: event.clientY})
        this.pushEvent("add_note", {position})
      })
    }
  }
</script>

If the hook may mount after yours, wait for lf:ready:

const flow = document.getElementById("my-flow")
if (flow.liveflow) init(flow.liveflow)
else flow.addEventListener("lf:ready", (event) => init(event.detail), {once: true})

JavaScript Exports

Besides the hook, the live_flow package exports:

ExportPurpose
LiveFlowHook, FileImportHook, setupDownloadHandlerHooks and the download listener (Getting Started, Export)
registerEdgePath(type, fn)Redraw custom edge shapes while dragging (Edges)
getEdgePath, getBezierPath, getStraightPath, getStepPath, getSmoothStepPath, calculateBezierPathEdge path math, identical to LiveFlow.Paths on the server
registerElk(ELK), hasElk(), getLayoutedElementsOptional ELK layout (Layout)
exportSVG, exportPNG, buildExportSvgProgrammatic export (Export)
CoordinateUtils, EdgeUpdater, PanZoomManager, NodeDragManager, ConnectionManager, SelectionManager, calcAutoPan, parseKeys, matchesKeysInternals, for advanced customization