Create collaborative drawing canvases with Multisynq
A collaborative whiteboard allows multiple users to draw, sketch, and annotate together in real-time. This guide shows you how to build a robust whiteboard application using Multisynq.
class WhiteboardModel extends Multisynq.Model {
init(options) {
super.init(options);
this.strokes = new Map(); // strokeId -> stroke data
this.users = new Map(); // userId -> user cursor/tool state
this.canvasSize = options.canvasSize || { width: 1200, height: 800 };
// Subscribe to drawing events
this.subscribe(this.sessionId, "startStroke", this.handleStartStroke);
this.subscribe(this.sessionId, "addPoint", this.handleAddPoint);
this.subscribe(this.sessionId, "endStroke", this.handleEndStroke);
this.subscribe(this.sessionId, "eraseStroke", this.handleEraseStroke);
this.subscribe(this.sessionId, "clearCanvas", this.handleClearCanvas);
this.subscribe(this.sessionId, "updateCursor", this.handleCursorUpdate);
this.subscribe(this.sessionId, "changeTool", this.handleToolChange);
// User management
this.subscribe(this.sessionId, "view-join", this.handleUserJoin);
this.subscribe(this.sessionId, "view-exit", this.handleUserLeave);
}
handleStartStroke(data) {
const { userId, strokeId, point, tool } = data;
const stroke = {
id: strokeId,
userId,
points: [point],
tool: {
type: tool.type || 'pen',
color: tool.color || '#000000',
size: Math.max(1, Math.min(50, tool.size || 2)), // Clamp size
opacity: Math.max(0.1, Math.min(1, tool.opacity || 1))
},
startTime: this.now(),
completed: false
};
this.strokes.set(strokeId, stroke);
// Broadcast stroke start to all users
this.publish(this.sessionId, "strokeStarted", {
stroke,
userId
});
}
handleAddPoint(data) {
const { strokeId, point } = data;
const stroke = this.strokes.get(strokeId);
if (!stroke || stroke.completed) return;
// Validate point data
if (!this.isValidPoint(point)) return;
stroke.points.push(point);
// Broadcast new point
this.publish(this.sessionId, "pointAdded", {
strokeId,
point,
pointIndex: stroke.points.length - 1
});
}
handleEndStroke(data) {
const { strokeId } = data;
const stroke = this.strokes.get(strokeId);
if (!stroke) return;
stroke.completed = true;
stroke.endTime = this.now();
// Broadcast stroke completion
this.publish(this.sessionId, "strokeCompleted", {
strokeId,
stroke
});
}
handleEraseStroke(data) {
const { strokeId, userId } = data;
if (!this.strokes.has(strokeId)) return;
// Remove stroke
this.strokes.delete(strokeId);
// Broadcast erasure
this.publish(this.sessionId, "strokeErased", {
strokeId,
erasedBy: userId
});
}
handleClearCanvas(data) {
const { userId } = data;
// Clear all strokes
this.strokes.clear();
// Broadcast clear
this.publish(this.sessionId, "canvasCleared", {
clearedBy: userId,
timestamp: this.now()
});
}
handleCursorUpdate(data) {
const { userId, cursor } = data;
if (!this.isValidPoint(cursor)) return;
// Update user cursor position
const user = this.users.get(userId) || {};
user.cursor = cursor;
user.lastUpdate = this.now();
this.users.set(userId, user);
// Broadcast cursor update (except to sender)
this.publish(this.sessionId, "cursorMoved", {
userId,
cursor
});
}
handleToolChange(data) {
const { userId, tool } = data;
// Update user tool
const user = this.users.get(userId) || {};
user.tool = {
type: tool.type || 'pen',
color: tool.color || '#000000',
size: Math.max(1, Math.min(50, tool.size || 2)),
opacity: Math.max(0.1, Math.min(1, tool.opacity || 1))
};
this.users.set(userId, user);
// Broadcast tool change
this.publish(this.sessionId, "toolChanged", {
userId,
tool: user.tool
});
}
handleUserJoin(viewId) {
// Initialize user state
this.users.set(viewId, {
userId: viewId,
joinedAt: this.now(),
cursor: { x: 0, y: 0 },
tool: {
type: 'pen',
color: this.generateUserColor(viewId),
size: 2,
opacity: 1
}
});
// Send current canvas state to new user
this.sendCanvasState(viewId);
// Announce user joined
this.publish(this.sessionId, "userJoinedWhiteboard", {
userId: viewId,
userCount: this.users.size
});
}
handleUserLeave(viewId) {
this.users.delete(viewId);
// Announce user left
this.publish(this.sessionId, "userLeftWhiteboard", {
userId: viewId,
userCount: this.users.size
});
}
sendCanvasState(userId) {
// Send all existing strokes to new user
const strokesArray = Array.from(this.strokes.values());
this.publish(userId, "canvasState", {
strokes: strokesArray,
canvasSize: this.canvasSize,
userCount: this.users.size
});
}
isValidPoint(point) {
return point &&
typeof point.x === 'number' &&
typeof point.y === 'number' &&
point.x >= 0 && point.x <= this.canvasSize.width &&
point.y >= 0 && point.y <= this.canvasSize.height;
}
generateUserColor(userId) {
// Generate consistent color for user
const colors = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4',
'#FFEAA7', '#DDA0DD', '#98D8C8', '#F7DC6F'
];
const hash = this.hashString(userId);
return colors[hash % colors.length];
}
hashString(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash);
}
}
class WhiteboardView extends Multisynq.View {
constructor(model) {
super(model);
this.canvas = null;
this.ctx = null;
this.isDrawing = false;
this.currentStrokeId = null;
this.lastPoint = null;
this.currentTool = {
type: 'pen',
color: '#000000',
size: 2,
opacity: 1
};
this.cursors = new Map(); // Other users' cursors
this.strokes = new Map(); // Rendered strokes
this.setupCanvas();
this.setupEventListeners();
this.setupUI();
}
setupCanvas() {
this.canvas = document.getElementById('whiteboard-canvas');
this.ctx = this.canvas.getContext('2d');
// Set canvas size
this.canvas.width = 1200;
this.canvas.height = 800;
// Configure drawing context
this.ctx.lineCap = 'round';
this.ctx.lineJoin = 'round';
}
setupEventListeners() {
// Subscribe to model events
this.subscribe(this.sessionId, "strokeStarted", this.onStrokeStarted);
this.subscribe(this.sessionId, "pointAdded", this.onPointAdded);
this.subscribe(this.sessionId, "strokeCompleted", this.onStrokeCompleted);
this.subscribe(this.sessionId, "strokeErased", this.onStrokeErased);
this.subscribe(this.sessionId, "canvasCleared", this.onCanvasCleared);
this.subscribe(this.sessionId, "cursorMoved", this.onCursorMoved);
this.subscribe(this.sessionId, "toolChanged", this.onToolChanged);
this.subscribe(this.viewId, "canvasState", this.onCanvasState);
// Canvas mouse/touch events
this.canvas.addEventListener('mousedown', (e) => this.startDrawing(e));
this.canvas.addEventListener('mousemove', (e) => this.draw(e));
this.canvas.addEventListener('mouseup', (e) => this.stopDrawing(e));
this.canvas.addEventListener('mouseleave', (e) => this.stopDrawing(e));
// Touch events for mobile
this.canvas.addEventListener('touchstart', (e) => this.handleTouch(e, 'start'));
this.canvas.addEventListener('touchmove', (e) => this.handleTouch(e, 'move'));
this.canvas.addEventListener('touchend', (e) => this.handleTouch(e, 'end'));
// Cursor tracking
this.canvas.addEventListener('mousemove', (e) => this.updateCursor(e));
// Prevent scrolling on touch
this.canvas.addEventListener('touchmove', (e) => e.preventDefault());
}
setupUI() {
// Tool buttons
document.getElementById('pen-tool').addEventListener('click', () => {
this.setTool('pen');
});
document.getElementById('eraser-tool').addEventListener('click', () => {
this.setTool('eraser');
});
// Color picker
document.getElementById('color-picker').addEventListener('change', (e) => {
this.currentTool.color = e.target.value;
this.publishToolChange();
});
// Size slider
document.getElementById('size-slider').addEventListener('input', (e) => {
this.currentTool.size = parseInt(e.target.value);
this.publishToolChange();
});
// Clear button
document.getElementById('clear-button').addEventListener('click', () => {
this.clearCanvas();
});
}
startDrawing(event) {
if (this.currentTool.type === 'eraser') {
this.eraseAtPoint(this.getPointFromEvent(event));
return;
}
this.isDrawing = true;
this.currentStrokeId = this.generateStrokeId();
this.lastPoint = this.getPointFromEvent(event);
// Start new stroke
this.publish(this.sessionId, "startStroke", {
userId: this.viewId,
strokeId: this.currentStrokeId,
point: this.lastPoint,
tool: this.currentTool
});
}
draw(event) {
if (!this.isDrawing) return;
const currentPoint = this.getPointFromEvent(event);
// Add point to current stroke
this.publish(this.sessionId, "addPoint", {
strokeId: this.currentStrokeId,
point: currentPoint
});
this.lastPoint = currentPoint;
}
stopDrawing(event) {
if (!this.isDrawing) return;
this.isDrawing = false;
// End current stroke
this.publish(this.sessionId, "endStroke", {
strokeId: this.currentStrokeId
});
this.currentStrokeId = null;
this.lastPoint = null;
}
handleTouch(event, type) {
event.preventDefault();
if (event.touches.length !== 1) return; // Only handle single touch
const touch = event.touches[0];
const mouseEvent = new MouseEvent(
type === 'start' ? 'mousedown' : type === 'move' ? 'mousemove' : 'mouseup',
{
clientX: touch.clientX,
clientY: touch.clientY
}
);
this.canvas.dispatchEvent(mouseEvent);
}
updateCursor(event) {
const point = this.getPointFromEvent(event);
this.publish(this.sessionId, "updateCursor", {
userId: this.viewId,
cursor: point
});
}
getPointFromEvent(event) {
const rect = this.canvas.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top
};
}
// Event handlers for model events
onStrokeStarted(data) {
if (data.userId === this.viewId) return; // Don't render own strokes twice
const { stroke } = data;
this.strokes.set(stroke.id, {
...stroke,
renderedPoints: 0
});
this.renderStroke(stroke);
}
onPointAdded(data) {
const { strokeId, point } = data;
const stroke = this.strokes.get(strokeId);
if (!stroke) return;
// Add point to local stroke
if (!stroke.points) stroke.points = [];
stroke.points.push(point);
this.renderStrokeSegment(stroke, stroke.renderedPoints);
stroke.renderedPoints = stroke.points.length;
}
onStrokeCompleted(data) {
const { strokeId } = data;
const stroke = this.strokes.get(strokeId);
if (stroke) {
stroke.completed = true;
}
}
onStrokeErased(data) {
const { strokeId } = data;
this.strokes.delete(strokeId);
this.redrawCanvas();
}
onCanvasCleared(data) {
this.strokes.clear();
this.clearCanvasDisplay();
}
onCursorMoved(data) {
const { userId, cursor } = data;
if (userId === this.viewId) return; // Don't show own cursor
this.cursors.set(userId, cursor);
this.renderCursors();
}
onToolChanged(data) {
// Could show tool indicators for other users
console.log(`User ${data.userId} changed tool:`, data.tool);
}
onCanvasState(data) {
const { strokes } = data;
// Clear and rebuild canvas
this.strokes.clear();
this.clearCanvasDisplay();
strokes.forEach(stroke => {
this.strokes.set(stroke.id, {
...stroke,
renderedPoints: stroke.points.length
});
this.renderStroke(stroke);
});
}
// Rendering methods
renderStroke(stroke) {
if (!stroke.points || stroke.points.length === 0) return;
this.ctx.save();
this.applyToolStyle(stroke.tool);
this.ctx.beginPath();
this.ctx.moveTo(stroke.points[0].x, stroke.points[0].y);
for (let i = 1; i < stroke.points.length; i++) {
this.ctx.lineTo(stroke.points[i].x, stroke.points[i].y);
}
this.ctx.stroke();
this.ctx.restore();
}
renderStrokeSegment(stroke, fromIndex) {
if (!stroke.points || stroke.points.length < 2) return;
this.ctx.save();
this.applyToolStyle(stroke.tool);
this.ctx.beginPath();
if (fromIndex === 0) {
this.ctx.moveTo(stroke.points[0].x, stroke.points[0].y);
fromIndex = 1;
} else {
this.ctx.moveTo(stroke.points[fromIndex - 1].x, stroke.points[fromIndex - 1].y);
}
for (let i = fromIndex; i < stroke.points.length; i++) {
this.ctx.lineTo(stroke.points[i].x, stroke.points[i].y);
}
this.ctx.stroke();
this.ctx.restore();
}
applyToolStyle(tool) {
this.ctx.globalCompositeOperation = tool.type === 'eraser' ? 'destination-out' : 'source-over';
this.ctx.strokeStyle = tool.color;
this.ctx.lineWidth = tool.size;
this.ctx.globalAlpha = tool.opacity;
}
renderCursors() {
// Clear previous cursors
this.clearCursors();
// Draw current cursors
this.cursors.forEach((cursor, userId) => {
this.drawCursor(cursor, userId);
});
}
drawCursor(cursor, userId) {
this.ctx.save();
this.ctx.fillStyle = this.getUserColor(userId);
this.ctx.beginPath();
this.ctx.arc(cursor.x, cursor.y, 5, 0, 2 * Math.PI);
this.ctx.fill();
this.ctx.restore();
}
clearCursors() {
// This is simplified - in a real implementation,
// you'd track cursor areas to clear efficiently
}
redrawCanvas() {
this.clearCanvasDisplay();
this.strokes.forEach(stroke => {
this.renderStroke(stroke);
});
}
clearCanvasDisplay() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
// Tool methods
setTool(toolType) {
this.currentTool.type = toolType;
this.publishToolChange();
this.updateUI();
}
publishToolChange() {
this.publish(this.sessionId, "changeTool", {
userId: this.viewId,
tool: this.currentTool
});
}
clearCanvas() {
this.publish(this.sessionId, "clearCanvas", {
userId: this.viewId
});
}
eraseAtPoint(point) {
// Find strokes at this point and erase them
for (const [strokeId, stroke] of this.strokes) {
if (this.isPointOnStroke(point, stroke)) {
this.publish(this.sessionId, "eraseStroke", {
strokeId,
userId: this.viewId
});
break; // Erase one stroke at a time
}
}
}
isPointOnStroke(point, stroke) {
// Simplified hit detection
const threshold = stroke.tool.size + 5;
return stroke.points.some(strokePoint => {
const dx = point.x - strokePoint.x;
const dy = point.y - strokePoint.y;
return Math.sqrt(dx * dx + dy * dy) <= threshold;
});
}
updateUI() {
// Update tool UI state
document.querySelectorAll('.tool-button').forEach(btn => {
btn.classList.remove('active');
});
document.getElementById(`${this.currentTool.type}-tool`).classList.add('active');
}
generateStrokeId() {
return `stroke_${this.viewId}_${this.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
getUserColor(userId) {
// Generate consistent color for user (same as in model)
const colors = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4',
'#FFEAA7', '#DDA0DD', '#98D8C8', '#F7DC6F'
];
const hash = this.hashString(userId);
return colors[hash % colors.length];
}
hashString(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash);
}
}
<!DOCTYPE html>
<html>
<head>
<title>Shared Whiteboard</title>
<style>
body {
margin: 0;
font-family: Arial, sans-serif;
}
.whiteboard-container {
display: flex;
height: 100vh;
}
.toolbar {
width: 200px;
background: #f5f5f5;
padding: 20px;
border-right: 1px solid #ddd;
}
.tool-section {
margin-bottom: 20px;
}
.tool-section h3 {
margin: 0 0 10px 0;
font-size: 14px;
color: #333;
}
.tool-buttons {
display: flex;
gap: 5px;
}
.tool-button {
padding: 10px;
border: 1px solid #ddd;
background: white;
cursor: pointer;
border-radius: 4px;
}
.tool-button.active {
background: #007bff;
color: white;
}
.color-picker {
width: 100%;
height: 40px;
border: none;
border-radius: 4px;
}
.size-slider {
width: 100%;
}
.clear-button {
width: 100%;
padding: 10px;
background: #dc3545;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.canvas-container {
flex: 1;
overflow: hidden;
background: white;
}
#whiteboard-canvas {
border: 1px solid #ddd;
cursor: crosshair;
}
.status-bar {
position: absolute;
bottom: 10px;
left: 10px;
background: rgba(0,0,0,0.7);
color: white;
padding: 5px 10px;
border-radius: 15px;
font-size: 12px;
}
</style>
</head>
<body>
<div class="whiteboard-container">
<div class="toolbar">
<div class="tool-section">
<h3>Tools</h3>
<div class="tool-buttons">
<button id="pen-tool" class="tool-button active">✏️</button>
<button id="eraser-tool" class="tool-button">🗑️</button>
</div>
</div>
<div class="tool-section">
<h3>Color</h3>
<input type="color" id="color-picker" class="color-picker" value="#000000">
</div>
<div class="tool-section">
<h3>Size</h3>
<input type="range" id="size-slider" class="size-slider" min="1" max="50" value="2">
<div id="size-display">2px</div>
</div>
<div class="tool-section">
<button id="clear-button" class="clear-button">Clear Canvas</button>
</div>
</div>
<div class="canvas-container">
<canvas id="whiteboard-canvas"></canvas>
<div class="status-bar">
<span id="user-count">1 user online</span>
</div>
</div>
</div>
<script src="multisynq.min.js"></script>
<script>
// Initialize application
Multisynq.Session.join({
apiKey: "your_api_key_here",
appId: "com.example.whiteboard",
name: Multisynq.App.autoSession(),
model: WhiteboardModel,
view: WhiteboardView,
debug: ["session"]
});
</script>
</body>
</html>
Add support for drawing shapes:
class ShapeWhiteboardModel extends WhiteboardModel {
handleStartShape(data) {
const { userId, shapeId, shapeType, startPoint, tool } = data;
const shape = {
id: shapeId,
userId,
type: shapeType, // 'rectangle', 'circle', 'line'
startPoint,
endPoint: startPoint,
tool,
completed: false
};
this.shapes.set(shapeId, shape);
this.publish(this.sessionId, "shapeStarted", shape);
}
handleUpdateShape(data) {
const { shapeId, endPoint } = data;
const shape = this.shapes.get(shapeId);
if (shape && !shape.completed) {
shape.endPoint = endPoint;
this.publish(this.sessionId, "shapeUpdated", { shapeId, endPoint });
}
}
}
Add text annotation capability:
class TextWhiteboardModel extends WhiteboardModel {
handleAddText(data) {
const { userId, textId, point, text, style } = data;
const textElement = {
id: textId,
userId,
point,
text: text.substring(0, 500), // Limit text length
style: {
fontSize: style.fontSize || 16,
fontFamily: style.fontFamily || 'Arial',
color: style.color || '#000000'
},
timestamp: this.now()
};
this.textElements.set(textId, textElement);
this.publish(this.sessionId, "textAdded", textElement);
}
}
Implement drawing layers:
class LayeredWhiteboardModel extends WhiteboardModel {
init(options) {
super.init(options);
this.layers = new Map(); // layerId -> layer data
this.activeLayer = 'layer1';
// Create default layer
this.layers.set(this.activeLayer, {
id: this.activeLayer,
name: 'Layer 1',
visible: true,
strokes: new Map()
});
}
handleCreateLayer(data) {
const { userId, layerId, name } = data;
const layer = {
id: layerId,
name: name || `Layer ${this.layers.size + 1}`,
visible: true,
strokes: new Map(),
createdBy: userId,
createdAt: this.now()
};
this.layers.set(layerId, layer);
this.publish(this.sessionId, "layerCreated", layer);
}
}
Performance Optimization:
User Experience:
Mobile Support:
Data Management:
Collaboration Features:
This whiteboard implementation provides a solid foundation for building collaborative drawing applications with Multisynq.