A fully interactive, real‑time collaborative diagram editor built with Vue 3 (Composition API), powered by Syncfusion EJ2 Diagram for rich visual editing and SignalR for live multi-user synchronization. This project demonstrates how to design a shared editing experience where multiple users can simultaneously create, modify, and annotate diagrams—complete with peer selection highlights and instant updates.
Multiple users can work on the same diagram at the same time—creating, editing, and selecting elements collaboratively with real‑time updates reflected across all connected clients.
Drag‑and‑drop shapes, connectors, undo/redo, and a polished UI help users create professional diagrams quickly and effortlessly.
SignalR ensures updates are delivered instantly and reliably.
Export or import diagrams as JSON, download images, or print your diagram—giving users full control over saving and sharing their work.
The codebase is clean, modular, and developer‑friendly, making it easy to customize tools, shapes, collaboration rules, and UI components for your own product needs.
Demo • UI Overview • Features • Architecture •
Prerequisites • Getting Started • Usage •
Collaboration: Vue Implementation • Server Hub (Minimal) • Events & Message Flow •
Contributing • License
- Live guest count showing active collaborators
- Toolbar for diagram quick actions
- Property panels for editing nodes and connectors appearance
- Symbol palette with drag‑and‑drop shapes
- ✔️ Real‑time collaboration powered with Redis and SignalR
- ✔️ Built using Syncfusion EJ2 Diagram with full editing capabilities
- ✔️ Peer‑selection highlighting with user badges
- ✔️ JSON import/export, image export, printing
- ✔️ Keyboard shortcuts
- ✔️ Customizable symbol palette with grouped shapes
- Node.js 20+
- Vite (bundled with project)
- .NET SDK 8.0+ (for hosting your own SignalR Hub)
- A Syncfusion license key (if applicable)
Register your Syncfusion license key in
main.tswhen required.
git clone https://github.com/syncfusion/ej2-showcase-vue-diagram-collaborative-editing.git
cd ej2-showcase-vue-diagram-collaborative-editing
npm install.env
VITE_SIGNALR_URL = 'https://diagram-collaborative-editing-hxhkc9dsbeb2f2et.eastus2-01.azurewebsites.net/diagramhub'
VITE_ROOM_NAME = 'ej2_vue_diagram'npm run devOpen → http://localhost:5173
- Use the Symbol Palette to drop shapes on the canvas
- Edit nodes and connectors using the property panel
- Open two browser windows to see real‑time collaboration in action
- Import/export diagrams using the toolbar
src\composables\useCollaborationHub.ts
import { HubConnection, HubConnectionBuilder, LogLevel, HttpTransportType } from '@microsoft/signalr';
import { environment } from '../config/environment';
export interface SelectionEvent {
connectionId: string;
userId?: string;
userName: string;
elementIds: string[];
selectorBounds?: any;
}
export class CollaborationService {
private hub?: HubConnection;
async connect(handlers: {
onConnected?: (id: string) => void;
onReceiveData?: (data: unknown) => void;
onUserJoined?: (message: string) => void;
onUserLeft?: (message: string) => void;
onPeerSelectionChanged?: (evt: SelectionEvent | null) => void;
} = {}): Promise<void> {
this.hub = new HubConnectionBuilder()
.withUrl(environment.signalRUrl, {
skipNegotiation: false,
transport: HttpTransportType.WebSockets
})
.withAutomaticReconnect([0, 1000, 5000, 30000])
.configureLogging(LogLevel.Information)
.build();
this.hub.on('OnConnectedAsync', handlers.onConnected);
this.hub.on('ReceiveData', handlers.onReceiveData);
this.hub.on('UserJoined', handlers.onUserJoined);
this.hub.on('UserLeft', handlers.onUserLeft);
this.hub.on('PeerSelectionChanged', handlers.onPeerSelectionChanged);
await this.hub.start();
}
async disconnect(): Promise<void> {
await this.hub?.stop();
}
async broadcastChanges(
changes: string[],
clientVersion: number,
editedElements: string[],
selectionBounds: SelectionEvent,
roomName: string
): Promise<void> {
await this.hub?.send(
'BroadcastToOtherClients',
changes,
clientVersion,
editedElements,
selectionBounds,
roomName
);
}
async selectElements(elementIds: string[], bounds: any): Promise<void> {
await this.hub?.invoke('SelectElements', elementIds, bounds);
}
}src\components\DiagramEditor.vue
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { DiagramComponent } from '@syncfusion/ej2-vue-diagrams';
import { CollaborationService, SelectionEvent } from '../services/collaboration.service';
const diagram = ref<any>(null);
const guestCount = ref(1);
const clientVersion = '1.0.0';
const roomName = 'default-room';
const collab = new CollaborationService();
const peerSelections = new Map<string, { userName: string; nodeIds: Set<string>; selectorBounds: any }>();
onMounted(async () => {
await collab.connect({
onConnected: () => console.log('Connected'),
onReceiveData: (data) => applyIncomingChanges(data),
onUserJoined: () => guestCount.value++,
onUserLeft: () => (guestCount.value = Math.max(1, guestCount.value - 1)),
onPeerSelectionChanged: (evt) => onPeerSelectionChanged(evt)
});
});
onBeforeUnmount(() => collab.disconnect());
function historyChange(args: any) {
const changes = diagram.value?.ej2Instances?.getDiagramUpdates(args) ?? [];
if (!changes.length) return;
const editedElements = (args.source ?? []).map((s: any) => s.id);
const { ids, bounds } = getSelectionData();
const selEvent: SelectionEvent = {
connectionId: '',
userName: '',
elementIds: ids,
selectorBounds: bounds
};
collab.broadcastChanges(changes, Number(clientVersion), editedElements, selEvent, roomName);
}
function applyIncomingChanges(data: any) {
try {
diagram.value?.ej2Instances?.setDiagramUpdates(data);
} catch (err) {
console.warn('Failed applying peer updates', err);
}
}
function selectionChange() {
const { ids, bounds } = getSelectionData();
collab.selectElements(ids, bounds);
}
function getSelectionData() {
const selected = diagram.value?.ej2Instances?.selectedItems;
const ids = selected?.nodes?.map((n: any) => n.id) ?? [];
const bounds = selected?.wrapper?.bounds ?? null;
return { ids, bounds };
}
function onPeerSelectionChanged(evt: SelectionEvent | null) {
if (!evt) return;
peerSelections.set(evt.connectionId, {
userName: evt.userName,
nodeIds: new Set(evt.elementIds),
selectorBounds: evt.selectorBounds
});
renderPeerBadges();
}
function renderPeerBadges() {
const svg = document.getElementById('diagram_diagramLayer') as SVGSVGElement | null;
if (!svg) return;
const old = document.getElementById('badge-layer');
old?.remove();
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
g.setAttribute('id', 'badge-layer');
peerSelections.forEach((peer) => {
const b = peer.selectorBounds?.bounds;
if (!b) return;
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rect.setAttribute('x', String(b.x));
rect.setAttribute('y', String(b.y));
rect.setAttribute('width', String(b.width));
rect.setAttribute('height', String(b.height));
rect.setAttribute('fill', 'none');
rect.setAttribute('stroke', '#3b82f6');
rect.setAttribute('stroke-width', '2');
g.appendChild(rect);
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.textContent = peer.userName;
text.setAttribute('x', String(b.x));
text.setAttribute('y', String(b.y - 4));
text.setAttribute('font-size', '10');
g.appendChild(text);
});
svg.appendChild(g);
}
</script>
<template>
<DiagramComponent
ref="diagram"
id="diagram"
width="100%"
height="100%"
@historyChange="historyChange"
@selectionChange="selectionChange"
/>
<div class="presence">Active guests: {{ guestCount }}</div>
</template>using Microsoft.AspNetCore.SignalR;
public class DiagramHub : Hub
{
public override async Task OnConnectedAsync()
{
await Clients.Caller.SendAsync("OnConnectedAsync", Context.ConnectionId);
await base.OnConnectedAsync();
}
public async Task BroadcastToOtherClients(
string[] changes,
string clientVersion,
string[]? editedElements,
object? selectionBounds,
string roomName)
{
await Clients.Others.SendAsync("ReceiveData", changes);
}
public async Task SelectElements(string[] elementIds, object bounds)
{
var evt = new {
connectionId = Context.ConnectionId,
userName = Context.User?.Identity?.Name ?? $"Guest-{Context.ConnectionId[..5]}",
elementIds,
selectorBounds = new { bounds }
};
await Clients.Others.SendAsync("PeerSelectionChanged", evt);
}
}- Diagram change detected →
historyChange - Client computes deltas using
getDiagramUpdates() - Client → Hub: send deltas via
BroadcastToOtherClients - Hub → Clients: broadcast via
ReceiveData - Clients apply updates using
setDiagramUpdates()
We welcome contributions! Fork the repo, make your changes, and submit a pull request. Please follow contribution best practices.
Syncfusion® libraries require a valid license key in production. See the Syncfusion licensing guide:
https://ej2.syncfusion.com/vue/documentation/licensing/overview
- Open issues: https://github.com/syncfusion/ej2-showcase-vue-diagram-collaborative-editing/issues
- Explore Syncfusion® Vue components: https://www.syncfusion.com/vue-components
- Community forums: https://www.syncfusion.com/forums

