Skip to content

Repository files navigation

Vue Collaborative Diagram Editor (Syncfusion EJ2 + SignalR)

Vue TypeScript Syncfusion EJ2 SignalR

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.


Why Use This Vue Collaborative Diagram Editor?

1. Collaborate Instantly With Your Team

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.

2. Build Diagrams Faster With a Powerful Editor

Drag‑and‑drop shapes, connectors, undo/redo, and a polished UI help users create professional diagrams quickly and effortlessly.

3. Stay in Sync Across All Devices

SignalR ensures updates are delivered instantly and reliably.

4. Save, Load, and Revisit Your Work Easily

Export or import diagrams as JSON, download images, or print your diagram—giving users full control over saving and sharing their work.

5. Works Out of the Box and Easy to Extend

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.


Quick Navigation

DemoUI OverviewFeaturesArchitecture
PrerequisitesGetting StartedUsage
Collaboration: Vue ImplementationServer Hub (Minimal)Events & Message Flow
ContributingLicense


Demo

Live Demo

Collaborative Diagram Editor


UI Overview

  • 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

Features

  • ✔️ 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

Architecture

Collaboration Flow (Sequence Diagram)

Collaborative Sequence Diagram


Prerequisites

  • 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.ts when required.


Getting Started

1) Clone & Install

git clone https://github.com/syncfusion/ej2-showcase-vue-diagram-collaborative-editing.git
cd ej2-showcase-vue-diagram-collaborative-editing
npm install

2) Configure Environment

.env

VITE_SIGNALR_URL = 'https://diagram-collaborative-editing-hxhkc9dsbeb2f2et.eastus2-01.azurewebsites.net/diagramhub'
VITE_ROOM_NAME = 'ej2_vue_diagram'

3) Run the Vue App

npm run dev

Open → http://localhost:5173


Usage

  • 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

Collaboration: Vue Implementation

SignalR Service

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);
  }
}

Diagram Component (Vue 3 Composition API)

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>

Server Hub (Minimal)

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);
    }
}

Events & Message Flow

  • Diagram change detectedhistoryChange
  • Client computes deltas using getDiagramUpdates()
  • Client → Hub: send deltas via BroadcastToOtherClients
  • Hub → Clients: broadcast via ReceiveData
  • Clients apply updates using setDiagramUpdates()

Contributing

We welcome contributions! Fork the repo, make your changes, and submit a pull request. Please follow contribution best practices.


License

Syncfusion® libraries require a valid license key in production. See the Syncfusion licensing guide:
https://ej2.syncfusion.com/vue/documentation/licensing/overview


Support & Feedback

About

A real-time collaborative diagram editor built with Vue 3 and Syncfusion EJ2 Diagram, powered by SignalR for seamless multi-user editing, live updates, and peer selection visualization.

Resources

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages