Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions realtime-collaboration/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# JointJS: Realtime Collaboration

This demo is also available online at [jointjs.com](https://jointjs.com/demos/genogram).

## Available Versions

- [TypeScript](./ts/)

## Screenshot

![screenshot](./screenshot.png)
Binary file added realtime-collaboration/screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions realtime-collaboration/ts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# JointJS: Realtime Collaboration (TypeScript)
91 changes: 91 additions & 0 deletions realtime-collaboration/ts/assets/jointjs-logo-black.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 22 additions & 0 deletions realtime-collaboration/ts/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description"
content="Example of using JointJS with realtime collaboration.">
<title>JointJS: Realtime Collaboration</title>
</head>

<body id="app">
<div id="users"></div>
<div id="paper-container"></div>
<a target="_blank" href="https://www.jointjs.com">
<img id="logo" src="./assets/jointjs-logo-black.svg" rel="noopener noreferrer" width="200" height="50"/>
</a>
<!-- Application files: -->
<script type="module" src="/src/main.ts"></script>
</body>

</html>
21 changes: 21 additions & 0 deletions realtime-collaboration/ts/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "@joint/demo-realtime-collaboration-ts",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test-layout": "npx tsx scripts/test-layout.cts"
},
"devDependencies": {
"typescript": "~5.8.2",
"vite": "^7.3.1"
},
"dependencies": {
"@joint/core": "4.2.4",
"yjs": "13.6.27",
"y-webrtc": "10.3.0"
}
}
106 changes: 106 additions & 0 deletions realtime-collaboration/ts/src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { dia, shapes } from '@joint/core';
import { init as initCollaboration } from './collaboration';
import { init as initInteractions } from './interactions';
import { TextBox, TextBoxView } from './shapes/text-box';

const textMargin = 5;

export const cellNamespace = { ...shapes, custom: { TextBox, TextBoxView }};

export let graph: dia.Graph;

export let paper: dia.Paper;

export function init() {

graph = new dia.Graph({}, { cellNamespace });

paper = new dia.Paper({
el: document.getElementById('paper-container'),
width: window.innerWidth,
height: window.innerHeight,
overflow: true,
model: graph,
cellViewNamespace: cellNamespace,
gridSize: 10,
drawGrid: { name: 'dot', args: { color: '#ccc' }},
async: true,
linkPinning: false,
defaultAnchor: {
name: 'center',
args: { useModelGeometry: true },
},
defaultConnectionPoint: {
name: 'rectangle',
args: { useModelGeometry: true },
},
});

window.addEventListener('resize', () => {
paper.setDimensions(window.innerWidth, window.innerHeight);
});

const defaultLabel = {
markup: [
{ tagName: 'rect', selector: 'labelBody' },
{ tagName: 'text', selector: 'labelText' },
],
attrs: {
labelBody: {
ref: 'labelText',
fill: '#fff',
fillOpacity: 0.9,
stroke: '#333',
strokeWidth: 0.5,
width: `calc(w + ${textMargin * 2})`,
height: `calc(h + ${textMargin * 2})`,
x: `calc(x - ${textMargin})`,
y: `calc(y - ${textMargin})`,
},
labelText: {
fontSize: 12,
fontFamily: 'sans-serif',
textAnchor: 'middle',
textVerticalAnchor: 'middle',
fill: '#333',
strokeWidth: 2,
},
},
};

const makeLink = (id: string, sourceId: dia.Cell.ID, targetId: dia.Cell.ID, labelText?: string) =>
new shapes.standard.Link({
id,
source: { id: sourceId },
target: { id: targetId },
defaultLabel,
...(labelText ? { labels: [{ position: 0.5, attrs: { labelText: { text: labelText }}}] } : {}),
});

const web = new TextBox({ id: 'web', position: { x: 120, y: 300 }, attrs: { label: { text: 'Web App' }}});
const gateway = new TextBox({ id: 'gateway', position: { x: 360, y: 300 }, attrs: { label: { text: 'API Gateway' }}});
const products = new TextBox({ id: 'products', position: { x: 620, y: 150 }, attrs: { label: { text: 'Product Service' }}});
const orders = new TextBox({ id: 'orders', position: { x: 620, y: 300 }, attrs: { label: { text: 'Order Service' }}});
const users = new TextBox({ id: 'users', position: { x: 620, y: 450 }, attrs: { label: { text: 'User Service' }}});
const prodsDb = new TextBox({ id: 'prodsDb', position: { x: 900, y: 150 }, attrs: { label: { text: 'Products DB' }}});
const ordersDb = new TextBox({ id: 'ordersDb', position: { x: 900, y: 300 }, attrs: { label: { text: 'Orders DB' }}});
const usersDb = new TextBox({ id: 'usersDb', position: { x: 900, y: 450 }, attrs: { label: { text: 'Users DB' }}});

graph.addCells([
web, gateway, users, products, orders, usersDb, prodsDb, ordersDb,
makeLink('l-web-gw', web.id, gateway.id, 'HTTP'),
makeLink('l-gw-users', gateway.id, users.id, 'REST'),
makeLink('l-gw-prods', gateway.id, products.id, 'REST'),
makeLink('l-gw-ords', gateway.id, orders.id, 'REST'),
makeLink('l-users-db', users.id, usersDb.id, 'SQL'),
makeLink('l-prods-db', products.id, prodsDb.id, 'SQL'),
makeLink('l-ords-db', orders.id, ordersDb.id, 'SQL'),
makeLink('l-ords-usr', orders.id, users.id, 'Validate'),
]);

initCollaboration();
initInteractions();
}



