Rendering Angular Components Inside JointJS Element Views
This tutorial explains step by step how to render Angular components inside JointJS element views using Angular's createComponent() API.
Overview​
JointJS renders elements as SVG. To embed Angular components, we use SVG's <foreignObject> element which allows HTML content inside SVG. We create a custom ElementView that:
- Renders Angular components inside a
foreignObjectdefined in the element's markup - Dynamically creates Angular components using
createComponent() - Manages the component lifecycle (create, update, destroy)
- Handles two-way data binding between JointJS model and Angular component
Step 1: Create the Angular Component​
First, create a standard Angular component that will be rendered inside the JointJS element view.
// components/element.component.ts
import {
Component,
Input,
Output,
EventEmitter,
ChangeDetectionStrategy,
HostBinding,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
export interface ElementData {
id: string;
label: string;
description: string;
type: 'default' | 'process' | 'decision';
}
@Component({
selector: 'app-element',
standalone: true,
imports: [FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './element.component.html',
styleUrls: ['./element.component.css'],
})
export class ElementComponent {
@Input() id = '';
@Input() label = '';
@Input() description = '';
@Input() type: 'default' | 'process' | 'decision' = 'default';
@Output() descriptionChanged = new EventEmitter<string>();
@HostBinding('class')
get hostClass(): string {
return `element-container type-${this.type}`;
}
onDescriptionChange(value: string): void {
this.descriptionChanged.emit(value);
}
}
The template (element.component.html):
<div class="element-header">{{ label }}</div>
<div class="element-body">
<span class="element-badge">{{ type }}</span>
<input
type="text"
[ngModel]="description"
(ngModelChange)="onDescriptionChange($event)"
/>
</div>
The styles (element.component.css):
:host {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background: white;
border-radius: 8px;
border: 1px solid #d1d5db;
overflow: hidden;
}
.element-header {
width: 100%;
padding: 8px 12px;
background: #1f2937;
color: white;
font-weight: 600;
font-size: 14px;
}
.element-body {
flex: 1;
padding: 12px;
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
}
Key points:
- Use
ChangeDetectionStrategy.OnPushfor better performance - Define
@Input()properties for data from JointJS model - Define
@Output()events to communicate changes back to JointJS - Use
@HostBindingfor dynamic class binding on the host element
Step 2: Define a Custom JointJS Element Model​
Before creating the Element view, we define a custom Element model whose markup includes a foreignObject with an HTML div container. This container is where Angular will render.
Use dia.Element's generic type parameter to define the attributes interface with typed data:
// models/angular-element.ts
import { dia } from '@joint/core';
import { ElementData } from '../components/element.component';
// Define attributes interface with typed data property
export interface AngularElementAttributes extends dia.Element.Attributes {
data: ElementData;
}
// Use generic type parameter for type-safe attribute access
export class AngularElement extends dia.Element<AngularElementAttributes> {
override defaults(): AngularElementAttributes {
return {
...super.defaults,
type: 'AngularElement',
size: { width: 200, height: 120 },
markup: [{
tagName: 'foreignObject',
selector: 'foreignObject',
attributes: {
overflow: 'visible',
},
children: [{
// HTML container for Angular component
tagName: 'div',
selector: 'container',
namespaceURI: 'http://www.w3.org/1999/xhtml',
style: {
width: '100%',
height: '100%',
}
}]
}],
data: {
id: '',
label: 'Node',
description: '',
type: 'default',
},
attrs: {
foreignObject: {
width: 'calc(w)',
height: 'calc(h)',
}
}
};
}
}
Key points:
- Extend
dia.Element.Attributesto define a custom attributes interface with typeddata - Pass the attributes interface as a generic type parameter to
dia.Element<T> - This provides type safety when calling
element.get('data')orelement.set('data', ...) - The
markupincludes aforeignObjectelement with an HTMLdivcontainer as a child - Use
namespaceURI: 'http://www.w3.org/1999/xhtml'for HTML elements inside foreignObject - Store component data in a
dataproperty - The
attrs.foreignObjectuses calc expressions to size the foreignObject to match the element model sizeelement.size().
Step 3: Create the Custom Element View​
Create a custom element view by extending dia.ElementView to render the Angular component.
// views/angular-element-view.ts
import {
ApplicationRef,
ComponentRef,
createComponent,
EnvironmentInjector,
} from '@angular/core';
import { Subscription } from 'rxjs';
import { dia } from '@joint/core';
import { AngularElement } from '../models/angular-element';
import { ElementComponent } from '../components/element.component';
export class AngularElementView extends dia.ElementView<AngularElement> {
private componentRef: ComponentRef<ElementComponent> | null = null;
private container: HTMLDivElement | null = null;
private subscription: Subscription | null = null;
// Static properties set on subclasses created by the factory function
static appRef?: ApplicationRef;
static injector?: EnvironmentInjector;
// Instance getters to access the subclass static properties
protected get appRef(): ApplicationRef | undefined {
return (this.constructor as typeof AngularElementView).appRef;
}
protected get injector(): EnvironmentInjector | undefined {
return (this.constructor as typeof AngularElementView).injector;
}
// ... methods below
}
Step 3.1: Render the Angular Component​
Override render() to create the Angular component:
override render(): this {
// Clean up any existing Angular component before re-rendering
this.destroyAngularComponent();
super.render();
this.renderAngularComponent();
return this;
}
private renderAngularComponent(): void {
const { model } = this;
// Find the container div created by markup
this.container = this.findNode('container') as HTMLDivElement;
// Create the Angular component using createComponent
const { appRef, injector } = this;
if (appRef && injector) {
this.componentRef = createComponent(ElementComponent, {
hostElement: this.container,
environmentInjector: injector,
});
// Attach to Angular's change detection tree first
appRef.attachView(this.componentRef.hostView);
// Set initial inputs and trigger change detection
this.updateAngularComponent(); // Defined in next step
this.componentRef.changeDetectorRef.detectChanges();
// Subscribe to outputs (store subscription for cleanup)
this.subscription = this.componentRef.instance.descriptionChanged.subscribe(
(description: string) => {
model.set('data', { ...model.get('data'), description });
}
);
}
}
Key points:
- Use
createComponent()withhostElementto render into a specific DOM element - Pass
EnvironmentInjectorfor dependency injection context - Call
ApplicationRef.attachView()to attach the view to Angular's change detection tree before running initial change detection - Subscribe to component outputs to update the JointJS model
Step 3.2: Update the Component on Model Changes​
Override update() to also sync model data when attrs or markup changes trigger a full update:
override update(): void {
super.update();
this.updateAngularComponent();
}
private updateAngularComponent(): void {
if (!this.componentRef) return;
const data = this.model.get('data');
// Update component inputs using setInput()
if (data) {
this.componentRef.setInput('id', data.id);
this.componentRef.setInput('label', data.label);
this.componentRef.setInput('description', data.description);
this.componentRef.setInput('type', data.type);
}
}
Important: Use componentRef.setInput() instead of directly setting properties. This properly triggers Angular's OnPush change detection. Direct property assignment (instance.prop = value) bypasses the input binding mechanism and won't trigger updates.
Step 3.3: Update the Component When Data Changes​
By default, update() is only called when so called "presentation attributes" change, such as attrs or markup. To react to changes in the model's data property, we need to add data to the presentation attributes.
We use a custom flag to signal data changes and handle it in confirmUpdate().
// Custom flag for data changes
static DATA_FLAG: string = 'DATA';
// Map 'data' changes to a custom flag
override presentationAttributes(): dia.CellView.PresentationAttributes {
return dia.ElementView.addPresentationAttributes({
data: AngularElementView.DATA_FLAG
});
}
// Handle the custom DATA flag
override confirmUpdate(flag: number, options: { [key: string]: unknown }): number {
let flags = super.confirmUpdate(flag, options);
if (this.hasFlag(flags, AngularElementView.DATA_FLAG)) {
this.updateAngularComponent();
flags = this.removeFlag(flags, AngularElementView.DATA_FLAG);
}
return flags;
}
This tells JointJS to call confirmUpdate() whenever model.set('data', ...) is called, which then updates the Angular component inputs.
Step 3.4: Clean Up on Remove​
Override onRemove() to properly destroy the Angular component:
override onRemove(): void {
this.destroyAngularComponent();
super.onRemove();
}
private destroyAngularComponent(): void {
this.subscription?.unsubscribe();
this.subscription = null;
if (this.componentRef) {
this.appRef?.detachView(this.componentRef.hostView);
this.componentRef.destroy();
this.componentRef = null;
}
}
Step 3.5: Create a Factory Function​
Create a factory function to inject Angular's DI context:
export function createAngularElementView(
appRef: ApplicationRef,
injector: EnvironmentInjector
): typeof AngularElementView {
// Return a new subclass to avoid global mutable state
// when multiple papers are created
return class extends AngularElementView {
static override appRef = appRef;
static override injector = injector;
};
}
Step 4: Configure the Paper​
Set up the JointJS Paper to use the custom view:
@Component({
selector: 'app-root',
standalone: true,
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent implements AfterViewInit {
private appRef = inject(ApplicationRef);
private injector = inject(EnvironmentInjector);
ngAfterViewInit(): void {
// Create the custom view class with Angular DI
const AngularElementView = createAngularElementView(
this.appRef,
this.injector
);
// Define cell namespace
const cellNamespace = {
...shapes,
AngularElement,
};
// Create the paper
this.paper = new dia.Paper({
el: this.paperContainer.nativeElement,
model: this.graph,
cellViewNamespace: {
...cellNamespace,
AngularElementView,
},
// Allow default browser behavior (e.g. blur inputs) when clicking
// on the paper's blank area or on element/link views
preventDefaultBlankAction: false,
preventDefaultViewAction: false,
// ... other options
});
}
}
Key points:
- Inject
ApplicationRefandEnvironmentInjectorin the component - Call
createAngularElementView()to create the view class with DI context - Register the view in
cellViewNamespacewith the naming convention{ElementType}View(For more info, see docs) - Set
preventDefaultBlankActionandpreventDefaultViewActiontofalseto allow default browser behavior (like blurring inputs) when clicking on the paper
Step 5: Create Elements and Update Data​
Create elements and update their data:
// Create an element
const element = new AngularElement({
position: { x: 50, y: 50 },
data: {
id: 'element-1',
label: 'Start',
description: 'Beginning of the flow',
type: 'default',
},
});
this.graph.addCell(element);
// Update element data (triggers view update)
element.set('data', {
...element.get('data'),
description: 'Updated description'
});
Step 6: Using Highlighters and Tools​
JointJS highlighters and element tools work with Angular components as usual. Here's an example of managing selection state with highlighters.
Maintain Selection State​
Keep track of selected cell IDs in your component:
export class AppComponent {
selection: dia.Cell.ID[] = [];
private static readonly SELECTION_HIGHLIGHTER_ID = 'selection';
}
Add/Remove Highlighters and Tools on Selection Change​
Use highlighters.addClass to apply a CSS class to selected cells, and add element tools:
import { dia, elementTools, highlighters, shapes } from '@joint/core';
setSelection(cellIds: dia.Cell.ID[]): void {
const { paper } = this;
const highlighterId = AppComponent.SELECTION_HIGHLIGHTER_ID;
// Remove all existing selection highlighters and tools
highlighters.addClass.removeAll(paper, highlighterId);
paper.removeTools();
// Update selection
this.selection = cellIds;
// Add highlighters to newly selected cells
for (const id of this.selection) {
const cellView = paper.findViewByModel(id);
if (cellView) {
highlighters.addClass.add(cellView, 'root', highlighterId, {
className: 'selected'
});
}
}
// Add element tools when a single element is selected
if (this.selection.length === 1) {
const cellView = paper.findViewByModel(this.selection[0]);
if (cellView && cellView.model.isElement()) {
const toolsView = new dia.ToolsView({
tools: [
new elementTools.Connect({
x: 'calc(w + 15)',
y: 'calc(h / 2)',
}),
],
});
(cellView as dia.ElementView).addTools(toolsView);
}
}
}
Handle Click Events​
Wire up the selection to pointer events:
// Handle cell selection
this.paper.on('cell:pointerclick', (cellView: dia.CellView) => {
this.setSelection([cellView.model.id]);
});
this.paper.on('blank:pointerclick', () => {
this.setSelection([]);
});
Define Selection Styles in CSS​
/* The highlighter adds 'selected' class to the element view's root */
.selected .element-container {
outline: 3px solid #4E81EE;
outline-offset: 3px;
}
Benefits​
- Full JointJS compatibility - Highlighters and tools work seamlessly with Angular components
- Flexibility - Easy to support multi-selection by adding multiple IDs to the array
- Built-in API - JointJS highlighters handle the DOM manipulation
Summary​
The integration works through these mechanisms:
- foreignObject in markup - The element's markup includes a
foreignObjectwith an HTML container, preserving support for ports, highlighters, and tools - Dynamic component creation -
createComponent()renders the Angular component into the container element - Change detection integration -
appRef.attachView()includes the component in Angular's change detection - Model-to-view sync -
presentationAttributes()triggersupdate()on data changes, which usessetInput()to update the component - View-to-model sync - Component outputs are subscribed to update the JointJS model
- Lifecycle management - Components are properly destroyed when elements are removed
- Full JointJS features - Highlighters, tools, and other JointJS features work as expected
This pattern allows you to use the full power of Angular components (dependency injection, reactive forms, animations, etc.) inside JointJS diagrams.
Notes​
- You can apply the same pattern to support multiple element types, each with its own Angular component and corresponding view.
- Instead of creating a brand-new element, you can extend an existing JointJS shape, add a
foreignObjectto its markup, and render an Angular component only for a specific part of the element (for example, a custom label or inspector area). - Angular components that render SVG are also supported. In that case, the root
gSVG element can be used as the host element forcreateComponent().