183 lines
6.3 KiB
JavaScript
183 lines
6.3 KiB
JavaScript
// 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
|
|
};
|
|
})();
|