Skip to content

Svelte adapter

The Svelte adapter ships as @xenolithengine/graph-svelte. Where React/Vue get a component, Svelte gets its own idiom: a mount action (use:xenolith) that re-dispatches every editor event as a typed CustomEvent, plus a bag of stores (createXenolithStores()) you wire from the action’s ready event. No wrapper component, no context magic — actions and stores are the two things Svelte already does natively.

Works with Svelte 4 and 5 (runtime-only API surface — stores and actions; nothing here needs the compiler). The editor is WebGL/client-only: in SvelteKit see the integration page for the browser gate.

Install

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

Peer deps: svelte >= 4, pixi.js@^8.6.0.

Mount the editor

<script lang="ts">
import { xenolith, createXenolithStores } from '@xenolithengine/graph-svelte'
const stores = createXenolithStores()
const props = { minimap: true, resizeToWindow: false, snap: 8 }
function onReady(e: CustomEvent<XenolithEditor>) {
stores.editor.set(e.detail) // wire reactive stores — see below
e.detail.registry.register(mySchema)
e.detail.view.fitView({ padding: 80 })
}
</script>
<div
use:xenolith={props}
on:ready={onReady}
on:node-click={(e) => console.log('clicked', e.detail.nodeId)}
on:node-removing={(e) => { if (e.detail.nodeId === lockedId) e.detail.cancel() }}
style="position:absolute;inset:0"
></div>

When props gets a new object reference, the action forwards it to the editor (reference-diffed, same immutable-props semantics as the React/Vue adapters).

Action props

The full XenolithProps surface (nine keys, ADAPTER-CONTRACT §1):

PropTypeNotes
themeXenolithThemeDefault is the Xen theme.
graphXenolithGraphV1Initial scene; same shape editor.loadJSON accepts.
zoomBounds[min, max]Default [0.05, 16]. Mount-time.
minimapboolean | { position }Prefer declarative toggles via editor.chrome.
snapnumberGrid snap step in world px. Mount-time.
disableGridbooleanHide the background grid. Mount-time.
resizeToWindowbooleanDefault true. Set false to fit the host element.
fitOnLoadbooleanAfter graph mounts, call fitView.
isValidConnection(info) => booleanCustom connection guard; return false to reject a wire.

Action events

ready (detail: the XenolithEditor — fires once when mounted) plus one kebab-named CustomEvent per editor event, each with its typed payload in event.detail (svelte-check knows the shapes — they derive from EditorEvents, never hand-listed):

<div
use:xenolith={props}
on:ready={onReady}
on:selection-changed={(e) => (selectedIds = e.detail.nodeIds)}
on:edge-connecting={(e) => { if (!allowed(e.detail)) e.detail.cancel() }}
on:widget-changed={(e) => syncForm(e.detail)}
/>

The node:click → node-click kebab mapping covers all 25 editor events (the four preventable -ing ones expose e.detail.cancel()); the full list derives from EDITOR_EVENT_NAMES in @xenolithengine/graph-adapter-core — see the events reference.

Reactive stores

createXenolithStores() builds a per-editor bag — call it once per editor, never a module-level singleton (multiple editors must coexist). Wire the live editor in from on:ready; every store re-binds on swap, and dispose() unsubscribes everything when the editor is gone for good.

