Build real-time collaborative editing experiences with Multisynq’s Model-View architecture
Create powerful collaborative editing experiences where multiple users can work together on documents, code, designs, and more in real-time using Multisynq’s deterministic synchronization.
Multisynq collaborative editing uses the Model-View pattern where:
Track active users and their cursors automatically:
class CollaborativeModel extends Multisynq.Model {
init() {
this.document = { content: "", cursors: new Map() };
this.users = new Map();
// Built-in user lifecycle events
this.subscribe(this.sessionId, "view-join", this.handleUserJoin);
this.subscribe(this.sessionId, "view-exit", this.handleUserLeave);
// Custom collaboration events
this.subscribe(this.sessionId, "updateCursor", this.handleCursorUpdate);
this.subscribe(this.sessionId, "textEdit", this.handleTextEdit);
}
handleUserJoin(viewId) {
this.users.set(viewId, {
id: viewId,
name: this.generateUserName(),
color: this.getRandomColor(),
joinedAt: this.now()
});
this.publish(this.sessionId, "usersChanged", Array.from(this.users.values()));
}
handleUserLeave(viewId) {
this.users.delete(viewId);
this.document.cursors.delete(viewId);
this.publish(this.sessionId, "usersChanged", Array.from(this.users.values()));
}
getRandomColor() {
const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD'];
return colors[Math.floor(this.random() * colors.length)];
}
generateUserName() {
const adjectives = ['Quick', 'Bright', 'Creative', 'Smart', 'Cool'];
const nouns = ['Writer', 'Editor', 'Author', 'Coder', 'Designer'];
const adj = adjectives[Math.floor(this.random() * adjectives.length)];
const noun = nouns[Math.floor(this.random() * nouns.length)];
return `${adj} ${noun}`;
}
}
class CursorModel extends Multisynq.Model {
init() {
this.cursors = new Map();
this.subscribe(this.sessionId, "cursorMove", this.handleCursorMove);
this.subscribe(this.sessionId, "selectionChange", this.handleSelectionChange);
}
handleCursorMove({ viewId, position }) {
const cursor = this.cursors.get(viewId) || {};
cursor.position = position;
cursor.lastUpdate = this.now();
this.cursors.set(viewId, cursor);
this.publish(this.sessionId, "cursorsUpdated", this.getCursorsArray());
}
handleSelectionChange({ viewId, selection }) {
const cursor = this.cursors.get(viewId) || {};
cursor.selection = selection;
cursor.lastUpdate = this.now();
this.cursors.set(viewId, cursor);
this.publish(this.sessionId, "cursorsUpdated", this.getCursorsArray());
}
getCursorsArray() {
return Array.from(this.cursors.entries()).map(([viewId, cursor]) => ({
viewId,
...cursor
}));
}
}
class CursorView extends Multisynq.View {
constructor(model) {
super(model);
this.editor = document.getElementById('editor');
this.cursorOverlay = document.getElementById('cursor-overlay');
this.subscribe(this.sessionId, "cursorsUpdated", this.renderCursors);
this.setupCursorTracking();
}
setupCursorTracking() {
this.editor.addEventListener('mouseup', () => this.sendCursorPosition());
this.editor.addEventListener('keyup', () => this.sendCursorPosition());
this.editor.addEventListener('selectionchange', () => this.sendSelection());
}
sendCursorPosition() {
const selection = window.getSelection();
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const position = this.getPositionFromRange(range);
this.publish(this.sessionId, "cursorMove", {
viewId: this.viewId,
position: position
});
}
}
sendSelection() {
const selection = window.getSelection();
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const selectionData = {
start: this.getPositionFromRange({
startContainer: range.startContainer,
startOffset: range.startOffset
}),
end: this.getPositionFromRange({
endContainer: range.endContainer,
endOffset: range.endOffset
})
};
this.publish(this.sessionId, "selectionChange", {
viewId: this.viewId,
selection: selectionData
});
}
}
renderCursors(cursors) {
// Clear existing cursors
this.cursorOverlay.innerHTML = '';
cursors.forEach(cursor => {
if (cursor.viewId !== this.viewId) {
this.renderRemoteCursor(cursor);
}
});
}
renderRemoteCursor(cursor) {
const cursorElement = document.createElement('div');
cursorElement.className = 'remote-cursor';
cursorElement.style.backgroundColor = cursor.color || '#999';
// Position the cursor element
const position = this.getElementPosition(cursor.position);
cursorElement.style.left = position.x + 'px';
cursorElement.style.top = position.y + 'px';
this.cursorOverlay.appendChild(cursorElement);
}
}
class DocumentModel extends Multisynq.Model {
init() {
this.content = "";
this.operations = [];
this.version = 0;
this.subscribe(this.sessionId, "insertText", this.handleInsert);
this.subscribe(this.sessionId, "deleteText", this.handleDelete);
this.subscribe(this.sessionId, "replaceText", this.handleReplace);
}
handleInsert({ viewId, position, text, version }) {
// Simple operational transformation
const adjustedPosition = this.transformPosition(position, version);
const operation = {
id: this.generateOperationId(),
type: 'insert',
position: adjustedPosition,
text: text,
viewId: viewId,
version: this.version,
timestamp: this.now()
};
this.applyOperation(operation);
}
handleDelete({ viewId, start, end, version }) {
const adjustedStart = this.transformPosition(start, version);
const adjustedEnd = this.transformPosition(end, version);
const operation = {
id: this.generateOperationId(),
type: 'delete',
start: adjustedStart,
end: adjustedEnd,
deletedText: this.content.substring(adjustedStart, adjustedEnd),
viewId: viewId,
version: this.version,
timestamp: this.now()
};
this.applyOperation(operation);
}
applyOperation(operation) {
switch (operation.type) {
case 'insert':
this.content =
this.content.slice(0, operation.position) +
operation.text +
this.content.slice(operation.position);
break;
case 'delete':
this.content =
this.content.slice(0, operation.start) +
this.content.slice(operation.end);
break;
}
this.operations.push(operation);
this.version++;
this.publish(this.sessionId, "documentUpdated", {
content: this.content,
operation: operation,
version: this.version
});
}
transformPosition(position, fromVersion) {
// Simple position transformation based on intervening operations
let adjustedPosition = position;
for (let i = fromVersion; i < this.operations.length; i++) {
const op = this.operations[i];
if (op.type === 'insert' && op.position <= adjustedPosition) {
adjustedPosition += op.text.length;
} else if (op.type === 'delete' && op.start <= adjustedPosition) {
const deletedLength = op.end - op.start;
adjustedPosition = Math.max(op.start, adjustedPosition - deletedLength);
}
}
return adjustedPosition;
}
generateOperationId() {
return `op_${this.now()}_${Math.floor(this.random() * 1000000)}`;
}
}
class SimpleEditorModel extends Multisynq.Model {
init() {
this.content = "Welcome to collaborative editing!\n\nStart typing to see real-time sync.";
this.users = new Map();
this.subscribe(this.sessionId, "view-join", this.handleUserJoin);
this.subscribe(this.sessionId, "view-exit", this.handleUserLeave);
this.subscribe(this.sessionId, "contentChange", this.handleContentChange);
}
handleUserJoin(viewId) {
this.users.set(viewId, {
id: viewId,
name: `User ${this.users.size + 1}`,
color: this.getRandomColor()
});
// Send current state to new user
this.publish(viewId, "initialContent", {
content: this.content,
users: Array.from(this.users.values())
});
// Notify others of new user
this.publish(this.sessionId, "userJoined", this.users.get(viewId));
}
handleUserLeave(viewId) {
const user = this.users.get(viewId);
this.users.delete(viewId);
if (user) {
this.publish(this.sessionId, "userLeft", user);
}
}
handleContentChange({ viewId, content }) {
this.content = content;
// Broadcast to all other users
this.publish(this.sessionId, "contentUpdated", {
content: this.content,
authorId: viewId
});
}
getRandomColor() {
const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7'];
return colors[Math.floor(this.random() * colors.length)];
}
}
class SimpleEditorView extends Multisynq.View {
constructor(model) {
super(model);
this.textarea = document.getElementById('editor');
this.userList = document.getElementById('users');
this.suppressEvents = false;
this.subscribe(this.sessionId, "initialContent", this.handleInitialContent);
this.subscribe(this.sessionId, "contentUpdated", this.handleContentUpdate);
this.subscribe(this.sessionId, "userJoined", this.handleUserJoined);
this.subscribe(this.sessionId, "userLeft", this.handleUserLeft);
this.setupEditor();
}
setupEditor() {
this.textarea.addEventListener('input', () => {
if (!this.suppressEvents) {
this.publish(this.sessionId, "contentChange", {
viewId: this.viewId,
content: this.textarea.value
});
}
});
}
handleInitialContent({ content, users }) {
this.suppressEvents = true;
this.textarea.value = content;
this.suppressEvents = false;
this.updateUserList(users);
}
handleContentUpdate({ content, authorId }) {
if (authorId !== this.viewId) {
const cursorPosition = this.textarea.selectionStart;
this.suppressEvents = true;
this.textarea.value = content;
this.textarea.setSelectionRange(cursorPosition, cursorPosition);
this.suppressEvents = false;
}
}
handleUserJoined(user) {
this.showUserNotification(`${user.name} joined`, user.color);
}
handleUserLeft(user) {
this.showUserNotification(`${user.name} left`, '#999');
}
updateUserList(users) {
this.userList.innerHTML = '';
users.forEach(user => {
const userElement = document.createElement('div');
userElement.className = 'user-indicator';
userElement.style.backgroundColor = user.color;
userElement.textContent = user.name;
this.userList.appendChild(userElement);
});
}
showUserNotification(message, color) {
const notification = document.createElement('div');
notification.className = 'notification';
notification.style.borderLeftColor = color;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => notification.remove(), 3000);
}
}
class CodeMirrorCollabView extends Multisynq.View {
constructor(model) {
super(model);
this.editor = CodeMirror.fromTextArea(document.getElementById('code-editor'), {
lineNumbers: true,
mode: 'javascript',
theme: 'default'
});
this.subscribe(this.sessionId, "codeChange", this.handleRemoteChange);
this.setupCollaboration();
}
setupCollaboration() {
this.editor.on('change', (instance, change) => {
if (change.origin !== 'remote') {
this.publish(this.sessionId, "codeEdit", {
viewId: this.viewId,
change: change,
content: this.editor.getValue()
});
}
});
}
handleRemoteChange({ change, authorId }) {
if (authorId !== this.viewId) {
// Apply remote change without triggering local change event
this.editor.replaceRange(
change.text.join('\n'),
change.from,
change.to,
'remote'
);
}
}
}
class RobustEditorModel extends Multisynq.Model {
init() {
this.content = "";
this.subscribe(this.sessionId, "textEdit", this.handleTextEdit);
}
handleTextEdit(data) {
try {
// Validate edit data
if (!this.validateEdit(data)) {
console.warn('Invalid edit received:', data);
return;
}
// Apply edit
this.applyEdit(data);
} catch (error) {
console.error('Error applying edit:', error);
// Recover by broadcasting current state
this.publish(this.sessionId, "contentReset", {
content: this.content,
reason: "recovery"
});
}
}
validateEdit(data) {
return data &&
typeof data.position === 'number' &&
data.position >= 0 &&
data.position <= this.content.length;
}
}
Build chat systems with message history and user presence
Create collaborative drawing and design tools
Deep dive into Model event handling and state management
Learn advanced View techniques and UI integration