Skip to content

Vue adapter

The Vue adapter ships as @xenolithengine/graph-vue. Composition-API only — there is no Options-API mirror. Like the React adapter, it is a thin layer over the headless editor: every public method on XenolithEditor works the same way the core docs describe. What the adapter adds is Vue-shaped sugar: one component (<XenolithGraph>), an injection key, a handful of composables, and four in-editor panel components.

The adapter does not offer v-model on the whole graph. The editor owns its WebGL state; diffing 1000-node arrays from a Vue parent on every change would defeat the perf budget. Instead the host reads live snapshots through composables and mutates through the editor’s imperative API.

Install

Terminal window
pnpm add @xenolithengine/graph-vue @xenolithengine/graph-editor pixi.js vue

pixi.js is a peer dependency. Vue 3.3+ is required.

Mount the editor

<template>
<XenolithGraph
:resize-to-window="false"
style="position:absolute;inset:0"
@ready="onReady"
/>
</template>
<script setup lang="ts">
import { XenolithGraph } from '@xenolithengine/graph-vue'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
function onReady(editor: XenolithEditor): void {
editor.registry.register({
type: 'Hello', title: 'Hello', category: 'data',
pins: [{ kind: 'data', direction: 'out', type: 'string', label: 'msg' }],
})
editor.insertNode('Hello', { x: 0, y: 0 })
editor.view.fitView({ padding: 80 })
}
</script>

@ready is the Vue equivalent of React’s onReady — emitted once the editor instance is alive. Treat it as the place for one-shot setup: register schemas, seed the graph, fit the view.

<XenolithGraph> props

Same shape as the React adapter (kebab-cased in templates):

PropTypeNotes
themeXenolithThemeDefault is the Xen theme.
graphXenolithGraphV1Initial scene; same shape editor.loadJSON accepts.
zoom-bounds[min, max]Default [0.05, 16].
minimapboolean | { position }Prefer <XenolithMiniMap> (declarative subtree).
snapnumberGrid snap step in world px.
disable-gridbooleanHide the background grid.
resize-to-windowbooleanDefault true. Set false to fit the parent.
fit-on-loadbooleanAfter graph mounts, call fitView.
is-valid-connection(info) => booleanCustom connection guard on top of the type check; return false to reject a wire.

<XenolithGraph> events

@ready plus one event per editor event (kebab-cased), each with its typed payload — handler params are inferred by vue-tsc/Volar, no any:

<XenolithGraph
@ready="onReady"
@node-removing="(p) => { if (p.nodeId === locked) p.cancel() }"
@edge-connecting="(p) => { /* validate / veto */ }"
@selection-changed="(p) => updateInspector(p.nodeIds)"
@widget-changed="(p) => syncFormState(p)"
/>

The full mapping lives in EDITOR_EVENT_NAMES from @xenolithengine/graph-adapter-core. Preventable events (-ing suffix) accept a payload.cancel() call from the handler.

For long-tail / dynamic subscriptions in child components, use useEditorEvent (below).

Composables

Composables must be called inside a <XenolithGraph> subtree — the component provides the live editor via provide(XenolithEditorKey, ref). Children inject the same ref.

import {
useEditor, useEditorOrNull, useEditorReady, useEditorEvent, useXenolithGraph,
useNodes, useEdges, useSelection, useViewport, useGraphJSON, useUndoRedo, useNodesState,
} from '@xenolithengine/graph-vue'
ComposableReturnsNotes
useEditor()ShallowRef<XenolithEditor | null>Throws when called outside a <XenolithGraph> subtree.
useEditorOrNull()sameSame but tombstones to shallowRef(null) instead of throwing.
useEditorReady(cb)same + runs cb when editor mountsThe right call for one-shot per-component setup (registering commands, contextMenu items).
useEditorEvent(event, handler)voidOne-shot subscribe; cleans up on unmount.
useXenolithGraph(target, props?)ShallowRef<XenolithEditor | null>Mount into any element WITHOUT <XenolithGraph> — portals, third-party slots. Props synced by reference; destroyed on unmount.
useNodes()Readonly<ShallowRef<readonly Node[]>>Re-fires on node mutations + undo/redo.
useEdges()Readonly<ShallowRef<readonly Edge[]>>Same for edges.
useSelection()Readonly<ShallowRef<readonly NodeId[]>>selection:changed.
useViewport()Readonly<ShallowRef<ViewportState>>viewport:changed.
useGraphJSON()Readonly<ShallowRef<XenolithGraphV1 | null>>Any mutation, load, undo/redo.
useUndoRedo(){ canUndo, canRedo: ShallowRef<boolean>; undo, redo: () => boolean }Toolbar state + handles.
useNodesState(){ nodes, edges: Readonly<ShallowRef<…>>; applyChanges; setNodes; setEdges }The controlled state — see below.

editor.value is null during a child component’s setup() because Vue runs setup synchronously before the parent’s onMounted. Use useEditorReady(cb) (or watch(editor, ...)) for any setup that needs the live instance:

useEditorReady((editor) => {
const off = editor.commands.register({
id: 'app.deleteAll', label: 'Delete all', hotkey: 'Mod+Shift+K',
execute: () => { for (const n of [...editor.graphNodes()]) editor.removeNode(n.id) },
})
return off // cleanup runs on component unmount
})

Undo / Redo toolbar

<script setup lang="ts">
import { XenolithPanel, XenolithButton, useUndoRedo } from '@xenolithengine/graph-vue'
const { canUndo, canRedo, undo, redo } = useUndoRedo()
</script>
<template>
<XenolithPanel position="top-right">
<XenolithButton :disabled="!canUndo" @click="undo()">Undo</XenolithButton>
<XenolithButton :disabled="!canRedo" @click="redo()">Redo</XenolithButton>
</XenolithPanel>
</template>

