// doc.js — the canvas document plus its undo stack. // // Every mutation passes through begin()/commit(). A gesture (a drag, a resize, // a nudge-repeat) spans hundreds of pointer events but has to collapse into a // single undo entry, so the snapshot is taken once at begin() and pushed once // at commit() — and only when the document actually changed, so a click that // moves nothing leaves no trace. // // Undo is snapshot-based rather than a stack of inverse operations: the // document is a small pure-JSON object, cloning it costs microseconds, and a // snapshot cannot be subtly wrong the way a hand-written inverse can. The // stack is capped so a long session cannot grow without bound. window.Canvas = window.Canvas || {}; Canvas.Doc = (function () { var MAX_UNDO = 100; var doc = { nodes: [], edges: [] }; var undoStack = []; var redoStack = []; var pending = null; var dirty = false; var version = ''; var listeners = []; function clone(v) { return JSON.parse(JSON.stringify(v)); } function emit() { for (var i = 0; i < listeners.length; i++) listeners[i](); } // newId mints a 16-hex-character node/edge id, the shape the format uses in // the wild. Collisions are checked against the live document anyway. function newId() { var bytes = new Uint8Array(8); window.crypto.getRandomValues(bytes); var out = ''; for (var i = 0; i < bytes.length; i++) { out += (bytes[i] + 0x100).toString(16).slice(1); } if (find(doc.nodes, out) || find(doc.edges, out)) return newId(); return out; } function find(list, id) { for (var i = 0; i < list.length; i++) { if (list[i].id === id) return list[i]; } return null; } return { init: function (d, hash) { doc = (d && typeof d === 'object') ? d : {}; if (!Array.isArray(doc.nodes)) doc.nodes = []; if (!Array.isArray(doc.edges)) doc.edges = []; undoStack = []; redoStack = []; pending = null; dirty = false; version = hash || ''; }, get: function () { return doc; }, nodes: function () { return doc.nodes; }, edges: function () { return doc.edges; }, node: function (id) { return find(doc.nodes, id); }, edge: function (id) { return find(doc.edges, id); }, newId: newId, // edgesFor returns every edge touching any of the given node ids. edgesFor: function (nodeIds) { var want = {}; nodeIds.forEach(function (id) { want[id] = true; }); return doc.edges.filter(function (e) { return want[e.fromNode] || want[e.toNode]; }); }, // --- gesture bracketing --- // // Nested begin() calls join the outer gesture rather than starting a // new one, so a composite action (drag a group, which drags its // members) still produces exactly one undo entry. begin: function (label) { if (pending) { pending.depth++; return; } pending = { label: label || '', before: clone(doc), depth: 0 }; }, commit: function () { if (!pending) return; if (pending.depth > 0) { pending.depth--; return; } var before = pending.before; var label = pending.label; pending = null; // A gesture that changed nothing (a click that selected without // moving) must not push an undo entry the user would have to press // Ctrl+Z through. if (JSON.stringify(before) === JSON.stringify(doc)) return; undoStack.push({ label: label, doc: before }); if (undoStack.length > MAX_UNDO) undoStack.shift(); redoStack.length = 0; dirty = true; emit(); }, // abort rolls the document back to the gesture's starting state, for // Escape mid-drag. abort: function () { if (!pending) return; if (pending.depth > 0) { pending.depth--; return; } doc = pending.before; pending = null; emit(); }, inGesture: function () { return pending !== null; }, undo: function () { if (!undoStack.length) return false; var entry = undoStack.pop(); redoStack.push({ label: entry.label, doc: clone(doc) }); doc = entry.doc; dirty = true; emit(); return true; }, redo: function () { if (!redoStack.length) return false; var entry = redoStack.pop(); undoStack.push({ label: entry.label, doc: clone(doc) }); doc = entry.doc; dirty = true; emit(); return true; }, canUndo: function () { return undoStack.length > 0; }, canRedo: function () { return redoStack.length > 0; }, // --- structural helpers --- addNode: function (node) { doc.nodes.push(node); return node; }, addEdge: function (edge) { doc.edges.push(edge); return edge; }, // remove drops the named nodes and edges. Every edge touching a removed // node goes too — a dangling endpoint would fail validation on save, so // it can never be allowed to exist even transiently. remove: function (nodeIds, edgeIds) { var deadNodes = {}; (nodeIds || []).forEach(function (id) { deadNodes[id] = true; }); var deadEdges = {}; (edgeIds || []).forEach(function (id) { deadEdges[id] = true; }); doc.nodes = doc.nodes.filter(function (n) { return !deadNodes[n.id]; }); doc.edges = doc.edges.filter(function (e) { return !deadEdges[e.id] && !deadNodes[e.fromNode] && !deadNodes[e.toNode]; }); }, // --- save state --- isDirty: function () { return dirty; }, markSaved: function (hash) { dirty = false; if (hash) version = hash; emit(); }, version: function () { return version; }, onChange: function (fn) { listeners.push(fn); }, notify: emit }; })();