Extension Points
Sprotty is designed to be highly extensible, allowing you to customize and enhance its functionality to meet your specific requirements. Sprotty’s architecture is built around several key extension points that allow you to:
- Customize the rendering of diagram elements
- Add new behaviors through actions and commands
- Integrate with external data sources
- Modify the rendering pipeline with post-processors
- All configurable through the dependency injection system
Each extension point follows Sprotty’s architectural principles:
- Separation of concerns: Each extension has a specific responsibility
- Dependency injection: Extensions are loosely coupled and easily testable
- Type safety: All extensions are fully typed
- Composability: Multiple extensions can work together seamlessly
Sprotty uses InversifyJS for dependency injection, which provides a powerful and flexible way to configure and extend the framework.
The DI container manages all the services and components in Sprotty. Each module registers its dependencies, and the container resolves them at runtime.
import { Container, ContainerModule } from 'inversify';
import { TYPES, loadDefaultModules } from 'sprotty';
// Create a custom module
const customModule = new ContainerModule((bind, unbind, isBound, rebind) => {
// Register your custom services here
bind(TYPES.IModelFactory).to(CustomModelFactory).inSingletonScope();
bind(TYPES.IActionDispatcher).to(CustomActionDispatcher).inSingletonScope();
});
// Create and configure the container
const container = new Container();
loadDefaultModules(container); // Load Sprotty's default modules
container.load(customModule); // Load your custom module
Binding Types:
bind()
: Register a new servicerebind()
: Override an existing serviceunbind()
: Remove a service bindingisBound()
: Check if a service is already bound
Scopes:
inSingletonScope()
: Single instance shared across the containerinTransientScope()
: New instance created each timeinRequestScope()
: Single instance per request
Service Replacement:
const customModule = new ContainerModule((bind, unbind, isBound, rebind) => {
// Replace the default logger with a custom one
rebind(TYPES.ILogger).to(CustomLogger).inSingletonScope();
// Replace the default model factory
rebind(TYPES.IModelFactory).to(CustomModelFactory).inSingletonScope();
});
Service Addition:
const customModule = new ContainerModule((bind, unbind, isBound, rebind) => {
// Add a new service
bind(TYPES.ICustomService).to(CustomService).inSingletonScope();
// Register multiple implementations of the same interface
bind(TYPES.IActionHandler).to(CustomActionHandler1);
bind(TYPES.IActionHandler).to(CustomActionHandler2);
});
Conditional Binding:
const customModule = new ContainerModule((bind, unbind, isBound, rebind) => {
if (process.env.NODE_ENV === 'development') {
bind(TYPES.ILogger).to(VerboseLogger).inSingletonScope();
} else {
bind(TYPES.ILogger).to(ProductionLogger).inSingletonScope();
}
});
Sprotty’s rendering system is based on a model-view architecture where model elements define the data structure and views define how they are rendered.
Model elements extend SModelElementImpl
and define the data structure for your diagram elements.
import { SModelElementImpl, SShapeElementImpl } from 'sprotty';
export class CustomNode extends SShapeElementImpl {
customProperty: string = '';
size: { width: number; height: number } = { width: 100, height: 50 };
constructor() {
super();
this.type = 'custom-node';
}
}
export class CustomEdge extends SEdgeImpl {
customRouting: string = 'straight';
constructor() {
super();
this.type = 'custom-edge';
}
}
Views define how model elements are rendered. Sprotty uses JSX with a custom svg
function for SVG rendering.
SVG View Example:
/** @jsx svg */
import { svg } from 'sprotty/lib/jsx';
import { injectable } from 'inversify';
import { VNode } from 'snabbdom';
import { SShapeElementView, IViewArgs, RenderingContext } from 'sprotty';
import { CustomNode } from './model';
@injectable()
export class CustomNodeView extends SShapeElementView {
render(element: CustomNode, context: RenderingContext, args?: IViewArgs): VNode | undefined {
if (!this.isVisible(element, context)) {
return undefined;
}
return <g>
<rect class-sprotty-node={true}
class-custom-node={true}
class-mouseover={element.hoverFeedback}
class-selected={element.selected}
width={element.size.width}
height={element.size.height}
rx="5" ry="5"
fill="#ffffff"
stroke="#000000"
stroke-width="2" />
<text x={element.size.width / 2}
y={element.size.height / 2}
text-anchor="middle"
dominant-baseline="middle">
{element.customProperty}
</text>
{context.renderChildren(element)}
</g>;
}
}
Use configureModelElement
to register your custom model elements and views:
import { configureModelElement, TYPES } from 'sprotty';
const customModule = new ContainerModule((bind, unbind, isBound, rebind) => {
const context = { bind, unbind, isBound, rebind };
// Register custom node
configureModelElement(context, 'custom-node', CustomNode, CustomNodeView);
// Register custom edge
configureModelElement(context, 'custom-edge', CustomEdge, CustomEdgeView);
// Register with features
configureModelElement(context, 'interactive-node', CustomNode, CustomNodeView, {
enable: [selectFeature, moveFeature, hoverFeature]
});
});
Actions and commands are the primary mechanism for implementing custom behaviors in Sprotty.
- Actions: Describe what should happen (intent)
- Commands: Implement how it should happen (execution)
Actions are serializable objects that can be sent between client and server, while commands perform the actual state changes.
Actions extend the base Action
interface and define the data needed for the operation.
import { Action } from 'sprotty-protocol';
export interface CreateCustomNodeAction extends Action {
kind: 'createCustomNode';
nodeType: string;
position: { x: number; y: number };
parentId?: string;
properties?: Record<string, any>;
}
export interface UpdateCustomNodeAction extends Action {
kind: 'updateCustomNode';
nodeId: string;
properties: Record<string, any>;
}
export interface DeleteCustomNodeAction extends Action {
kind: 'deleteCustomNode';
nodeId: string;
}
export interface MoveCustomNodeAction extends Action {
kind: 'moveCustomNode';
nodeId: string;
newPosition: { x: number; y: number };
}
Commands implement the actual behavior and must support undo/redo operations.
Simple Command:
import { injectable, inject } from 'inversify';
import { Command, CommandExecutionContext, CommandResult, TYPES } from 'sprotty';
import { CreateCustomNodeAction } from './actions';
@injectable()
export class CreateCustomNodeCommand extends Command {
static KIND = 'createCustomNode';
private createdNodeId: string;
constructor(@inject(TYPES.Action) protected readonly action: CreateCustomNodeAction) {
super();
}
execute(context: CommandExecutionContext): CommandResult {
const newNode = this.createNode(context);
const parent = this.action.parentId ? context.root.index.getById(this.action.parentId) : context.root;
if (parent && 'children' in parent) {
parent.children.push(newNode);
}
this.createdNodeId = newNode.id;
return CommandResult.ok();
}
undo(context: CommandExecutionContext): CommandResult {
const node = context.root.index.getById(this.createdNodeId);
if (node && node.parent && 'children' in node.parent) {
const index = node.parent.children.indexOf(node);
if (index >= 0) {
node.parent.children.splice(index, 1);
}
}
return CommandResult.ok();
}
redo(context: CommandExecutionContext): CommandResult {
return this.execute(context);
}
private createNode(context: CommandExecutionContext): CustomNode {
const node = new CustomNode();
node.id = context.modelFactory.createId();
node.position = { ...this.action.position };
node.type = this.action.nodeType;
if (this.action.properties) {
Object.assign(node, this.action.properties);
}
return node;
}
}
Mergeable Command:
A Mergeable Command is a command that can accumulate subsequent commands of the same kind. For example, multiple subsequent move commands can be merged to yield a single command, so that they can be rolled back together by an undo.
import { injectable, inject } from 'inversify';
import { MergeableCommand, CommandExecutionContext, CommandResult, TYPES } from 'sprotty';
import { MoveCustomNodeAction } from './actions';
@injectable()
export class MoveCustomNodeCommand extends MergeableCommand {
static KIND = 'moveCustomNode';
private previousPosition?: { x: number; y: number };
constructor(@inject(TYPES.Action) protected readonly action: MoveCustomNodeAction) {
super();
}
execute(context: CommandExecutionContext): CommandResult {
const node = context.root.index.getById(this.action.nodeId) as CustomNode;
if (node) {
this.previousPosition = { ...node.position };
node.position = { ...this.action.newPosition };
}
return CommandResult.ok();
}
undo(context: CommandExecutionContext): CommandResult {
const node = context.root.index.getById(this.action.nodeId) as CustomNode;
if (node && this.previousPosition) {
node.position = { ...this.previousPosition };
}
return CommandResult.ok();
}
redo(context: CommandExecutionContext): CommandResult {
return this.execute(context);
}
merge(other: Command): boolean {
if (other instanceof MoveCustomNodeCommand && other.action.nodeId === this.action.nodeId) {
this.action.newPosition = { ...other.action.newPosition };
return true;
}
return false;
}
}
Action handlers connect actions to commands and can perform additional logic.
import { injectable } from 'inversify';
import { IActionHandler, ICommand } from 'sprotty';
import { CreateCustomNodeAction, UpdateCustomNodeAction, DeleteCustomNodeAction } from './actions';
@injectable()
export class CustomActionHandler implements IActionHandler {
handle(action: Action): ICommand | Action | void {
switch (action.kind) {
case 'createCustomNode':
return new CreateCustomNodeCommand(
action.nodeType,
action.position,
action.parentId,
action.properties
);
case 'updateCustomNode':
return new UpdateCustomNodeCommand(
action.nodeId,
action.properties
);
case 'deleteCustomNode':
return new DeleteCustomNodeCommand(action.nodeId);
case 'moveCustomNode':
return new MoveCustomCommand(
action.nodeId.
action.newPosition
)
}
}
}
Register your action handlers and commands in the DI container:
import { TYPES, configureCommand } from 'sprotty';
const customModule = new ContainerModule((bind, unbind, isBound, rebind) => {
const context = { bind, unbind, isBound, rebind };
// Register action handler
bind(TYPES.IActionHandler).to(CustomActionHandler);
// Register commands using configureCommand
configureCommand(context, CreateCustomNodeCommand);
configureCommand(context, UpdateCustomNodeCommand);
configureCommand(context, DeleteCustomNodeCommand);
configureCommand(context, MoveCustomNodeCommand);
});
Model sources are responsible for providing the diagram data to Sprotty. They can connect to various data sources and handle the communication between the client and external systems.
Model sources implement the ModelSource
interface and serve as the entry point for external data. They handle:
- Model requests from the client
- Model updates and synchronization
- Communication with external systems
- Bounds computation coordination (when
needsClientLayout
is enabled) - Action handling and forwarding (for selected action types)
For simple applications, you can extend LocalModelSource
to provide static or dynamic data.
import { injectable } from 'inversify';
import { LocalModelSource, SModelRootImpl } from 'sprotty';
@injectable()
export class CustomLocalModelSource extends LocalModelSource {
private currentModel: SModelRootImpl;
constructor() {
super();
this.currentModel = this.createInitialModel();
}
get model(): SModelRootImpl {
return this.currentModel;
}
async updateModel(newModel: SModelRootImpl): Promise<void> {
this.currentModel = newModel;
await this.updateRoot();
}
private createInitialModel(): SModelRootImpl {
const root = new SModelRootImpl();
root.id = 'root';
root.type = 'graph';
// Add your initial model elements here
const node = new CustomNode();
node.id = 'node1';
node.position = { x: 100, y: 100 };
root.children.push(node);
return root;
}
}
For applications that need to communicate with a server, extend DiagramServerProxy
.
import { injectable } from 'inversify';
import { DiagramServerProxy, SModelRootImpl } from 'sprotty';
@injectable()
export class CustomDiagramServer extends DiagramServerProxy {
constructor() {
super();
}
protected async requestModel(): Promise<SModelRootImpl> {
// Implement your server communication logic
const response = await fetch('/api/diagram/model');
const modelData = await response.json();
return this.createModelFromData(modelData);
}
protected async handleAction(action: Action): Promise<void> {
// Handle actions that need server processing
if (action.kind === 'createCustomNode') {
await this.sendToServer(action);
}
}
private async sendToServer(action: Action): Promise<void> {
await fetch('/api/diagram/action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(action)
});
}
private createModelFromData(data: any): SModelRootImpl {
// Convert your data format to Sprotty model
const root = new SModelRootImpl();
root.id = data.id;
root.type = data.type;
// Convert nodes
for (const nodeData of data.nodes) {
const node = new CustomNode();
node.id = nodeData.id;
node.position = nodeData.position;
node.customProperty = nodeData.customProperty;
root.children.push(node);
}
return root;
}
}
For real-time applications, you can create a WebSocket-based model source.
import { injectable } from 'inversify';
import { ModelSource, SModelRootImpl } from 'sprotty';
@injectable()
export class WebSocketModelSource extends ModelSource {
private ws: WebSocket;
private currentModel: SModelRootImpl;
constructor() {
super();
this.ws = new WebSocket('ws://localhost:8080/diagram');
this.setupWebSocket();
}
get model(): SModelRootImpl {
return this.currentModel;
}
handle(action: Action): ICommand | Action | void {
// Send actions to server via WebSocket
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(action));
}
}
async commitModel(newRoot: SModelRootImpl): Promise<SModelRootImpl> {
const previousModel = this.currentModel;
this.currentModel = newRoot;
return previousModel;
}
private setupWebSocket(): void {
this.ws.onopen = () => {
console.log('WebSocket connected');
this.requestModel();
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.handleServerMessage(data);
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
this.ws.onclose = () => {
console.log('WebSocket disconnected');
};
}
private handleServerMessage(data: any): void {
switch (data.type) {
case 'model':
this.currentModel = this.createModelFromData(data.model);
this.updateRoot();
break;
case 'action':
this.actionDispatcher.dispatch(data.action);
break;
}
}
private createModelFromData(data: any): SModelRootImpl {
// Implementation similar to previous example
}
}
Post-processors allow you to modify the rendered VNodes after they are created but before they are applied to the DOM. This is useful for adding custom styling, event handlers, or other modifications.
Post-processors implement the IVNodePostprocessor
interface and are called for each VNode during the rendering process. They can:
- Modify VNode attributes and properties
- Add event listeners
- Apply custom styling
- Add animations
- Perform DOM manipulations
import { injectable } from 'inversify';
import { VNode } from 'snabbdom';
import { IVNodePostprocessor, SModelElementImpl } from 'sprotty';
import { setAttr, setClass } from 'sprotty/lib/base/views/vnode-utils';
@injectable()
export class CustomStylingPostprocessor implements IVNodePostprocessor {
decorate(vnode: VNode, element: SModelElementImpl): VNode {
// Add custom CSS class based on element type
if (element.type === 'custom-node') {
setClass(vnode, 'custom-node-style', true);
}
// Add custom attributes
if (element.id.includes('important')) {
setAttr(vnode, 'data-important', 'true');
}
return vnode;
}
postUpdate(): void {
// Called after all VNodes are updated
// Use this for cleanup or global updates
}
}
import { injectable } from 'inversify';
import { VNode } from 'snabbdom';
import { IVNodePostprocessor, SModelElementImpl } from 'sprotty';
import { on } from 'sprotty/lib/base/views/vnode-utils';
@injectable()
export class CustomEventHandlerPostprocessor implements IVNodePostprocessor {
decorate(vnode: VNode, element: SModelElementImpl): VNode {
// Add custom click handler
if (element.type === 'custom-node') {
on(vnode, 'click', (event: Event) => {
event.preventDefault();
this.handleCustomClick(element, event);
});
}
// Add custom hover handlers
on(vnode, 'mouseenter', (event: Event) => {
this.handleMouseEnter(element, event);
});
on(vnode, 'mouseleave', (event: Event) => {
this.handleMouseLeave(element, event);
});
return vnode;
}
postUpdate(): void {
// Cleanup if needed
}
private handleCustomClick(element: SModelElementImpl, event: Event): void {
console.log('Custom click on element:', element.id);
// Dispatch custom action or perform other logic
}
private handleMouseEnter(element: SModelElementImpl, event: Event): void {
// Add hover effect
}
private handleMouseLeave(element: SModelElementImpl, event: Event): void {
// Remove hover effect
}
}
import { injectable } from 'inversify';
import { VNode } from 'snabbdom';
import { IVNodePostprocessor, SModelElementImpl } from 'sprotty';
import { setAttr } from 'sprotty/lib/base/views/vnode-utils';
@injectable()
export class AnimationPostprocessor implements IVNodePostprocessor {
private animatedElements = new Set<string>();
decorate(vnode: VNode, element: SModelElementImpl): VNode {
// Add animation attributes for new elements
if (!this.animatedElements.has(element.id)) {
setAttr(vnode, 'class', 'fade-in');
setAttr(vnode, 'style', {
animation: 'fadeIn 0.5s ease-in-out'
});
this.animatedElements.add(element.id);
}
// Add transition for position changes
if (element.position) {
setAttr(vnode, 'style', {
transition: 'transform 0.3s ease-in-out'
});
}
return vnode;
}
postUpdate(): void {
// Clean up animation tracking if needed
}
}
Register your post-processors in the DI container:
import { TYPES } from 'sprotty';
const customModule = new ContainerModule((bind, unbind, isBound, rebind) => {
// Register post-processors
bind(CustomStylingPostprocessor).toSelf().inSingletonScope();
bind(TYPES.IVNodePostprocessor).toService(CustomStylingPostprocessor);
bind(CustomEventHandlerPostprocessor).toSelf().inSingletonScope();
bind(TYPES.IVNodePostprocessor).toService(CustomEventHandlerPostprocessor);
bind(AnimationPostprocessor).toSelf().inSingletonScope();
bind(TYPES.IVNodePostprocessor).toService(AnimationPostprocessor);
});
By following these extension points and best practices, you can create powerful, maintainable, and performant customizations for your Sprotty-based diagramming applications.