Controlled state (commit-time)

React Flow / Pinia migrants: useNodesState() gives you the controlled triple — nodes, edges, applyChanges, setNodes — folded from commit-time graph:changed arrays (ADR 0006). Same semantics as React’s useNodesState, Vue-shaped: the mirror is a shallowRef of plain records, exposed as readonly computed refs. One deliberate difference from React Flow: positions arrive when a drag COMMITS, never per frame, so your component tree stays still while the WebGL renderer owns the drag.

<script setup lang="ts">
import { XenolithGraph, useNodesState } from '@xenolithengine/graph-vue'
const { nodes, setNodes } = useNodesState()
const addBox = () => setNodes((prev) => [...prev, makeBox(prev.length)])
</script>
<template>
<XenolithGraph :resize-to-window="false" style="position:absolute;inset:0">
<button v-for="n in nodes" :key="n.id" :style="nodeStyle(n)">{{ n.type }}</button>
</XenolithGraph>
</template>
  • setNodes(next) — pass the next nodes array or an updater over the live mirror. Adds, removes, position and state deltas are diffed onto the editor as one undo step (shallow diff: position by coordinates, state by reference); the graph:changed echo converges the mirror. setEdges(next) is the edge half: endpoints compare by node id and pin id, also one undo step.
  • applyChanges(changes) — forward an array (yours, or an echo) to editor.applyChanges; idempotent for echoes.
  • For Pinia/Vuex stores use the pure reduceGraphChanges reducer from @xenolithengine/graph-editor directly and subscribe to graph:changed yourself.

Mount without the component tree

useXenolithGraph(target, props?) mounts an editor into any element — a portal, a third-party slot — and returns ShallowRef<XenolithEditor | null>. target is a template ref (or getter); props is a ref/computed/getter producing a fresh object when props change (reference-diffed). The binding is destroyed on component unmount; a target swap tears the old editor down before the new one mounts.

<script setup lang="ts">
import { ref } from 'vue'
import { useXenolithGraph } from '@xenolithengine/graph-vue'
const host = ref<HTMLDivElement | null>(null)
const editor = useXenolithGraph(host, () => ({ snap: 8, minimap: true }))
</script>
<template>
<div ref="host" style="position:absolute;inset:0" />
</template>

In-editor panel components

Same four primitives as the React adapter — they portal into editor.chrome.overlayRoot via Vue’s <Teleport>, so they live “in” the graph and inherit the theme’s --xeno-* vars.

ComponentWhat it does
<XenolithPanel position bare>Absolute-positioned floating card. bare drops the chrome and only positions the slot.
<XenolithButton :active :disabled @click>Themed button. active paints with the accent colour.
<XenolithControls />Declarative wrapper over editor.chrome.setControls(...). Renders no DOM of its own.
<XenolithMiniMap position />Declarative minimap toggle. Renders no DOM.
<template>
<XenolithGraph :resize-to-window="false" style="position:absolute;inset:0" @ready="onReady">
<XenolithControls position="bottom-left" />
<XenolithMiniMap position="bottom-right" />
<Toolbar />
</XenolithGraph>
</template>

Custom Vue widgets

vueWidget(Component) wraps a Vue 3 SFC as a node widget. The component receives value, setValue (committed through the command bus — undoable), plus reactive theme tokens (accent, text, muted) and the widget cell’s width / height.

Knob.vue
<template>
<input
type="range" min="0" max="100" step="1"
:value="value ?? 0" :style="{ width: '100%', accentColor: accent, minWidth: 0 }"
@input="setValue(Number(($event.target as HTMLInputElement).value))"
/>
</template>
<script setup lang="ts">
import type { WidgetProps } from '@xenolithengine/graph-vue'
defineProps<WidgetProps>()
</script>
import Knob from './Knob.vue'
import { vueWidget } from '@xenolithengine/graph-vue'
editor.registerWidget('knob', vueWidget(Knob))
editor.registry.register({
type: 'Volume', title: 'Volume', category: 'data',
pins: [{ kind: 'data', direction: 'out', type: 'number', label: 'v' }],
widgets: [{ id: 'v', type: 'custom', renderer: 'knob', key: 'v', label: 'Volume' }],
})

The wrapped component is mounted once per widget instance. Subsequent value changes mutate reactive props in place — no remount, no flicker. Theme switches (setTheme(...)) push new accent / text / muted values into the same refs.

SSR / Nuxt

<XenolithGraph> paints nothing during SSR — PIXI requires WebGL. Inside a Nuxt page wrap it in <ClientOnly>:

<template>
<ClientOnly>
<XenolithGraph :resize-to-window="false" @ready="onReady" />
</ClientOnly>
</template>

A dedicated Nuxt module is planned post-v1.0; until then the manual <ClientOnly> wrap is the full story.

Imperative escape hatch (no <XenolithGraph> parent)

Need the editor outside the component tree — for instance, mounted into a third-party slot or a portal? Use createEditorBinding from @xenolithengine/graph-adapter-core directly:

import { createEditorBinding } from '@xenolithengine/graph-adapter-core'
const binding = await createEditorBinding(targetEl, { graph, fitOnLoad: true })
binding.on('node:click', (p) => console.log(p.nodeId))
// ... later:
binding.destroy()

This is the same primitive <XenolithGraph> builds on internally.

What’s NOT in this adapter

  • No v-model="graph". Same architectural reason as React: 1000-node diffing on each parent update would defeat the WebGL renderer. v-model on individual widget values (via vueWidget()) is the supported boundary, and useNodesState() covers the controlled-store use case at commit time (see above).
  • No SSR rendering. <ClientOnly> is required in Nuxt.