Save & restore
The whole graph is JSON. Download / upload a .json file, and autosave to localStorage on every edit (driven by useGraphJSON). Reload the page — it comes back.
import { useEffect, useRef, useState } from 'react'
import { XenolithGraph, XenolithControls, XenolithPanel, XenolithButton, useEditor, useGraphJSON } from '@xenolithengine/graph-react'
import { initSaveRestore, downloadGraph, uploadGraph, saveToLocal, restoreFromLocal, hasSaved } from '@xenolithengine/demo/save-restore'
import { DemoStage } from '../Layout.js'
// Persistence: the whole graph is JSON (editor.toJSON ⇄ loadJSON). The file + localStorage helpers
// live in the framework-agnostic core; only the autosave-on-edit is React here, since it rides
// useGraphJSON's reactive change stream. `useEditor()` is strict — the panel only renders inside
// <XenolithGraph>, after the editor has mounted, so we never hold null.
function SavePanel() {
const editor = useEditor()
const json = useGraphJSON()
const [savedAt, setSavedAt] = useState<number | null>(null)
const fileRef = useRef<HTMLInputElement | null>(null)
const first = useRef(true)
// Autosave (debounced) whenever the graph changes — useGraphJSON re-emits on edits.
useEffect(() => {
if (!json) return
if (first.current) { first.current = false; return } // skip the initial load
const t = setTimeout(() => { saveToLocal(editor); setSavedAt(Date.now()) }, 500)
return () => clearTimeout(t)
}, [json, editor])
const btn: React.CSSProperties = { width: '100%' }
return (
<XenolithPanel position="top-left" style={{ display: 'flex', flexDirection: 'column', gap: 6, width: 180 }}>
<p style={{ margin: 0, fontSize: 11, textTransform: 'uppercase', letterSpacing: '.05em', color: 'var(--xeno-muted)' }}>Save / restore</p>
<XenolithButton style={btn} onClick={() => downloadGraph(editor)}>↓ Download .json</XenolithButton>
<XenolithButton style={btn} onClick={() => fileRef.current?.click()}>↑ Upload .json</XenolithButton>
<XenolithButton style={btn} onClick={() => restoreFromLocal(editor)} disabled={savedAt === null && !hasSaved()}>↺ Restore last</XenolithButton>
<span style={{ color: 'var(--xeno-muted)', fontSize: 11, lineHeight: 1.4 }}>
{savedAt ? '✓ Autosaved to your browser' : 'Edit the graph — it autosaves to localStorage.'}
</span>
<input ref={fileRef} type="file" accept="application/json,.json" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) uploadGraph(editor, f) }} />
</XenolithPanel>
)
}
/** Showcase: save & restore. Restores the last autosave on load, else the demo graph. */
export function SaveRestoreDemo() {
return (
<DemoStage>
<XenolithGraph className="xeno" resizeToWindow={false} onReady={initSaveRestore}>
<XenolithControls position="bottom-left" />
<SavePanel />
</XenolithGraph>
</DemoStage>
)
} // Persistence: the whole graph is JSON (editor.exportJSON / loadJSON). These framework-agnostic
// helpers cover the real save/restore surface — download/upload a .json file, and save/restore the
// last graph from localStorage. The host wires them to buttons; the only framework-reactive bit
// (autosave on every edit) stays in the host because it rides that framework's change subscription.
import { loadDemo } from './scene.js'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
export const SAVE_KEY = 'xeno:save-restore-demo'
function download(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = filename
document.body.appendChild(a); a.click(); a.remove()
setTimeout(() => URL.revokeObjectURL(url), 0)
}
export function downloadGraph(editor: XenolithEditor): void {
download(editor.exportJSON(), 'graph.json')
}
export function uploadGraph(editor: XenolithEditor, file: File): void {
void file.text().then((t) => {
try { editor.loadJSON(JSON.parse(t)); editor.fitView({ padding: 48, maxZoom: 1 }) } catch { /* bad file */ }
})
}
export function saveToLocal(editor: XenolithEditor): void {
localStorage.setItem(SAVE_KEY, JSON.stringify(editor.toJSON()))
}
export function hasSaved(): boolean {
return localStorage.getItem(SAVE_KEY) !== null
}
export function restoreFromLocal(editor: XenolithEditor): boolean {
const s = localStorage.getItem(SAVE_KEY)
if (!s) return false
try { editor.loadJSON(JSON.parse(s)); editor.fitView({ padding: 48, maxZoom: 1 }); return true } catch { return false }
}
/** Initial load: the demo graph, then the last autosave on top if present. */
export function initSaveRestore(editor: XenolithEditor): void {
loadDemo(editor)
restoreFromLocal(editor)
} <script setup lang="ts">
// Vue SFC — save & restore. Framework-agnostic helpers (download/upload/autosave/restore) live in
// the shared package; this SFC is just the Vue-specific glue: emit `@ready` for one-time setup,
// host the panel as a child component (uses `useEditor` inside).
import { XenolithGraph } from '@xenolithengine/graph-vue'
import { initSaveRestore } from '@xenolithengine/demo/save-restore'
import SavePanel from './SavePanel.vue'
</script>
<template>
<div class="app" style="position:absolute;inset:0;">
<XenolithGraph class="xeno" :resize-to-window="false" @ready="initSaveRestore">
<SavePanel />
</XenolithGraph>
</div>
</template> <script setup lang="ts">
// Autosave on every edit: subscribe to `graph:loaded` + `history:changed` (post-edit signal). The
// shared helpers do the actual IO; we only debounce + flash a status string.
import { ref } from 'vue'
import { useEditor, useEditorEvent } from '@xenolithengine/graph-vue'
import {
downloadGraph, uploadGraph, saveToLocal, restoreFromLocal, hasSaved,
} from '@xenolithengine/demo/save-restore'
const editor = useEditor()
const savedAt = ref<number | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
let timer: ReturnType<typeof setTimeout> | null = null
let first = true
function scheduleAutosave(): void {
if (first) { first = false; return }
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
const e = editor.value
if (!e) return
saveToLocal(e)
savedAt.value = Date.now()
}, 500)
}
useEditorEvent('history:changed', scheduleAutosave)
function onDownload(): void { const e = editor.value; if (e) downloadGraph(e) }
function onUpload(ev: Event): void {
const f = (ev.target as HTMLInputElement).files?.[0]
const e = editor.value
if (f && e) uploadGraph(e, f)
}
function onRestore(): void { const e = editor.value; if (e) restoreFromLocal(e) }
</script>
<template>
<div data-xeno-panel class="panel">
<p class="label">Save / restore</p>
<button class="btn" @click="onDownload">↓ Download .json</button>
<button class="btn" @click="fileInput?.click()">↑ Upload .json</button>
<button class="btn" :disabled="savedAt === null && !hasSaved()" @click="onRestore">↺ Restore last</button>
<span class="status">{{ savedAt ? '✓ Autosaved to your browser' : 'Edit the graph — it autosaves to localStorage.' }}</span>
<input ref="fileInput" type="file" accept="application/json,.json" hidden @change="onUpload" />
</div>
</template>
<style scoped>
.panel {
position: absolute; top: 12px; left: 12px;
display: flex; flex-direction: column; gap: 6px; width: 180px;
padding: 10px;
background: var(--xeno-panel, #1d1d1d);
border: 1px solid var(--xeno-border, #333);
border-radius: 8px;
font: 12px Inter, system-ui, sans-serif;
color: var(--xeno-text, #cfcfcf);
z-index: 5;
}
.label { margin: 0; font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--xeno-muted, #9a9a9a); }
.btn { width: 100%; padding: 6px 10px; font: inherit; font-size: 12px; cursor: pointer;
border: 1px solid var(--xeno-border, #333); border-radius: 6px;
background: transparent; color: var(--xeno-text, #cfcfcf); }
.btn:hover:not(:disabled) { background: #2a2a2a; }
.btn:disabled { opacity: .5; cursor: default; }
.status { color: var(--xeno-muted, #9a9a9a); font-size: 11px; line-height: 1.4; }
</style> // Persistence: the whole graph is JSON (editor.exportJSON / loadJSON). These framework-agnostic
// helpers cover the real save/restore surface — download/upload a .json file, and save/restore the
// last graph from localStorage. The host wires them to buttons; the only framework-reactive bit
// (autosave on every edit) stays in the host because it rides that framework's change subscription.
import { loadDemo } from './scene.js'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
export const SAVE_KEY = 'xeno:save-restore-demo'
function download(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = filename
document.body.appendChild(a); a.click(); a.remove()
setTimeout(() => URL.revokeObjectURL(url), 0)
}
export function downloadGraph(editor: XenolithEditor): void {
download(editor.exportJSON(), 'graph.json')
}
export function uploadGraph(editor: XenolithEditor, file: File): void {
void file.text().then((t) => {
try { editor.loadJSON(JSON.parse(t)); editor.fitView({ padding: 48, maxZoom: 1 }) } catch { /* bad file */ }
})
}
export function saveToLocal(editor: XenolithEditor): void {
localStorage.setItem(SAVE_KEY, JSON.stringify(editor.toJSON()))
}
export function hasSaved(): boolean {
return localStorage.getItem(SAVE_KEY) !== null
}
export function restoreFromLocal(editor: XenolithEditor): boolean {
const s = localStorage.getItem(SAVE_KEY)
if (!s) return false
try { editor.loadJSON(JSON.parse(s)); editor.fitView({ padding: 48, maxZoom: 1 }); return true } catch { return false }
}
/** Initial load: the demo graph, then the last autosave on top if present. */
export function initSaveRestore(editor: XenolithEditor): void {
loadDemo(editor)
restoreFromLocal(editor)
} <!-- Svelte 5 — save & restore: autosave rides the store bag's graphJSON (commit-time
snapshots), download/upload/restore through the shared helpers; the panel is the real
XenolithPanel portalled into the editor overlay. -->
<script lang="ts">
import { xenolith, createXenolithStores, createXenolithEditorContext } from '@xenolithengine/graph-svelte'
import { XenolithPanel, XenolithButton } from '@xenolithengine/graph-svelte/components'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import {
initSaveRestore, downloadGraph, uploadGraph, saveToLocal, restoreFromLocal, hasSaved,
} from '@xenolithengine/demo/save-restore'
const stores = createXenolithStores()
const panelEditor = createXenolithEditorContext()
const graphJSON = stores.graphJSON // destructure for the $-auto-subscription
let editor = $state<XenolithEditor | null>(null)
let savedAt = $state<number | null>(null)
let fileInput: HTMLInputElement
let timer: ReturnType<typeof setTimeout> | null = null
let first = true
const hasSave = hasSaved()
// Commit-time autosave: every committed change produces a fresh graphJSON snapshot.
$effect(() => {
const json = $graphJSON // tracked — the effect re-runs on every commit
if (!editor || !json || first) { first = false; return }
if (timer) clearTimeout(timer)
const e = editor
timer = setTimeout(() => { saveToLocal(e); savedAt = Date.now() }, 500)
})
</script>
<div
use:xenolith={{ resizeToWindow: false }}
onready={(e) => { editor = e.detail; panelEditor.set(e.detail); stores.editor.set(e.detail); initSaveRestore(e.detail) }}
style="position:absolute;inset:0"
></div>
<XenolithPanel position="top-left">
<XenolithButton onclick={() => editor && downloadGraph(editor)}>↓ Download .json</XenolithButton>
<XenolithButton onclick={() => fileInput.click()}>↑ Upload .json</XenolithButton>
<XenolithButton disabled={savedAt === null && !hasSave} onclick={() => editor && restoreFromLocal(editor)}>↺ Restore last</XenolithButton>
<span>{savedAt ? '✓ Autosaved to your browser' : 'Edit the graph — it autosaves to localStorage.'}</span>
<input bind:this={fileInput} type="file" accept="application/json,.json" hidden
onchange={(ev) => { const f = (ev.currentTarget as HTMLInputElement).files?.[0]; if (f && editor) uploadGraph(editor, f) }} />
</XenolithPanel> // Persistence: the whole graph is JSON (editor.exportJSON / loadJSON). These framework-agnostic
// helpers cover the real save/restore surface — download/upload a .json file, and save/restore the
// last graph from localStorage. The host wires them to buttons; the only framework-reactive bit
// (autosave on every edit) stays in the host because it rides that framework's change subscription.
import { loadDemo } from './scene.js'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
export const SAVE_KEY = 'xeno:save-restore-demo'
function download(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = filename
document.body.appendChild(a); a.click(); a.remove()
setTimeout(() => URL.revokeObjectURL(url), 0)
}
export function downloadGraph(editor: XenolithEditor): void {
download(editor.exportJSON(), 'graph.json')
}
export function uploadGraph(editor: XenolithEditor, file: File): void {
void file.text().then((t) => {
try { editor.loadJSON(JSON.parse(t)); editor.fitView({ padding: 48, maxZoom: 1 }) } catch { /* bad file */ }
})
}
export function saveToLocal(editor: XenolithEditor): void {
localStorage.setItem(SAVE_KEY, JSON.stringify(editor.toJSON()))
}
export function hasSaved(): boolean {
return localStorage.getItem(SAVE_KEY) !== null
}
export function restoreFromLocal(editor: XenolithEditor): boolean {
const s = localStorage.getItem(SAVE_KEY)
if (!s) return false
try { editor.loadJSON(JSON.parse(s)); editor.fitView({ padding: 48, maxZoom: 1 }); return true } catch { return false }
}
/** Initial load: the demo graph, then the last autosave on top if present. */
export function initSaveRestore(editor: XenolithEditor): void {
loadDemo(editor)
restoreFromLocal(editor)
} // Solid — save & restore: autosave rides the store bag's graphJSON accessor (commit-time
// snapshots) via createEffect; imperative IO through the shared helpers.
import { createEffect, createSignal } from 'solid-js'
import { xenolith, createXenolithStores } from '@xenolithengine/graph-solid'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import {
initSaveRestore, downloadGraph, uploadGraph, saveToLocal, restoreFromLocal, hasSaved,
} from '@xenolithengine/demo/save-restore'
export function SaveRestoreDemo() {
const stores = createXenolithStores()
const [editor, setEditor] = createSignal<XenolithEditor | null>(null)
const [savedAt, setSavedAt] = createSignal<number | null>(null)
const [hasSave] = createSignal(hasSaved())
let timer: ReturnType<typeof setTimeout> | null = null
let first = true
let fileInput: HTMLInputElement | undefined
// Commit-time autosave: every committed change produces a fresh graphJSON snapshot.
createEffect(() => {
const json = stores.graphJSON() // tracked signal read
const e = editor()
if (!e || !json || first) { first = false; return }
if (timer) clearTimeout(timer)
timer = setTimeout(() => { saveToLocal(e); setSavedAt(Date.now()) }, 500)
})
const btn = 'width:100%;padding:6px 10px;font-size:12px;cursor:pointer;border:1px solid var(--xeno-border);border-radius:6px;background:transparent;color:var(--xeno-text)'
return (
<div
use:xenolith={{ resizeToWindow: false }}
on:ready={(e) => { setEditor(e.detail); stores.setEditor(e.detail); initSaveRestore(e.detail) }}
style="position:absolute;inset:0"
>
<div style="position:absolute;top:12px;left:12px;display:flex;flex-direction:column;gap:6px;width:180px;padding:10px;background:var(--xeno-panel);border:1px solid var(--xeno-border);border-radius:8px;font:12px Inter,system-ui,sans-serif;color:var(--xeno-text);z-index:5">
<button style={btn} onClick={() => { const e = editor(); if (e) downloadGraph(e) }}>↓ Download .json</button>
<button style={btn} onClick={() => fileInput?.click()}>↑ Upload .json</button>
<button style={btn} disabled={savedAt() === null && !hasSave()} onClick={() => { const e = editor(); if (e) restoreFromLocal(e) }}>↺ Restore last</button>
<span style="color:#9a9a9a;font-size:11px">{savedAt() ? '✓ Autosaved to your browser' : 'Edit the graph — it autosaves.'}</span>
<input ref={fileInput} type="file" accept="application/json,.json" hidden
onChange={(ev) => { const f = ev.currentTarget.files?.[0]; const e = editor(); if (f && e) uploadGraph(e, f) }} />
</div>
</div>
)
} // Persistence: the whole graph is JSON (editor.exportJSON / loadJSON). These framework-agnostic
// helpers cover the real save/restore surface — download/upload a .json file, and save/restore the
// last graph from localStorage. The host wires them to buttons; the only framework-reactive bit
// (autosave on every edit) stays in the host because it rides that framework's change subscription.
import { loadDemo } from './scene.js'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
export const SAVE_KEY = 'xeno:save-restore-demo'
function download(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = filename
document.body.appendChild(a); a.click(); a.remove()
setTimeout(() => URL.revokeObjectURL(url), 0)
}
export function downloadGraph(editor: XenolithEditor): void {
download(editor.exportJSON(), 'graph.json')
}
export function uploadGraph(editor: XenolithEditor, file: File): void {
void file.text().then((t) => {
try { editor.loadJSON(JSON.parse(t)); editor.fitView({ padding: 48, maxZoom: 1 }) } catch { /* bad file */ }
})
}
export function saveToLocal(editor: XenolithEditor): void {
localStorage.setItem(SAVE_KEY, JSON.stringify(editor.toJSON()))
}
export function hasSaved(): boolean {
return localStorage.getItem(SAVE_KEY) !== null
}
export function restoreFromLocal(editor: XenolithEditor): boolean {
const s = localStorage.getItem(SAVE_KEY)
if (!s) return false
try { editor.loadJSON(JSON.parse(s)); editor.fitView({ padding: 48, maxZoom: 1 }); return true } catch { return false }
}
/** Initial load: the demo graph, then the last autosave on top if present. */
export function initSaveRestore(editor: XenolithEditor): void {
loadDemo(editor)
restoreFromLocal(editor)
} // Angular standalone host component — save & restore. Imperative IO via the shared helpers;
// autosave rides the service's on$('history:changed') stream. Editor reference is captured
// once from mount().
import {
AfterViewInit, Component, DestroyRef, ElementRef, OnDestroy, inject, signal, viewChild,
} from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { XenolithGraphService } from '@xenolithengine/graph-angular'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import {
initSaveRestore, downloadGraph, uploadGraph, saveToLocal, restoreFromLocal, hasSaved,
} from '@xenolithengine/demo/save-restore'
@Component({
selector: 'save-restore-demo',
standalone: true,
providers: [XenolithGraphService],
template: `
<div class="app" style="position:absolute;inset:0;">
<div #host class="xeno" style="position:absolute;inset:0;"></div>
<div data-xeno-panel class="panel">
<p class="label">Save / restore</p>
<button class="btn" (click)="download()">↓ Download .json</button>
<button class="btn" (click)="fileInput.click()">↑ Upload .json</button>
<button class="btn" [disabled]="savedAt() === null && !hasSaved" (click)="restore()">↺ Restore last</button>
<span class="status">{{ savedAt() ? '✓ Autosaved to your browser' : 'Edit the graph — it autosaves to localStorage.' }}</span>
<input #fileInput type="file" accept="application/json,.json" hidden (change)="onUpload($event)" />
</div>
</div>
`,
styles: [`
.panel { position:absolute; top:12px; left:12px; display:flex; flex-direction:column; gap:6px;
width:180px; padding:10px; background:var(--xeno-panel,#1d1d1d);
border:1px solid var(--xeno-border,#333); border-radius:8px;
font:12px Inter,system-ui,sans-serif; color:var(--xeno-text,#cfcfcf); z-index:5; }
.label { margin:0; font-size:11px; text-transform:uppercase; letter-spacing:.05em; color:#9a9a9a; }
.btn { width:100%; padding:6px 10px; font-size:12px; cursor:pointer;
border:1px solid var(--xeno-border,#333); border-radius:6px;
background:transparent; color:var(--xeno-text,#cfcfcf); }
.status { color:#9a9a9a; font-size:11px; line-height:1.4; }
`],
})
export class SaveRestoreDemoComponent implements AfterViewInit, OnDestroy {
private graph = inject(XenolithGraphService)
private host = viewChild.required<ElementRef<HTMLDivElement>>('host')
private destroyRef = inject(DestroyRef)
private editor: XenolithEditor | null = null
private timer: ReturnType<typeof setTimeout> | null = null
private first = true
savedAt = signal<number | null>(null)
readonly hasSaved = hasSaved()
async ngAfterViewInit(): Promise<void> {
const editor = await this.graph.mount(this.host().nativeElement, { resizeToWindow: false })
this.editor = editor
initSaveRestore(editor)
this.graph.on$('history:changed')
.pipe(takeUntilDestroyed(this.destroyRef)) // outside injection context (post-await) — pass DestroyRef explicitly
.subscribe(() => this.scheduleAutosave())
}
scheduleAutosave(): void {
if (this.first) { this.first = false; return }
if (!this.editor) return
if (this.timer) clearTimeout(this.timer)
const e = this.editor
this.timer = setTimeout(() => { saveToLocal(e); this.savedAt.set(Date.now()) }, 500)
}
download(): void { if (this.editor) downloadGraph(this.editor) }
restore(): void { if (this.editor) restoreFromLocal(this.editor) }
onUpload(ev: Event): void {
const f = (ev.target as HTMLInputElement).files?.[0]
if (f && this.editor) uploadGraph(this.editor, f)
}
ngOnDestroy(): void { this.graph.destroy() }
} // Persistence: the whole graph is JSON (editor.exportJSON / loadJSON). These framework-agnostic
// helpers cover the real save/restore surface — download/upload a .json file, and save/restore the
// last graph from localStorage. The host wires them to buttons; the only framework-reactive bit
// (autosave on every edit) stays in the host because it rides that framework's change subscription.
import { loadDemo } from './scene.js'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
export const SAVE_KEY = 'xeno:save-restore-demo'
function download(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = filename
document.body.appendChild(a); a.click(); a.remove()
setTimeout(() => URL.revokeObjectURL(url), 0)
}
export function downloadGraph(editor: XenolithEditor): void {
download(editor.exportJSON(), 'graph.json')
}
export function uploadGraph(editor: XenolithEditor, file: File): void {
void file.text().then((t) => {
try { editor.loadJSON(JSON.parse(t)); editor.fitView({ padding: 48, maxZoom: 1 }) } catch { /* bad file */ }
})
}
export function saveToLocal(editor: XenolithEditor): void {
localStorage.setItem(SAVE_KEY, JSON.stringify(editor.toJSON()))
}
export function hasSaved(): boolean {
return localStorage.getItem(SAVE_KEY) !== null
}
export function restoreFromLocal(editor: XenolithEditor): boolean {
const s = localStorage.getItem(SAVE_KEY)
if (!s) return false
try { editor.loadJSON(JSON.parse(s)); editor.fitView({ padding: 48, maxZoom: 1 }); return true } catch { return false }
}
/** Initial load: the demo graph, then the last autosave on top if present. */
export function initSaveRestore(editor: XenolithEditor): void {
loadDemo(editor)
restoreFromLocal(editor)
}