197 changes: 197 additions & 0 deletions realtime-collaboration/ts/src/collaboration/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import * as Y from 'yjs';
import { WebrtcProvider } from 'y-webrtc';
import { g, highlighters } from '@joint/core';

import { graph, paper } from '../app';
import { User } from './user';
import { init as initUserList, render as renderUserList } from './user-list';
import type { dia } from '@joint/core';

export { User };

const remoteUsers = new Map<string, User>();

// ---- Exports (assigned in init) ----

export let provider: WebrtcProvider;
export let localUser: User;

// ---- Init ----

export function init() {

// Yjs sync

const ydoc = new Y.Doc();
const ymap = ydoc.getMap<dia.Cell.JSON>();

graph.getCells().forEach((cell) => {
ymap.set(cell.id as string, cell.toJSON({ ignoreDefaults: false }));
});

graph.on('change', (cell, opt) => {
if (opt.remote) return;
ymap.set(cell.id as string, cell.toJSON({ ignoreDefaults: false }));
});

graph.on('add', (cell, opt) => {
if (opt.remote) return;
ymap.set(cell.id as string, cell.toJSON({ ignoreDefaults: false }));
});

graph.on('remove', (cell, opt) => {
if (opt.remote) return;
ymap.delete(cell.id);
});

ymap.observe((event) => {
event.keysChanged.forEach((key) => {
const cellData = ymap.get(key);
if (!cellData) {
const cell = graph.getCell(key);
if (cell) graph.removeCell(cell, { remote: true });
return;
}
const cell = graph.getCell(key);
if (cell) {
const { id, type, ...restData } = cellData;
cell.set(restData, { remote: true });
} else {
graph.addCell(cellData);
}
});
});

// WebRTC provider

provider = new WebrtcProvider('jointjs-yjs', ydoc, {
signaling: ['wss://jointjs-y-webrtc.duckdns.org'],
});

// Local user

const colors = ['#FF5733', '#33FF57', '#3357FF', '#F333FF', '#33FFF5'];

localUser = new User(
'User ' + g.random(0, 1000).toString(),
colors[Math.floor(Math.random() * colors.length)],
);
provider.awareness.setLocalStateField('user', localUser);

initUserList(localUser, (newName) => {
localUser.name = newName;
provider.awareness.setLocalStateField('user', localUser);
}, (editing) => {
provider.awareness.setLocalStateField('editingName', editing);
});

// Awareness: cursors, selection highlights, user list

provider.awareness.on('change', () => {
const states = provider.awareness.getStates();
const activeNames = new Set<string>();

highlighters.mask.removeAll(paper, 'selection');
highlighters.mask.removeAll(paper, 'editing');

states.forEach((state) => {
if (!state.user) return;
const isLocal = state.user.name === localUser.name;

if (!isLocal) {
activeNames.add(state.user.name);

let user = remoteUsers.get(state.user.name);
if (!user) {
user = new User(state.user.name, state.user.color);
remoteUsers.set(state.user.name, user);
}

if (state.cursor) {
user.updateCursor(paper, state.cursor);
} else {
user.removeCursor();
}
}

const selection = state.selection || [];
selection.forEach((id: string) => {
const cell = graph.getCell(id);
if (!cell) return;
highlighters.mask.add(
cell.findView(paper),
cell.isLink() ? 'line' : 'body',
'selection',
{ attrs: { stroke: state.user.color, strokeWidth: 3 }}
);
});

if (!isLocal && state.editing) {
const cell = graph.getCell(state.editing);
if (cell) {
highlighters.mask.add(
cell.findView(paper),
cell.isLink() ? 'line' : 'body',
'editing',
{ attrs: { stroke: state.user.color, strokeWidth: 3, strokeDasharray: '6 3' }}
);
}
}
});

for (const [name, user] of remoteUsers) {
if (!activeNames.has(name)) {
user.removeCursor();
remoteUsers.delete(name);
}
}

renderUserList(states, localUser);
});
}

export function setCursorPosition(x: number, y: number) {
provider.awareness.setLocalStateField('cursor', { x, y });
}

export function isRemotelySelected(cellId: dia.Cell.ID) {
return Array.from(provider.awareness.getStates()).some(([, state]) => {
if (state.user === localUser || !state.selection) return false;
return state.selection.includes(cellId);
});
}

export function setEditingCell(cellId: dia.Cell.ID | null) {
provider.awareness.setLocalStateField('editing', cellId);
}

export function isEditingName() {
return !!provider.awareness.getLocalState()?.editingName;
}

export function isInteractionBlocked(cellId: dia.Cell.ID) {
return (
isEditingName() ||
isRemotelySelected(cellId) ||
isRemotelyEditing(cellId) ||
isConnectedToEditingLink(cellId)
);
}

function isConnectedToEditingLink(cellId: dia.Cell.ID): boolean {
for (const [, state] of provider.awareness.getStates()) {
if (state.user?.name === localUser.name || !state.editing) continue;
const editingCell = graph.getCell(state.editing);
if (!editingCell?.isLink()) continue;
const link = editingCell as dia.Link;
if (link.getSourceCell()?.id === cellId || link.getTargetCell()?.id === cellId) return true;
}
return false;
}

export function isRemotelyEditing(cellId: dia.Cell.ID) {
return Array.from(provider.awareness.getStates()).some(([, state]) => {
if (state.user === localUser || !state.editing) return false;
return state.editing === cellId;
});
}
Loading
Loading