<script lang="ts">
import { xenolith, createXenolithStores } from '@xenolithengine/graph-svelte'
const s = createXenolithStores()
const selected = derived(s.selection, (ids) => ids.length)
</script>
<div use:xenolith={{ resizeToWindow: false }} on:ready={(e) => s.editor.set(e.detail)}></div>
<p>{selected} selected · zoom {$s.viewport.zoom.toFixed(2)}</p>
<button disabled={!$s.undoRedo.canUndo} on:click={() => s.undoRedo.undo()}>Undo</button>
<ul>{#each $s.nodes as n (n.id)}<li>{n.type} @ {n.position.x},{n.position.y}</li>{/each}</ul>
StoreTypeRe-fires on
editorWritable<XenolithEditor | null>You set it from on:ready.
nodesReadable<readonly Node[]>add/remove/move, load, undo/redo.
edgesReadable<readonly Edge[]>connect/disconnect, node removal, load, undo/redo.
selectionReadable<readonly NodeId[]>selection:changed.
viewportReadable<ViewportState>pan/zoom.
graphJSONReadable<XenolithGraphV1 | null>any mutation, load, undo/redo.
undoRedo{ canUndo, canRedo: Readable<boolean>; undo, redo }history:changed.
nodesState()the controlled triple — belowcommit-time graph:changed.

Event bursts are coalesced into one microtask recompute per store: a 1000-node transaction costs ONE rebuild, not 1000 — same budget as the React/Vue hooks.

Controlled state (commit-time)

React Flow migrants: stores.nodesState() gives you the controlled triple — nodes, edges, applyChanges, setNodes, setEdges — folded from commit-time graph:changed arrays (ADR 0006). Same semantics as React’s useNodesState / Vue’s useNodesState, Svelte-shaped: each call creates an independent mirror (one per consumer). One deliberate difference from React Flow: positions arrive when a drag COMMITS, never per frame.

<script lang="ts">
import { xenolith, createXenolithStores } from '@xenolithengine/graph-svelte'
const s = createXenolithStores()
const { nodes, setNodes } = s.nodesState()
const addBox = () => setNodes((prev) => [...prev, makeBox(prev.length)])
</script>
<div use:xenolith={{ resizeToWindow: false }} on:ready={(e) => s.editor.set(e.detail)}></div>
<button on:click={addBox}>Add box</button>
{#each $nodes as n (n.id)}<div class="chip" style="transform: translate({n.position.x}px, {n.position.y}px)">{n.type}</div>{/each}

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) forwards an array to editor.applyChanges — idempotent for echoes.

In-editor components (Svelte 5)

The panel family ships behind the ./components subpath — they are Svelte 5 components compiled by YOUR build (the package ships source), so importing them needs Svelte 5 while the runtime entry (action + stores) keeps working on Svelte 4:

<script lang="ts">
import { xenolith, createXenolithEditorContext } from '@xenolithengine/graph-svelte'
import { XenolithPanel, XenolithButton, XenolithControls, XenolithMiniMap } from '@xenolithengine/graph-svelte/components'
// During component init (NOT inside on:ready — Svelte context is init-time only):
const editor = createXenolithEditorContext()
const props = { resizeToWindow: false }
</script>
<div use:xenolith={props} on:ready={(e) => editor.set(e.detail)} style="position:absolute;inset:0"></div>
<XenolithControls position="bottom-left" />
<XenolithMiniMap position="bottom-right" />
<XenolithPanel position="top-right">
<XenolithButton active on:click={() => addBox()}>Add box</XenolithButton>
</XenolithPanel>
ComponentWhat it does
<XenolithPanel position bare>Portals into editor.chrome.overlayRoot; six anchors, bare drops the card chrome.
<XenolithButton active disabled onclick>Themed --xeno-* button; active paints with the accent.
<XenolithControls …>Declarative chrome.setControls; unmount → toolbar removed.
<XenolithMiniMap position />Declarative minimap toggle; unmount → hidden.
<XenolithProposalQueue />Declarative proposal review panel (silent no-op without a propose-mode session).

Custom Svelte widgets

svelteWidget(Component) bridges a Svelte 5 component into a node widget — mount once per instance, updates stream through a props store (no remount, internal state survives):

MyKnob.svelte
<script lang="ts">
import type { WidgetProps } from '@xenolithengine/graph-svelte/components'
let { value, setValue, accent }: WidgetProps = $props()
</script>
<input type="range" style={`accent-color:${accent}`} value={Number(value ?? 0)}
oninput={(e) => setValue(Number(e.currentTarget.value))} />
import MyKnob from './MyKnob.svelte'
import { svelteWidget } from '@xenolithengine/graph-svelte/components'
editor.registerWidget('knob', svelteWidget(MyKnob))

Imperative primitive

createXenolithGraph(el, props?) — the raw createEditorBinding pass-through for hosts that mount outside any template node (the action keeps its binding private). The caller owns teardown.

import { createXenolithGraph } from '@xenolithengine/graph-svelte'
const binding = await createXenolithGraph(hostEl, { graph: saved, fitOnLoad: true })
binding.on('node:click', (p) => console.log(p.nodeId))
// … later:
binding.destroy()

What’s NOT in this adapter

  • Components/widgets need Svelte 5 (runes). The action and the stores work on Svelte 4; the ./components subpath (panels + svelteWidget) requires Svelte 5, compiled by your build.
  • No SSR. WebGL only; in SvelteKit gate with browser / export const ssr = false — see the SvelteKit integration.