Build real-time collaborative editing experiences with Multisynq’s Model-View architecture
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;
}
}