diff --git a/realtime-collaboration/README.md b/realtime-collaboration/README.md
new file mode 100644
index 00000000..98a5cfca
--- /dev/null
+++ b/realtime-collaboration/README.md
@@ -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
+
+
diff --git a/realtime-collaboration/screenshot.png b/realtime-collaboration/screenshot.png
new file mode 100644
index 00000000..8101ec29
Binary files /dev/null and b/realtime-collaboration/screenshot.png differ
diff --git a/realtime-collaboration/ts/README.md b/realtime-collaboration/ts/README.md
new file mode 100644
index 00000000..11225ed9
--- /dev/null
+++ b/realtime-collaboration/ts/README.md
@@ -0,0 +1 @@
+# JointJS: Realtime Collaboration (TypeScript)
diff --git a/realtime-collaboration/ts/assets/jointjs-logo-black.svg b/realtime-collaboration/ts/assets/jointjs-logo-black.svg
new file mode 100644
index 00000000..e5cc83f9
--- /dev/null
+++ b/realtime-collaboration/ts/assets/jointjs-logo-black.svg
@@ -0,0 +1,91 @@
+
\ No newline at end of file
diff --git a/realtime-collaboration/ts/index.html b/realtime-collaboration/ts/index.html
new file mode 100644
index 00000000..5660377e
--- /dev/null
+++ b/realtime-collaboration/ts/index.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+ JointJS: Realtime Collaboration
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/realtime-collaboration/ts/package.json b/realtime-collaboration/ts/package.json
new file mode 100644
index 00000000..cd99c11e
--- /dev/null
+++ b/realtime-collaboration/ts/package.json
@@ -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"
+ }
+}
diff --git a/realtime-collaboration/ts/src/app.ts b/realtime-collaboration/ts/src/app.ts
new file mode 100644
index 00000000..bac82feb
--- /dev/null
+++ b/realtime-collaboration/ts/src/app.ts
@@ -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();
+}
+
+
+
diff --git a/realtime-collaboration/ts/src/collaboration/index.ts b/realtime-collaboration/ts/src/collaboration/index.ts
new file mode 100644
index 00000000..8984147a
--- /dev/null
+++ b/realtime-collaboration/ts/src/collaboration/index.ts
@@ -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();
+
+// ---- 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();
+
+ 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();
+
+ 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;
+ });
+}
diff --git a/realtime-collaboration/ts/src/collaboration/user-cursor.ts b/realtime-collaboration/ts/src/collaboration/user-cursor.ts
new file mode 100644
index 00000000..430fbbae
--- /dev/null
+++ b/realtime-collaboration/ts/src/collaboration/user-cursor.ts
@@ -0,0 +1,61 @@
+import { dia, V, mvc } from '@joint/core';
+
+// ---- Types ----
+
+export interface Cursor {
+ x: number;
+ y: number;
+}
+
+interface UserAppearance {
+ name: string;
+ color: string;
+}
+
+interface UserCursorOptions extends mvc.ViewOptions {
+ paper: dia.Paper;
+ user: UserAppearance;
+ cursor: Cursor;
+}
+
+// ---- UserCursor view ----
+
+export class UserCursor extends mvc.View {
+ paper!: dia.Paper;
+ user!: UserAppearance;
+ cursor!: Cursor;
+
+ preinitialize(options: UserCursorOptions) {
+ this.tagName = 'g';
+ this.svgElement = true;
+ this.paper = options.paper;
+ this.user = options.user;
+ this.cursor = options.cursor;
+ }
+
+ attributes = {
+ pointerEvents: 'none',
+ filter: 'drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.4))',
+ };
+
+ render(): this {
+ V('circle', { r: 5, fill: this.user.color }).appendTo(this.el);
+ V('text', {
+ x: 10,
+ y: 4,
+ fontSize: 12,
+ fontFamily: 'sans-serif',
+ fill: this.user.color,
+ })
+ .appendTo(this.el)
+ .text(this.user.name);
+
+ this.update();
+ this.vel.appendTo(this.paper.getLayerView(dia.Paper.Layers.FRONT).el);
+ return this;
+ }
+
+ update() {
+ this.vel.attr('transform', `translate(${this.cursor.x}, ${this.cursor.y})`);
+ }
+}
diff --git a/realtime-collaboration/ts/src/collaboration/user-list.ts b/realtime-collaboration/ts/src/collaboration/user-list.ts
new file mode 100644
index 00000000..a1a6918b
--- /dev/null
+++ b/realtime-collaboration/ts/src/collaboration/user-list.ts
@@ -0,0 +1,64 @@
+import type { User } from './user';
+
+interface AwarenessState {
+ user?: { name: string; color: string };
+ cursor?: { x: number; y: number };
+ selection?: string[];
+ editingName?: boolean;
+}
+
+let remoteEl: HTMLElement;
+
+export function init(localUser: User, onNameChange: (name: string) => void, onEditingChange: (editing: boolean) => void): void {
+ const usersEl = document.querySelector('#users')!;
+
+ const localEl = document.createElement('div');
+ localEl.innerHTML = `•${localUser.name}you
`;
+
+ remoteEl = document.createElement('div');
+
+ usersEl.appendChild(localEl);
+ usersEl.appendChild(remoteEl);
+
+ const nameEl = localEl.querySelector('.user-name') as HTMLElement;
+
+ nameEl.addEventListener('focus', () => {
+ onEditingChange(true);
+ });
+
+ nameEl.addEventListener('blur', () => {
+ onEditingChange(false);
+ const newName = nameEl.textContent?.trim() || localUser.name;
+ nameEl.textContent = newName;
+ if (newName !== localUser.name) {
+ onNameChange(newName);
+ }
+ });
+
+ nameEl.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ nameEl.blur();
+ }
+ });
+
+ document.addEventListener('pointerdown', (e) => {
+ if (document.activeElement === nameEl && e.target !== nameEl) {
+ nameEl.blur();
+ }
+ });
+}
+
+export function render(states: Map, localUser: User): void {
+ if (!remoteEl) return;
+
+ const remoteStrings: string[] = [];
+
+ states.forEach((state) => {
+ if (!state.user || state.user.name === localUser.name) return;
+ const editingClass = state.editingName ? ' user-name--editing' : '';
+ remoteStrings.push(`•${state.user.name}
`);
+ });
+
+ remoteEl.innerHTML = remoteStrings.join('');
+}
diff --git a/realtime-collaboration/ts/src/collaboration/user.ts b/realtime-collaboration/ts/src/collaboration/user.ts
new file mode 100644
index 00000000..3b125a82
--- /dev/null
+++ b/realtime-collaboration/ts/src/collaboration/user.ts
@@ -0,0 +1,31 @@
+import type { dia } from '@joint/core';
+
+import { UserCursor, type Cursor } from './user-cursor';
+
+// ---- User entity ----
+
+export class User {
+ name: string;
+ color: string;
+ private _cursorView: UserCursor | null = null;
+
+ constructor(name: string, color: string) {
+ this.name = name;
+ this.color = color;
+ }
+
+ updateCursor(paper: dia.Paper, cursor: Cursor): void {
+ if (this._cursorView) {
+ this._cursorView.cursor = cursor;
+ this._cursorView.update();
+ } else {
+ this._cursorView = new UserCursor({ paper, user: this, cursor });
+ this._cursorView.render();
+ }
+ }
+
+ removeCursor(): void {
+ this._cursorView?.remove();
+ this._cursorView = null;
+ }
+}
diff --git a/realtime-collaboration/ts/src/interactions.ts b/realtime-collaboration/ts/src/interactions.ts
new file mode 100644
index 00000000..2ccda3be
--- /dev/null
+++ b/realtime-collaboration/ts/src/interactions.ts
@@ -0,0 +1,380 @@
+import { dia, elementTools, linkTools, highlighters } from '@joint/core';
+
+import { graph, paper } from './app';
+import { provider, localUser, isInteractionBlocked, isEditingName, setCursorPosition, setEditingCell } from './collaboration';
+import { TextBox } from './shapes/text-box';
+
+let activeEditor: HTMLElement | null = null;
+let activeLinkView: dia.LinkView | null = null;
+
+const removeLinkTools = () => {
+ activeLinkView?.removeTools();
+ activeLinkView = null;
+};
+
+export function init() {
+
+ paper.on('blank:pointerdblclick', (_evt, x, y) => {
+ if (isEditingName()) return;
+ const box = new TextBox({
+ position: { x: x - 40, y: y - 20 },
+ attrs: { label: { text: 'New box' }},
+ });
+ graph.addCell(box);
+ });
+
+ paper.on('element:pointerdblclick', (elementView) => {
+ const cell = elementView.model as dia.Element;
+ if (isInteractionBlocked(cell.id)) return;
+ startEditing(cell);
+ });
+
+ paper.on('link:pointerdown', (linkView, evt, x, y) => {
+ removeLinkTools();
+ highlighters.mask.removeAll(paper, 'label-selection');
+ if (isInteractionBlocked(linkView.model.id)) return;
+ const labelEl = (evt.target as Element).closest('.label') as SVGElement | null;
+ if (labelEl) {
+ const bodyEl = (labelEl.querySelector('[joint-selector="labelBody"]') as SVGElement) ?? labelEl;
+ highlighters.mask.add(linkView, bodyEl, 'label-selection', {
+ attrs: { stroke: localUser.color, strokeWidth: 2 },
+ });
+ } else {
+ activeLinkView = linkView;
+ linkView.addTools(new dia.ToolsView({
+ tools: [
+ new linkTools.Remove({
+ distance: linkView.getClosestPointLength({ x, y }),
+ offset: -20,
+ markup: [{
+ tagName: 'circle',
+ selector: 'button',
+ attributes: {
+ r: 10,
+ fill: 'white',
+ stroke: '#e74c3c',
+ 'stroke-width': 1.5,
+ cursor: 'pointer',
+ },
+ }, {
+ tagName: 'path',
+ selector: 'icon',
+ attributes: {
+ d: 'M -3.5 -3.5 3.5 3.5 M -3.5 3.5 3.5 -3.5',
+ fill: 'none',
+ stroke: '#e74c3c',
+ 'stroke-width': 2,
+ 'stroke-linecap': 'round',
+ 'pointer-events': 'none',
+ },
+ }],
+ }),
+ ],
+ }));
+ }
+ });
+
+ paper.on('link:pointerdblclick', (linkView, evt, x, y) => {
+ const link = linkView.model as dia.Link;
+ if (isInteractionBlocked(link.id)) return;
+ const labelEl = (evt.target as Element).closest('.label');
+ if (labelEl) {
+ const labelIndex = parseInt(labelEl.getAttribute('label-idx') ?? '', 10);
+ if (isNaN(labelIndex)) return;
+ startLabelEditing(link, labelIndex, labelEl);
+ } else {
+ const ratio = linkView.getClosestPointRatio({ x, y });
+ const labelIndex = link.labels().length;
+ link.insertLabel(labelIndex, {
+ position: ratio,
+ attrs: { labelText: { text: '' }},
+ });
+ requestAnimationFrame(() => {
+ const newLabelEl = linkView.el.querySelector(`.label[label-idx="${labelIndex}"]`);
+ if (newLabelEl) startLabelEditing(link, labelIndex, newLabelEl);
+ });
+ }
+ });
+
+ paper.on('element:mouseenter', (elementView) => {
+ if (isInteractionBlocked(elementView.model.id)) return;
+ elementView.addTools(
+ new dia.ToolsView({
+ tools: [
+ new elementTools.Connect({
+ x: 'calc(w)',
+ y: 'calc(h / 2 + 10)',
+ markup: [{
+ tagName: 'circle',
+ selector: 'button',
+ attributes: {
+ r: 10,
+ fill: 'white',
+ stroke: '#333',
+ 'stroke-width': 1.5,
+ cursor: 'pointer',
+ },
+ }, {
+ tagName: 'path',
+ selector: 'icon',
+ attributes: {
+ d: 'M -4 -1 L 0 -1 L 0 -4 L 4 0 L 0 4 L 0 1 L -4 1 Z',
+ fill: '#333',
+ 'pointer-events': 'none',
+ },
+ }],
+ }),
+ new elementTools.Remove({
+ markup: [{
+ tagName: 'circle',
+ selector: 'button',
+ attributes: {
+ r: 10,
+ fill: 'white',
+ stroke: '#e74c3c',
+ 'stroke-width': 1.5,
+ cursor: 'pointer',
+ },
+ }, {
+ tagName: 'path',
+ selector: 'icon',
+ attributes: {
+ d: 'M -3.5 -3.5 3.5 3.5 M -3.5 3.5 3.5 -3.5',
+ fill: 'none',
+ stroke: '#e74c3c',
+ 'stroke-width': 2,
+ 'stroke-linecap': 'round',
+ 'pointer-events': 'none',
+ },
+ }],
+ }),
+ ],
+ })
+ );
+ });
+
+ paper.on('element:mouseleave', (elementView) => {
+ elementView.removeTools();
+ });
+
+ paper.el.addEventListener('mousemove', (evt) => {
+ const { x, y } = paper.clientToLocalPoint(evt.clientX, evt.clientY);
+ setCursorPosition(x, y);
+ });
+
+ paper.el.addEventListener('mouseleave', () => {
+ provider.awareness.setLocalStateField('cursor', null);
+ });
+
+ paper.on('cell:pointerdown', (cellView, evt) => {
+ if (!cellView.model.isLink()) {
+ highlighters.mask.removeAll(paper, 'label-selection');
+ removeLinkTools();
+ }
+ activeEditor?.blur();
+ if (isInteractionBlocked(cellView.model.id)) {
+ cellView.preventDefaultInteraction(evt);
+ return;
+ }
+ const isLabelClick = cellView.model.isLink() && !!(evt.target as Element).closest('.label');
+ if (isLabelClick) return;
+ provider.awareness.setLocalStateField('selection', [cellView.model.id]);
+ });
+
+ paper.on('blank:pointerdown', () => {
+ removeLinkTools();
+ highlighters.mask.removeAll(paper, 'label-selection');
+ provider.awareness.setLocalStateField('selection', []);
+ activeEditor?.blur();
+ });
+
+}
+
+function startEditing(cell: dia.Element) {
+ const { x, y } = cell.position();
+ const { width, height } = cell.size();
+ const topLeft = paper.localToClientPoint(x, y);
+ const bottomRight = paper.localToClientPoint(x + width, y + height);
+
+ setEditingCell(cell.id);
+ provider.awareness.setLocalStateField('selection', []);
+
+ const wrapper = document.createElement('div');
+ const editor = document.createElement('div');
+ activeEditor = editor;
+ editor.contentEditable = 'true';
+ editor.spellcheck = false;
+ editor.textContent = (cell.attr('label/text') as string) || '';
+
+ const centerX = (topLeft.x + bottomRight.x) / 2;
+ const centerY = (topLeft.y + bottomRight.y) / 2;
+ const minWidth = bottomRight.x - topLeft.x;
+ const minHeight = bottomRight.y - topLeft.y;
+
+ Object.assign(wrapper.style, {
+ position: 'fixed',
+ left: `${centerX}px`,
+ top: `${centerY}px`,
+ transform: 'translate(-50%, -50%)',
+ minWidth: `${minWidth}px`,
+ minHeight: `${minHeight}px`,
+ zIndex: '100',
+ border: `2px solid ${localUser.color}`,
+ borderRadius: '2px',
+ background: 'white',
+ boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ boxSizing: 'border-box',
+ });
+
+ const fontSize = cell.attr('label/fontSize') ?? 14;
+ const textWrap = cell.attr('label/textWrap') as { width?: number; height?: number } | undefined;
+ const { sx, sy } = paper.scale();
+ const paddingH = textWrap?.width != null && textWrap.width < 0 ? (Math.abs(textWrap.width) / 2) * sx : 8;
+ const paddingV = textWrap?.height != null && textWrap.height < 0 ? (Math.abs(textWrap.height) / 2) * sy : 4;
+
+ Object.assign(editor.style, {
+ outline: 'none',
+ textAlign: 'center',
+ fontFamily: 'sans-serif',
+ fontSize: `${fontSize}px`,
+ padding: `${paddingV}px ${paddingH}px`,
+ wordBreak: 'break-word',
+ });
+
+ wrapper.appendChild(editor);
+ document.body.appendChild(wrapper);
+
+ editor.focus();
+ const range = document.createRange();
+ range.selectNodeContents(editor);
+ const sel = window.getSelection();
+ sel?.removeAllRanges();
+ sel?.addRange(range);
+
+ let done = false;
+
+ function commit() {
+ if (done) return;
+ done = true;
+ cell.attr('label/text', editor.textContent?.trim() ?? '');
+ cleanup();
+ }
+
+ function cancel() {
+ if (done) return;
+ done = true;
+ cleanup();
+ }
+
+ function cleanup() {
+ activeEditor = null;
+ setEditingCell(null);
+ wrapper.remove();
+ }
+
+ editor.addEventListener('blur', commit);
+
+ editor.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') {
+ e.stopPropagation();
+ cancel();
+ } else if (e.key === 'Enter') {
+ e.preventDefault();
+ commit();
+ }
+ });
+}
+
+function startLabelEditing(link: dia.Link, labelIndex: number, labelGroupEl: Element) {
+ const bodyEl = labelGroupEl.querySelector('[joint-selector="labelBody"]');
+ const rect = (bodyEl ?? labelGroupEl).getBoundingClientRect();
+
+ highlighters.mask.removeAll(paper, 'label-selection');
+ setEditingCell(link.id);
+ provider.awareness.setLocalStateField('selection', []);
+
+ const wrapper = document.createElement('div');
+ const editor = document.createElement('div');
+ activeEditor = editor;
+ editor.contentEditable = 'true';
+ editor.spellcheck = false;
+ editor.textContent = (link.label(labelIndex).attrs?.['labelText']?.text as string) ?? '';
+
+ Object.assign(wrapper.style, {
+ position: 'fixed',
+ left: `${rect.left + rect.width / 2}px`,
+ top: `${rect.top + rect.height / 2}px`,
+ transform: 'translate(-50%, -50%)',
+ minWidth: `${rect.width}px`,
+ minHeight: `${rect.height}px`,
+ zIndex: '100',
+ border: `2px solid ${localUser.color}`,
+ borderRadius: '2px',
+ background: 'white',
+ boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ boxSizing: 'border-box',
+ });
+
+ Object.assign(editor.style, {
+ outline: 'none',
+ textAlign: 'center',
+ fontFamily: 'sans-serif',
+ fontSize: '12px',
+ padding: '2px 8px',
+ wordBreak: 'break-word',
+ });
+
+ wrapper.appendChild(editor);
+ document.body.appendChild(wrapper);
+
+ editor.focus();
+ const range = document.createRange();
+ range.selectNodeContents(editor);
+ const sel = window.getSelection();
+ sel?.removeAllRanges();
+ sel?.addRange(range);
+
+ let done = false;
+
+ function commit() {
+ if (done) return;
+ done = true;
+ const text = editor.textContent?.trim() ?? '';
+ if (text) {
+ link.label(labelIndex, { attrs: { labelText: { text }}});
+ } else {
+ link.removeLabel(labelIndex);
+ }
+ cleanup();
+ }
+
+ function cancel() {
+ if (done) return;
+ done = true;
+ cleanup();
+ }
+
+ function cleanup() {
+ activeEditor = null;
+ setEditingCell(null);
+ wrapper.remove();
+ }
+
+ editor.addEventListener('blur', commit);
+
+ editor.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') {
+ e.stopPropagation();
+ cancel();
+ } else if (e.key === 'Enter') {
+ e.preventDefault();
+ commit();
+ }
+ });
+}
diff --git a/realtime-collaboration/ts/src/main.ts b/realtime-collaboration/ts/src/main.ts
new file mode 100644
index 00000000..0aa321ec
--- /dev/null
+++ b/realtime-collaboration/ts/src/main.ts
@@ -0,0 +1,4 @@
+import './styles.css';
+import { init } from './app';
+
+init();
diff --git a/realtime-collaboration/ts/src/shapes/text-box.ts b/realtime-collaboration/ts/src/shapes/text-box.ts
new file mode 100644
index 00000000..d26ca800
--- /dev/null
+++ b/realtime-collaboration/ts/src/shapes/text-box.ts
@@ -0,0 +1,119 @@
+import { V, dia, util } from '@joint/core';
+
+// ---- Constants ----
+
+const PADDING_X = 20;
+const PADDING_Y = 16;
+const MIN_WIDTH = 80;
+const MIN_HEIGHT = 40;
+const MAX_CONTENT_WIDTH = 240;
+
+// ---- Text Measurement ----
+
+let _measureSvg: SVGSVGElement | null = null;
+
+function getMeasureSvg(): SVGSVGElement {
+ if (!_measureSvg) {
+ _measureSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ _measureSvg.setAttribute('width', '10000');
+ _measureSvg.setAttribute('height', '10000');
+ Object.assign(_measureSvg.style, {
+ position: 'absolute',
+ left: '-10000px',
+ top: '-10000px',
+ });
+ document.body.appendChild(_measureSvg);
+ }
+ return _measureSvg;
+}
+
+const LABEL_ATTRS = { 'font-size': 14, 'font-family': 'sans-serif' };
+
+function measureText(text: string): { width: number; height: number } {
+ const broken = util.breakText(text, { width: MAX_CONTENT_WIDTH }, LABEL_ATTRS);
+ const vText = V('text').attr(LABEL_ATTRS);
+ vText.text(broken, { textVerticalAnchor: 'middle' });
+ vText.appendTo(getMeasureSvg());
+ const { width, height } = vText.getBBox();
+ vText.remove();
+ return { width, height };
+}
+
+// ---- Markup ----
+
+const markup = util.svg/* xml */`
+
+
+`;
+
+// ---- Model ----
+
+export class TextBox extends dia.Element {
+ preinitialize() {
+ this.markup = markup;
+ }
+
+ defaults() {
+ return {
+ ...super.defaults,
+ type: 'custom.TextBox',
+ size: { width: MIN_WIDTH, height: MIN_HEIGHT },
+ attrs: {
+ body: {
+ width: 'calc(w)',
+ height: 'calc(h)',
+ rx: 6,
+ ry: 6,
+ strokeWidth: 2,
+ stroke: '#333333',
+ fill: '#ffffff',
+ },
+ label: {
+ text: '',
+ textAnchor: 'middle',
+ textVerticalAnchor: 'middle',
+ x: 'calc(0.5*w)',
+ y: 'calc(0.5*h)',
+ fontSize: 14,
+ fontFamily: 'sans-serif',
+ fill: '#333333',
+ textWrap: {
+ width: MAX_CONTENT_WIDTH,
+ height: 0,
+ ellipsis: false,
+ },
+ },
+ },
+ };
+ }
+
+ initialize(...args: any[]) {
+ super.initialize(...args);
+ this.on('change', this.onAttrChange, this);
+ this.setSizeFromContent();
+ }
+
+ private onAttrChange() {
+ if (!this.hasChanged('attrs')) return;
+ this.setSizeFromContent();
+ }
+
+ setSizeFromContent() {
+ const text = (this.attr('label/text') as string) || '';
+ const { width, height } = measureText(text);
+ const newWidth = Math.max(MIN_WIDTH, Math.ceil(width) + PADDING_X * 2);
+ const newHeight = Math.max(MIN_HEIGHT, Math.ceil(height) + PADDING_Y * 2);
+ const { width: currentW, height: currentH } = this.size();
+ if (currentW === newWidth && currentH === newHeight) return;
+ this.resize(newWidth, newHeight);
+ }
+}
+
+// ---- View ----
+
+export class TextBoxView extends dia.ElementView {
+ resize() {
+ super.resize();
+ this.update();
+ }
+}
diff --git a/realtime-collaboration/ts/src/styles.css b/realtime-collaboration/ts/src/styles.css
new file mode 100644
index 00000000..92c5ca98
--- /dev/null
+++ b/realtime-collaboration/ts/src/styles.css
@@ -0,0 +1,96 @@
+*, *::before, *::after {
+ box-sizing: border-box;
+}
+
+html, body {
+ margin: 0;
+ padding: 0;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ font-family: sans-serif;
+}
+
+#paper-container {
+ position: fixed;
+ inset: 0;
+}
+
+#users {
+ position: fixed;
+ top: 16px;
+ right: 16px;
+ z-index: 10;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ background: rgba(255, 255, 255, 0.85);
+ backdrop-filter: blur(4px);
+ border: 1px solid rgba(0, 0, 0, 0.1);
+ border-radius: 8px;
+ padding: 10px 14px;
+ min-width: 140px;
+}
+
+.user {
+ font-size: 14px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.user-local {
+ font-weight: 600;
+}
+
+.user-name {
+ border-radius: 3px;
+ padding: 1px 5px;
+ outline: none;
+ transition: background 0.15s;
+}
+
+.user-local .user-name {
+ cursor: text;
+}
+
+.joint-link .label {
+ cursor: pointer;
+}
+
+.user-name--editing {
+ background: rgba(255, 220, 0, 0.35);
+ border-radius: 3px;
+ padding: 1px 5px;
+}
+
+.user-local .user-name:hover {
+ background: rgba(0, 0, 0, 0.06);
+}
+
+.user-local .user-name:focus {
+ background: rgba(255, 255, 255, 0.95);
+ outline: 2px solid currentColor;
+ outline-offset: 1px;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
+}
+
+
+.user-you {
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ border: 1.5px solid currentColor;
+ border-radius: 4px;
+ padding: 0px 4px;
+ line-height: 1.6;
+}
+
+#logo {
+ position: fixed;
+ bottom: 16px;
+ right: 16px;
+ z-index: 10;
+ display: block;
+}
diff --git a/realtime-collaboration/ts/tsconfig.json b/realtime-collaboration/ts/tsconfig.json
new file mode 100644
index 00000000..9011fe23
--- /dev/null
+++ b/realtime-collaboration/ts/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "resolveJsonModule": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "types": ["vite/client"]
+ },
+ "include": ["src"]
+}