A self-contained, Cytoscape.js-based Labeled Property Graph (LPG) editor. Drop it into any container element and get a full graph editing experience — canvas, sidebar, palettes, properties panel, stylesheet editor, layout controls, and undo/redo — with no external UI dependencies.
- Self-contained UI — renders its own sidebar, tabs, palettes, and context menu inside whatever container you give it
- Labeled Property Graph editing — nodes and edges have labels, typed properties, and CSS styles
- Per-element CSS styling stored as graph data, auto-applied via dynamic Cytoscape stylesheets
- Edge handles for drawing edges by hovering over a node
- Layout algorithms — built-in support for cose, dagre, cola, grid, circle, concentric, breadthfirst, and any other registered Cytoscape layout
- Undo / redo with 50-level history
- Cut / copy / paste with cascading paste offsets
- Multi-selection with box-select; Delete/Backspace removes selected elements
- Import / export in Cytoscape.js JSON format
- Node types and edge type palettes — click a type to select it; double-click the canvas to add a node of the selected type; add, edit, and delete types directly in the palette pane
- Palette saved with each file — the
.boxesfile format embeds the full palette, so any saved graph can be reused as a template - Template system — built-in templates (Blank, Arrows, Ontology or RDF File) are JSON files; custom templates are ordinary
.boxesfiles - Keyboard shortcuts — Ctrl+Z/Y (undo/redo), Ctrl+C/X/V (copy/cut/paste), Delete/Backspace
- Context menu with Edit Properties, Cut, Copy, Paste, Duplicate, Delete
- Vue 3 and React 18 thin-wrapper components
- Docker support for the web demonstrator
| Package | Description |
|---|---|
boxes-core |
Core editor library — use this in any JS project |
boxes-vue |
Vue 3 component wrapper |
boxes-react |
React 18 component wrapper |
boxes-web |
Express web server running the live demonstrator |
boxes-electron |
Standalone desktop app |
The fastest way to try Boxes:
docker compose upThen open http://localhost:3001 in your browser.
To rebuild after source changes:
docker compose up --buildnpm install
npm run build # build all packages
npm run web:dev # start the web demo server on :3001Install the package:
npm install boxes-coreimport { BoxesEditor } from 'boxes-core';
const editor = new BoxesEditor(document.getElementById('graph'), {
elements: {
nodes: [
{ data: { id: 'n1', label: 'Alice' }, position: { x: 100, y: 150 } },
{ data: { id: 'n2', label: 'Bob' }, position: { x: 300, y: 150 } }
],
edges: [
{ data: { id: 'e1', source: 'n1', target: 'n2', label: 'knows' } }
]
}
});The container must have an explicit width and height (e.g. width: 100vw; height: 100vh). BoxesEditor fills it completely with the canvas on the left and a collapsible sidebar on the right.
new BoxesEditor(container, {
// ── Option A: start from a template or saved file ─────────────────────
// Pass a pre-loaded template / .boxes JSON object. All fields below are
// extracted from it; any explicit option listed under Option B overrides
// the corresponding template field.
template: loadedTemplateObject,
// ── Option B: explicit fields (can be combined with template) ─────────
// Initial graph elements in Cytoscape.js JSON format
elements: { nodes: [], edges: [] },
// Initial stylesheet rules (Cytoscape.js selector/style pairs)
style: [
{ selector: 'node[type="person"]', style: { 'background-color': '#4A90E2' } }
],
// Initial Cytoscape layout (applied when no positions are present)
layout: { name: 'cose' },
// Node type palette entries
nodeTypes: [
{
id: 'person',
label: 'Person',
color: '#4A90E2', // fill colour shown in palette and applied to new nodes
borderColor: '#2A6AB2',
shape: 'ellipse', // Cytoscape node shape
data: {} // extra data merged into each new node of this type
}
],
// Edge type palette entries
edgeTypes: [
{
id: 'knows',
label: 'knows',
color: '#E24A4A', // line/arrow colour shown in palette
lineStyle: 'solid' // 'solid' | 'dashed' | 'dotted'
}
],
// Document title and description (saved with the file)
title: 'My Graph',
description: 'An example graph',
// Set false to disable the edge-handle magnet (useful for read-only views)
edgeHandle: true
})// Query
editor.getNodeTypes(); // returns array of node type objects
editor.getEdgeTypes(); // returns array of edge type objects
// Add
editor.addNodeType({ id: 'company', label: 'Company', color: '#FFD700', borderColor: '#B8860B', shape: 'roundrectangle', data: {} });
editor.addEdgeType({ id: 'employs', label: 'employs', color: '#888', lineStyle: 'dashed' });
// Update (by id)
editor.updateNodeType('company', { color: '#FFA500' });
editor.updateEdgeType('employs', { lineStyle: 'solid' });
// Remove
editor.removeNodeType('company');
editor.removeEdgeType('employs');All palette mutations re-render the palette and fire a paletteChanged event.
Tip — building templates from the UI: Switch to the Palette tab in the sidebar. Hover over any palette item to reveal ✎ (edit) and × (delete) buttons. Use + Add node type / + Add edge type at the bottom of each section to add new types. Once the palette is set up the way you want, save the file — the palette is stored in the
.boxesfile and will be restored when the file is reopened.
// Add a node (returns the new node's JSON)
editor.addNode(
{ label: 'Carol', type: 'person' }, // data
{ x: 200, y: 250 } // position (optional)
);
// Add a node of a palette type
editor.addNodeOfType('person', { x: 200, y: 250 });
// Add an edge
editor.addEdge('n1', 'n2', { label: 'likes' });
// Remove by ID
editor.removeElement('n1');
// Remove all currently selected elements
editor.removeSelected();
// Update data / style
editor.updateElement('n1', { label: 'Alice (updated)' });
editor.updateElementStyle('n1', { 'background-color': '#ff0000' });
// Replace all elements
editor.loadElements({
nodes: [{ data: { id: 'a', label: 'A' } }],
edges: []
});
// Get current elements
const { nodes, edges } = editor.getElements();// Full serialisable snapshot (elements + palette + stylesheet + layout + context)
const snapshot = editor.exportGraph();
localStorage.setItem('graph', JSON.stringify(snapshot));
// Restore (palette, stylesheet, title, and description are all restored)
const saved = JSON.parse(localStorage.getItem('graph'));
editor.importGraph(saved);Any saved .boxes file can be loaded as a starting template — it already carries the full palette, stylesheet, and context:
import { defaultTemplates, loadTemplateFromUrl } from 'boxes-core';
// Built-in templates (blank, arrows, owl-ontology)
const editor = new BoxesEditor(container, { template: defaultTemplates['arrows'] });
// Load a custom template JSON from a URL
const myTemplate = await loadTemplateFromUrl('/templates/my-domain.boxes');
const editor2 = new BoxesEditor(container, { template: myTemplate });The template format is identical to the exportGraph() snapshot — there is no separate template format. A .boxes file is a template.
// List what's registered
const layouts = editor.getAvailableLayouts();
// e.g. ['cose', 'dagre', 'cola', 'grid', 'circle', 'concentric', 'breadthfirst', 'preset', 'random', 'null']
// Run a layout
editor.runLayout({ name: 'dagre', rankDir: 'TB' });
editor.runLayout({ name: 'cose', animate: true });editor.undo(); // returns true if something was undone
editor.redo();
editor.canUndo(); // boolean
editor.canRedo();// Copy selected nodes (+ edges between them)
editor.copy();
// Cut (copy then delete)
editor.cut();
// Paste with a 20px cascade offset per call
editor.paste();
editor.canPaste(); // boolean — true when clipboard is non-empty// Get/set the full user stylesheet
const rules = editor.getStylesheet();
editor.setStylesheet([
{ selector: 'node', style: { 'background-color': '#888' } }
]);
// CRUD individual rules
editor.addStyleRule({ selector: '.highlight', style: { 'border-width': 4 } });
editor.updateStyleRule(0, 'node', { 'background-color': '#333' });
editor.removeStyleRule(0);editor.getSelected(); // array of Cytoscape element objects
editor.selectElements(['n1', 'n2']); // select by IDconst cy = editor.getCytoscape();
// Full access to Cytoscape.js API
cy.fit();
cy.zoom(1.5);editor.on('nodeAdded', ({ node }) => console.log('added', node));
editor.on('edgeAdded', ({ edge }) => console.log('added', edge));
editor.on('elementRemoved', ({ element }) => console.log('removed', element));
editor.on('select', ({ target }) => console.log('selected', target.id()));
editor.on('historyChange',({ canUndo, canRedo }) => updateButtons(canUndo, canRedo));
editor.on('clipboardChange', ({ hasClipboard }) => updatePasteButton(hasClipboard));
editor.off('nodeAdded', handler); // remove a specific handler| Event | Payload |
|---|---|
change |
{ type, target } — any graph mutation |
select / unselect |
{ target } |
selectionChange |
{ type, target, selected[] } |
nodeAdded |
{ node } (JSON) |
edgeAdded |
{ edge } (JSON) |
elementRemoved |
{ element } (JSON) |
elementUpdated |
{ element } (JSON) |
styleUpdated |
{ stylesheet } |
layoutRun |
{ name, options } |
elementsLoaded |
{ elements } |
graphImported |
{ graphData } |
paletteChanged |
{ nodeTypes, edgeTypes } |
edgeHandleComplete |
{ sourceId, targetId, edgeType } |
historyChange |
{ canUndo, canRedo } |
clipboardChange |
{ hasClipboard } |
paste |
{ nodes, edges } |
editor.destroy(); // removes DOM, event listeners, and the Cytoscape instanceInstall:
npm install boxes-vue boxes-core<template>
<BoxesEditor
:elements="elements"
:node-types="nodeTypes"
:edge-types="edgeTypes"
style="width: 100%; height: 100vh"
@node-added="onNodeAdded"
@history-change="onHistoryChange"
/>
</template>
<script setup>
import { ref } from 'vue';
import { BoxesEditor } from 'boxes-vue';
const elements = ref({
nodes: [
{ data: { id: 'n1', label: 'Alice' }, position: { x: 100, y: 150 } },
{ data: { id: 'n2', label: 'Bob' }, position: { x: 300, y: 150 } }
],
edges: [
{ data: { id: 'e1', source: 'n1', target: 'n2', label: 'knows' } }
]
});
const nodeTypes = [
{ id: 'person', label: 'Person', color: '#4A90E2', shape: 'ellipse' }
];
const edgeTypes = [
{ id: 'knows', label: 'knows', color: '#E24A4A', lineStyle: 'solid' }
];
function onNodeAdded({ node }) {
console.log('node added:', node);
}
function onHistoryChange({ canUndo, canRedo }) {
console.log('undo:', canUndo, 'redo:', canRedo);
}
</script><template>
<button @click="saveGraph">Save</button>
<button @click="loadGraph">Load</button>
<BoxesEditor ref="editorRef" :elements="elements" style="height: 80vh" />
</template>
<script setup>
import { ref } from 'vue';
import { BoxesEditor } from 'boxes-vue';
const editorRef = ref(null);
const elements = ref({ nodes: [], edges: [] });
function saveGraph() {
const data = editorRef.value?.exportGraph();
localStorage.setItem('graph', JSON.stringify(data));
}
function loadGraph() {
const saved = JSON.parse(localStorage.getItem('graph') || 'null');
if (saved) editorRef.value?.importGraph(saved);
}
</script>| Prop | Type | Default | Description |
|---|---|---|---|
template |
Object |
— | Pre-loaded template / .boxes JSON; individual props below take precedence |
elements |
Object |
{ nodes:[], edges:[] } |
Initial graph elements |
style |
Array |
[] |
Stylesheet rules |
layout |
Object |
{ name: 'preset' } |
Layout config |
nodeTypes |
Array |
[] |
Node palette types |
edgeTypes |
Array |
[] |
Edge palette types |
Watching elements: The component watches
elementsfor changes and callsloadElementsautomatically. This replaces the entire graph, so prefer using the ref API (addNode,addEdge) for incremental updates.
The Vue component forwards all core events as kebab-case Vue events: @node-added, @edge-added, @element-removed, @history-change, @clipboard-change, etc. See the event table in the core section for the full list.
All methods from the core API are available via the template ref: addNode, addEdge, removeElement, removeSelected, updateElement, runLayout, exportGraph, importGraph, undo, redo, copy, cut, paste, getStylesheet, setStylesheet, getCytoscape, getNodeTypes, getEdgeTypes, addNodeType, updateNodeType, removeNodeType, addEdgeType, updateEdgeType, removeEdgeType, and more.
Install:
npm install boxes-react boxes-coreimport { BoxesEditor } from 'boxes-react';
export default function App() {
const nodeTypes = [
{ id: 'person', label: 'Person', color: '#4A90E2', shape: 'ellipse' }
];
const edgeTypes = [
{ id: 'knows', label: 'knows', color: '#E24A4A', lineStyle: 'solid' }
];
return (
<BoxesEditor
elements={{
nodes: [
{ data: { id: 'n1', label: 'Alice' }, position: { x: 100, y: 150 } },
{ data: { id: 'n2', label: 'Bob' }, position: { x: 300, y: 150 } }
],
edges: [
{ data: { id: 'e1', source: 'n1', target: 'n2', label: 'knows' } }
]
}}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onNodeAdded={({ node }) => console.log('added', node)}
onHistoryChange={({ canUndo, canRedo }) => console.log(canUndo, canRedo)}
style={{ width: '100%', height: '100vh' }}
/>
);
}import { useRef } from 'react';
import { BoxesEditor } from 'boxes-react';
export default function App() {
const editorRef = useRef(null);
const handleSave = () => {
const data = editorRef.current?.exportGraph();
localStorage.setItem('graph', JSON.stringify(data));
};
const handleLoad = () => {
const saved = JSON.parse(localStorage.getItem('graph') || 'null');
if (saved) editorRef.current?.importGraph(saved);
};
const handleUndo = () => editorRef.current?.undo();
const handleRedo = () => editorRef.current?.redo();
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
<div>
<button onClick={handleSave}>Save</button>
<button onClick={handleLoad}>Load</button>
<button onClick={handleUndo}>Undo</button>
<button onClick={handleRedo}>Redo</button>
</div>
<BoxesEditor
ref={editorRef}
style={{ flex: 1 }}
onEdgeHandleComplete={({ sourceId, targetId }) =>
console.log(`Edge: ${sourceId} → ${targetId}`)
}
/>
</div>
);
}| Prop | Type | Default | Description |
|---|---|---|---|
template |
Object |
— | Pre-loaded template / .boxes JSON; individual props below take precedence |
elements |
Object |
{ nodes:[], edges:[] } |
Initial graph elements |
style |
Array |
[] |
Stylesheet rules |
layout |
Object |
{ name: 'preset' } |
Layout config |
nodeTypes |
Array |
[] |
Node palette types |
edgeTypes |
Array |
[] |
Edge palette types |
onChange |
Function |
— | Called on any graph mutation |
onSelect |
Function |
— | Called when an element is selected |
onUnselect |
Function |
— | Called when an element is deselected |
onSelectionChange |
Function |
— | Called on any selection change |
onNodeAdded |
Function |
— | Called when a node is added |
onEdgeAdded |
Function |
— | Called when an edge is added |
onElementRemoved |
Function |
— | Called when an element is removed |
onElementUpdated |
Function |
— | Called when element data/style changes |
onStyleUpdated |
Function |
— | Called when the stylesheet changes |
onLayoutRun |
Function |
— | Called after a layout runs |
onElementsLoaded |
Function |
— | Called after loadElements |
onGraphImported |
Function |
— | Called after importGraph |
onPaletteChanged |
Function |
— | { nodeTypes, edgeTypes } — called when palette is edited |
onEdgeHandleComplete |
Function |
— | Called when a new edge is drawn |
onHistoryChange |
Function |
— | { canUndo, canRedo } |
onClipboardChange |
Function |
— | { hasClipboard } |
Note: The editor initialises once on mount. Prop changes (other than
elements/layoutin the Vue wrapper) do not re-initialise the editor. Use therefAPI to drive graph changes programmatically.
addNode, addEdge, addNodeOfType, removeElement, removeSelected, updateElement, updateElementStyle, runLayout, getAvailableLayouts, getElements, loadElements, exportGraph, importGraph, getSelected, selectElements, getCytoscape, getNodeTypes, getEdgeTypes, addNodeType, updateNodeType, removeNodeType, addEdgeType, updateEdgeType, removeEdgeType, getEdgeType, setEdgeType, getStylesheet, setStylesheet, addStyleRule, updateStyleRule, removeStyleRule, undo, redo, canUndo, canRedo, copy, cut, paste, canPaste.
{
nodes: [
{
data: {
id: 'n1', // required — must be unique
label: 'Alice', // displayed as the node label
type: 'person', // optional — used for palette-based styling
_style: { // optional — per-element CSS overrides
'background-color': '#4A90E2',
'width': '80px'
}
// ...any other application-specific properties
},
position: { x: 100, y: 150 } // omit to let the layout place the node
}
],
edges: [
{
data: {
id: 'e1',
source: 'n1', // required
target: 'n2', // required
label: 'knows',
_style: { 'line-color': '#E24A4A' }
}
}
]
}exportGraph() returns an object suitable for JSON serialisation and re-loading with importGraph(). This is also the .boxes file format and the template format — they are all the same thing.
{
version: '1.0.0',
title: 'My Graph', // optional document title
description: 'Description text', // optional description
palette: {
nodeTypes: [ // node type palette entries
{ id: 'person', label: 'Person', color: '#4A90E2', borderColor: '#2A6AB2',
shape: 'ellipse', data: {} }
],
edgeTypes: [ // edge type palette entries
{ id: 'knows', label: 'knows', color: '#E24A4A', lineStyle: 'solid' }
]
},
elements: { nodes: [...], edges: [...] }, // full Cytoscape JSON
userStylesheet: [{ selector, style }, ...],
lastLayout: { name: 'dagre', options: { rankDir: 'TB' } },
context: {} // namespace context (e.g. JSON-LD prefixes)
}All fields are restored by importGraph(). Files saved by older versions of Boxes that contain a templateId field but no palette will fall back to the named built-in template for their palette.
| Shortcut | Action |
|---|---|
| Double-click canvas | Add node of selected type |
| Ctrl/⌘ + Z | Undo |
| Ctrl/⌘ + Y / Shift+Z | Redo |
| Ctrl/⌘ + C | Copy selected |
| Ctrl/⌘ + X | Cut selected |
| Ctrl/⌘ + V | Paste |
| Delete / Backspace | Remove selected elements |
boxes/
├── packages/
│ ├── core/ # Core editor — all UI and graph logic
│ ├── vue/ # Thin Vue 3 wrapper
│ ├── react/ # Thin React 18 wrapper
│ ├── web/ # Express server + browser demonstrator
│ └── electron/ # Desktop app
├── Dockerfile
├── docker-compose.yml
└── package.json
npm install # install all workspaces
npm run build # build every package
npm run build --workspace=packages/core # build one package
npm run dev --workspace=packages/core # watch modenpm test # run all tests
npm run test:watch # watch modeTests that exercise Cytoscape rendering require a real browser (
canvas.getContextis not available in happy-dom). Core graph-logic tests pass; rendering tests are best run in a headless Chromium via Playwright or similar.
npm run lint
npm run lint:fix- Self-contained core —
BoxesEditorrenders its entire UI (canvas + sidebar) into the container using_injectCSS()and_createUI(). Framework wrappers are single-<div>mount points. - Stylesheet strategy — per-element styles are stored in
data._style;_generateElementStyles()turns these intonode[id="n1"]CSS rules and merges them with user stylesheet rules each time the graph changes. - Undo / redo — each mutation calls
_pushUndo()which serialises the full graph viaexportGraph()(stripping transient edgehandles classes). History is capped at 50 entries. - Edge handles — uses
cytoscape-edgehandleswith a custom DOM handle div positioned over the bottom of hovered nodes. - Layouts — registered layouts are discovered at runtime by probing the Cytoscape extensions registry; the layout panel is built dynamically.
- Template / palette system — templates are plain JSON files stored in
packages/core/src/templates/and bundled into the JS module. The.boxesfile format is the template format:exportGraph()always writespalette,title,description, andcontext.importGraph()always restores them. The Vite build copies template JSON files todist/templates/so they are also fetch-accessible at/core/templates/*.json.
Apache 2.0