Architecture
This document describes the core implementation of y-mxgraph.
Overview
y-mxgraph acts as an adapter layer that translates Yjs changes into the native patch format recognized by draw.io's built-in collaboration system, making draw.io behave as if its own real-time collaboration feature is running.
┌─────────────────────────────────────────────────────────────┐
│ draw.io │
│ ┌─────────────┐ ┌──────────────────────────────┐ │
│ │ mxGraph │◄────►│ draw.io native collab │ │
│ │ (UI/canvas)│ │ (file.patch / diffPages) │ │
│ └─────────────┘ └──────────────┬────────────────┘ │
└──────────────────────────────────────│───────────────────────┘
│
Emulates native collab API
│
┌────────▼────────┐
│ y-mxgraph │
│ (adapter layer)│
└────────┬────────┘
│
┌────────▼────────┐
│ Y.Doc │
│ (CRDT) │
└────────┬────────┘
│
┌────────▼────────┐
│ Provider │
│ (y-webrtc, etc) │
└─────────────────┘Core Idea
draw.io ships with a mature real-time collaboration system (WebSocket-based). y-mxgraph does not replace it — it reuses it:
| Direction | Operation | Description |
|---|---|---|
| Local change | diffPages() → Y.Doc | Intercepts draw.io's diff output and stores it in Yjs |
| Remote change | Y.Doc → patch() | Generates a draw.io-compatible patch and injects it into the collab system |
Advantages:
- No need to modify draw.io's internal drawing logic
- Inherits draw.io's conflict handling, selection sync, and cursor collaboration
- Yjs provides strong-consistency CRDT-based real-time collaboration
draw.io → Y.Doc (Local Change Capture)
Reuses draw.io's native diff mechanism:
mxGraphModel.addListener("change", () => {
const patch = file.ui.diffPages(file.shadowPages, file.ui.pages);
file.setShadowPages(file.ui.clonePages(file.ui.pages));
applyFilePatch(doc, patch, { origin: LOCAL_ORIGIN });
});Flow:
- User action triggers the mxGraph
changeevent diffPages()is draw.io's built-in diff algorithm- Compares
shadowPages(last synced state) against currentpagesto produce a patch applyFilePatch()converts the patch to Yjs CRDT operations- Updates
shadowPagesto maintain the sync baseline - Tagged with
LOCAL_ORIGINto prevent feedback loops
Y.Doc → draw.io (Remote Change Injection)
Disguises Yjs changes as draw.io native collaboration patches:
doc.getMap(mxfileKey).observeDeep((events, transaction) => {
if (transaction.local && transaction.origin === LOCAL_ORIGIN) {
generatePatch(events); // update snapshot only, don't apply to UI
return;
}
const patch = generatePatch(events); // produce draw.io-native patch
file.patch([patch]); // call draw.io's built-in apply method
file.setShadowPages(file.ui.clonePages(file.ui.pages));
});Flow:
- Provider syncs remote Yjs changes
observeDeepdetects Y.Map/Y.Array mutations- Skips local transactions (prevents feedback loops)
generatePatch()produces a patch in draw.io's native formatfile.patch()is draw.io's built-in collaboration apply method- draw.io renders the changes using its own collaboration logic
Patch Structure
interface FilePatch {
// Diagram ids to remove
r?: string[];
// Diagrams to insert
i?: Array<{
data: string; // XML content
id: string; // diagram id
previous: string; // preceding diagram id (for ordering)
}>;
// Diagrams to update
u?: {
[diagramId: string]: {
name?: string; // rename
previous?: string; // reorder
cells?: {
r?: string[]; // remove cells
i?: Array<Record<string, string>>; // insert cells
u?: { // update cell attributes
[cellId: string]: Record<string, string>;
};
};
};
};
}Field names:
r(remove): ids to deletei(insert): items to add, including XML data and positionu(update): attribute changes and reorder operations
Order Maintenance
Diagram Order
Uses Y.Array<string> to store diagram id order:
// mxfile structure
{
diagrams: Y.Map<YDiagram>,
[diagramOrderKey]: Y.Array<string>
}Insertion position is determined by the previous field, supporting concurrent-insert conflict resolution.
Cell Order
Each diagram independently maintains its own cell order:
// mxGraphModel structure
{
[mxCellKey]: Y.Map<Y.XmlElement>,
[mxCellOrderKey]: Y.Array<string>
}Snapshot Mechanism
type DocSnapshot = {
diagramOrder: string[] | null;
cellsOrder: Map<string, string[]>;
cellAttrs: Map<string, Map<string, Record<string, string>>>;
};
const docSnapshots = new WeakMap<Y.Doc, DocSnapshot>();Purpose:
- Records document state before each transaction
- Used by
generatePatch()to compute diffs WeakMapprevents memory leaks
Undo/Redo Integration
Transaction Tagging
export const LOCAL_ORIGIN: object = {};
doc.transact(() => {
// local change
}, LOCAL_ORIGIN);UndoManager Configuration
const undoManager = new Y.UndoManager(doc, {
trackedOrigins: new Set([LOCAL_ORIGIN]),
});Key points:
- Only transactions tagged with
LOCAL_ORIGINenter the undo stack - Remote transactions are excluded
bindUndoManager()provides an mxUndoManager compatibility shim
Collaboration Features
Awareness State
// Local state
awareness.setLocalState({
'user.name': 'Alice',
'user.color': '#ff0000',
'cursor': { x: 100, y: 200, pageId: '0' },
'selection': { added: ['1', '2'], removed: [], pageId: '0' },
});
// Listen for remote state
awareness.on('update', ({ updated }) => {
for (const clientId of updated) {
const state = awareness.getStates().get(clientId);
// render remote cursor/selection
}
});Cursor Sync
mouse move ──────────────────────────────►
│ │
▼ ▼
mouseMoveThrottle (100ms) mouseleave
│ │
▼ ▼
cursor: { x, y, pageId } cursor: { x, y, pageId, hide: true }
│ │
└──────────────┬───────────────────────┘
▼
awareness.setLocalStateField()
│
▼
remote clients receive
│
┌──────────┴──────────┐
▼ ▼
hide: false hide: true
create/update cursor remove cursor DOMKey design decisions:
- Throttle:
mouseMoveThrottledefaults to 100 ms - Coordinate transform: screen → canvas coordinates (accounts for scale/translate)
- Page isolation: includes
pageId; cursors on other pages are hidden - Visibility:
hidefield controls visibility when mouse leaves the canvas
Selection Sync
local selection change
│
▼
selectionModel.addListener("change")
│
▼
awareness.setLocalStateField("selection", {
added: [...], // newly selected cell ids
removed: [...], // deselected cell ids
pageId,
})
│
▼
remote clients receive
│
▼
renderRemoteSelections()
│
├─► added: graph.highlightCell(cell, userColor)
│
└─► removed: highlightCell.destroy()Key design decisions:
- Incremental: only syncs the delta (added/removed), not the full selection
- Page isolation: only renders selections on the current page
- Auto-cleanup: destroys highlights when the user leaves or switches pages
XML Conversion
xml2ydoc
mxfile XML → xml-js → Y.Map/Y.Array/Y.XmlElement → Y.Doc- mxCell is converted to
Y.XmlElement, preserving full XML semantics - Order information is extracted into
Y.Array - Diagram structure is stored flat
ydoc2xml
Y.Doc → traverse Y data structures → xml-js → mxfile XML- Reconstructs XML in order
- Restores hierarchy via
previousrelationships - Supports indented formatting
Conflict Resolution
Concurrent Inserts
insertAfterUnique(orderArr, id, previous, fallbackToEnd);Strategy:
- Find the anchor position from
previous - Compute depth (handles chained dependencies)
- Sort by depth and order, then batch-insert
Deduplication
function ensureUniqueOrder(orderArr: Y.Array<string>) {
// removes duplicate ids, keeps the first occurrence
}Performance
- Batched patch apply: multiple changes in a single transaction
- Throttling: cursor move throttled (default 100 ms)
- Lazy init: snapshot initialized on first bind
- WeakMap storage: automatic GC of unused doc snapshots
Limitations & Notes
- Destroy: call
destroy(true)when unmounting to fully clean up - Single doc: each draw.io file is bound to one Y.Doc; multiple docs are not supported
- draw.io API dependency: relies on internal APIs like
file.ui.diffPages()which may change across draw.io versions