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 + +![screenshot](./screenshot.png) 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 @@ + + JOINT_JS_LOGO_SYMBOL_RGB-pdf + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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"] +}