JSON canvas initial
This commit is contained in:
@@ -0,0 +1,389 @@
|
||||
// main.js — bootstrap, toolbar, and keyboard shortcuts.
|
||||
//
|
||||
// The payload the server inlined (document + pre-rendered node HTML + the
|
||||
// version token) is the only input; nothing else is fetched on load.
|
||||
(function () {
|
||||
var data = window.CANVAS_DATA;
|
||||
var surface = document.getElementById('canvas-surface');
|
||||
if (!data || !surface) return;
|
||||
|
||||
var Doc = Canvas.Doc;
|
||||
var View = Canvas.View;
|
||||
var Select = Canvas.Select;
|
||||
var Nodes = Canvas.Nodes;
|
||||
var Edges = Canvas.Edges;
|
||||
var Save = Canvas.Save;
|
||||
|
||||
var world = document.getElementById('canvas-world');
|
||||
var marquee = document.getElementById('canvas-marquee');
|
||||
var hint = document.querySelector('[data-canvas-hint]');
|
||||
var statusEl = document.querySelector('[data-canvas-status]');
|
||||
var zoomEl = document.querySelector('[data-canvas-zoom]');
|
||||
|
||||
// Default sizes for new nodes, in world pixels. Multiples of the 20px grid
|
||||
// so a fresh node lands aligned.
|
||||
var SIZES = {
|
||||
text: { width: 260, height: 120 },
|
||||
file: { width: 400, height: 400 },
|
||||
link: { width: 400, height: 140 },
|
||||
group: { width: 520, height: 400 }
|
||||
};
|
||||
|
||||
function snap(v) { return Math.round(v / View.GRID) * View.GRID; }
|
||||
|
||||
// --- document actions ---
|
||||
|
||||
Canvas.Actions = {
|
||||
// add drops a node centred on a world point (the viewport centre when
|
||||
// called from the toolbar, the cursor when called from a double-click).
|
||||
add: function (type, extra, at) {
|
||||
var size = SIZES[type];
|
||||
var p = at || View.centreOfViewport();
|
||||
var node = {
|
||||
id: Doc.newId(),
|
||||
type: type,
|
||||
x: snap(p.x - size.width / 2),
|
||||
y: snap(p.y - size.height / 2),
|
||||
width: size.width,
|
||||
height: size.height
|
||||
};
|
||||
for (var k in extra) {
|
||||
if (Object.prototype.hasOwnProperty.call(extra, k)) node[k] = extra[k];
|
||||
}
|
||||
Doc.begin('add ' + type);
|
||||
Doc.addNode(node);
|
||||
Doc.commit();
|
||||
Select.set([node.id], []);
|
||||
return node;
|
||||
},
|
||||
|
||||
addText: function (at) {
|
||||
return Canvas.Actions.add('text', { text: '' }, at);
|
||||
},
|
||||
|
||||
addGroup: function (at) {
|
||||
return Canvas.Actions.add('group', { label: 'Group' }, at);
|
||||
},
|
||||
|
||||
// A file node is meaningless without a target and a link node without a
|
||||
// url, so both ask first and only then create — validation on the server
|
||||
// rejects the empty forms anyway.
|
||||
addFile: function () {
|
||||
openTreePicker({
|
||||
title: 'File node — pick a target',
|
||||
mode: 'any',
|
||||
initialPath: data.postUrl,
|
||||
confirmLabel: 'ADD',
|
||||
onSelect: function (p) {
|
||||
if (p) Canvas.Actions.add('file', { file: p });
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
addLink: function () {
|
||||
var input = document.createElement('input');
|
||||
input.type = 'url';
|
||||
input.className = 'input';
|
||||
input.placeholder = 'https://example.com';
|
||||
openModal({
|
||||
title: 'Link node',
|
||||
body: input,
|
||||
confirm: {
|
||||
label: 'ADD',
|
||||
onConfirm: function () {
|
||||
var v = input.value.trim();
|
||||
if (!v) return;
|
||||
closeModal();
|
||||
Canvas.Actions.add('link', { url: v });
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
deleteSelection: function () {
|
||||
if (Select.isEmpty()) return;
|
||||
Doc.begin('delete');
|
||||
Doc.remove(Select.nodes(), Select.edges());
|
||||
Doc.commit();
|
||||
Select.clear();
|
||||
},
|
||||
|
||||
setColor: function (color) {
|
||||
if (Select.isEmpty()) return;
|
||||
Doc.begin('colour');
|
||||
Select.nodes().forEach(function (id) {
|
||||
var n = Doc.node(id);
|
||||
if (!n) return;
|
||||
if (color) n.color = color; else delete n.color;
|
||||
});
|
||||
Select.edges().forEach(function (id) {
|
||||
var e = Doc.edge(id);
|
||||
if (!e) return;
|
||||
if (color) e.color = color; else delete e.color;
|
||||
});
|
||||
Doc.commit();
|
||||
},
|
||||
|
||||
// nudge moves the selection by whole grid steps, or single pixels with
|
||||
// shift held for the cases where the grid is in the way.
|
||||
nudge: function (dx, dy, fine) {
|
||||
var ids = Select.nodes();
|
||||
if (!ids.length) return;
|
||||
var step = fine ? 1 : View.GRID;
|
||||
Doc.begin('nudge');
|
||||
ids.forEach(function (id) {
|
||||
var n = Doc.node(id);
|
||||
if (!n) return;
|
||||
n.x += dx * step;
|
||||
n.y += dy * step;
|
||||
});
|
||||
Doc.commit();
|
||||
},
|
||||
|
||||
selectAll: function () {
|
||||
Select.set(
|
||||
Doc.nodes().map(function (n) { return n.id; }),
|
||||
Doc.edges().map(function (e) { return e.id; })
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// --- chrome ---
|
||||
|
||||
function setStatus(text) {
|
||||
if (statusEl) statusEl.textContent = text || '';
|
||||
}
|
||||
|
||||
function updateToolbar() {
|
||||
var undoBtn = document.querySelector('[data-action="undo"]');
|
||||
var redoBtn = document.querySelector('[data-action="redo"]');
|
||||
var delBtn = document.querySelector('[data-action="delete-selection"]');
|
||||
if (undoBtn) undoBtn.disabled = !Doc.canUndo();
|
||||
if (redoBtn) redoBtn.disabled = !Doc.canRedo();
|
||||
if (delBtn) delBtn.disabled = Select.isEmpty();
|
||||
|
||||
var saveBtn = document.querySelector('[data-action="canvas-save"]');
|
||||
if (saveBtn) saveBtn.classList.toggle('is-active', Doc.isDirty());
|
||||
if (hint) hint.hidden = Doc.nodes().length > 0;
|
||||
}
|
||||
|
||||
function updateZoom() {
|
||||
if (zoomEl) zoomEl.textContent = Math.round(View.scale() * 100) + '%';
|
||||
}
|
||||
|
||||
// refresh is the single re-render path: prune the selection against the
|
||||
// (possibly undone) document, reconcile both layers, update the chrome.
|
||||
function refresh() {
|
||||
Select.prune(Doc.get());
|
||||
Nodes.render();
|
||||
Edges.render();
|
||||
updateToolbar();
|
||||
}
|
||||
|
||||
// --- canvas-level actions (rename / delete the file itself) ---
|
||||
|
||||
function leaveGuard() {
|
||||
if (!Doc.isDirty()) return true;
|
||||
return window.confirm('This canvas has unsaved changes. Leave anyway?');
|
||||
}
|
||||
|
||||
function canvasAction(url, then) {
|
||||
fetch(url, { method: 'POST', credentials: 'same-origin', redirect: 'manual' })
|
||||
.then(function (res) {
|
||||
if (res.type === 'opaqueredirect' || res.ok) { then(); return; }
|
||||
return res.text().then(function (msg) {
|
||||
alert(msg || ('Request failed (' + res.status + ')'));
|
||||
});
|
||||
})
|
||||
.catch(function () { alert('Network error'); });
|
||||
}
|
||||
|
||||
function renameCanvas() {
|
||||
var input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'input';
|
||||
input.value = data.name;
|
||||
openModal({
|
||||
title: 'Rename canvas',
|
||||
body: input,
|
||||
confirm: {
|
||||
label: 'RENAME',
|
||||
onConfirm: function () {
|
||||
var v = input.value.trim();
|
||||
if (!v || v === data.name) { closeModal(); return; }
|
||||
closeModal();
|
||||
canvasAction(
|
||||
data.postUrl + '?canvas=' + encodeURIComponent(data.name) +
|
||||
'&rename=' + encodeURIComponent(v),
|
||||
function () {
|
||||
window.location.href = data.postUrl + '?canvas=' + encodeURIComponent(v);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function deleteCanvas() {
|
||||
openModal({
|
||||
title: 'Delete canvas',
|
||||
body: 'Delete "' + data.name + '"? The page itself is not affected.',
|
||||
confirm: {
|
||||
label: 'DELETE',
|
||||
danger: true,
|
||||
onConfirm: function () {
|
||||
closeModal();
|
||||
canvasAction(
|
||||
data.postUrl + '?canvas=' + encodeURIComponent(data.name) + '&delete=1',
|
||||
function () { window.location.href = data.postUrl; }
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- wiring ---
|
||||
|
||||
function wireToolbar() {
|
||||
var handlers = {
|
||||
'undo': function () { Doc.undo(); },
|
||||
'redo': function () { Doc.redo(); },
|
||||
'add-text': function () { Canvas.Edit.open(Canvas.Actions.addText().id); },
|
||||
'add-file': function () { Canvas.Actions.addFile(); },
|
||||
'add-link': function () { Canvas.Actions.addLink(); },
|
||||
'add-group': function () { Canvas.Actions.addGroup(); },
|
||||
'delete-selection': function () { Canvas.Actions.deleteSelection(); },
|
||||
'zoom-in': function () { View.zoomBy(1.2); },
|
||||
'zoom-out': function () { View.zoomBy(1 / 1.2); },
|
||||
'zoom-reset': function () { View.resetZoom(); },
|
||||
'fit': function () { View.fit(Doc.nodes()); },
|
||||
'canvas-save': function () { Save.run(); },
|
||||
'canvas-rename': renameCanvas,
|
||||
'canvas-delete': deleteCanvas
|
||||
};
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
var fn = handlers[btn.dataset.action];
|
||||
if (!fn) return;
|
||||
e.preventDefault();
|
||||
fn();
|
||||
});
|
||||
|
||||
document.querySelectorAll('.canvas-color-menu [data-color]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
Canvas.Actions.setColor(btn.dataset.color);
|
||||
});
|
||||
});
|
||||
|
||||
// The header dropdowns are wired on demand: the auto-wiring in
|
||||
// global-shortcuts.js only covers the page's ACTIONS menu, which this
|
||||
// view replaces.
|
||||
['canvas-menu', 'color-menu'].forEach(function (action) {
|
||||
wireDropdown(document.querySelector('[data-action="' + action + '"]'));
|
||||
});
|
||||
|
||||
document.querySelectorAll('.canvas-tabs a').forEach(function (a) {
|
||||
a.addEventListener('click', function (e) {
|
||||
if (!leaveGuard()) e.preventDefault();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// typing reports whether the keystroke belongs to a text field, an open
|
||||
// modal, or the node editor — in which case the canvas must keep its hands
|
||||
// off it.
|
||||
function typing(e) {
|
||||
var t = e.target;
|
||||
if (!t || !t.closest) return false;
|
||||
return !!t.closest('input, textarea, select, .cm-editor, .modal, [contenteditable="true"]');
|
||||
}
|
||||
|
||||
function wireKeys() {
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (typing(e)) return;
|
||||
|
||||
var mod = e.ctrlKey || e.metaKey;
|
||||
if (mod && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault();
|
||||
Save.run();
|
||||
return;
|
||||
}
|
||||
if (mod && e.key.toLowerCase() === 'z') {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) Doc.redo(); else Doc.undo();
|
||||
return;
|
||||
}
|
||||
if (mod && e.key.toLowerCase() === 'y') {
|
||||
e.preventDefault();
|
||||
Doc.redo();
|
||||
return;
|
||||
}
|
||||
if (mod && e.key.toLowerCase() === 'a') {
|
||||
e.preventDefault();
|
||||
Canvas.Actions.selectAll();
|
||||
return;
|
||||
}
|
||||
if (mod) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Delete':
|
||||
case 'Backspace':
|
||||
e.preventDefault();
|
||||
Canvas.Actions.deleteSelection();
|
||||
break;
|
||||
case 'Escape':
|
||||
if (!Canvas.Interact.cancel()) Select.clear();
|
||||
break;
|
||||
case 'Enter':
|
||||
var sel = Select.nodes();
|
||||
if (sel.length === 1) {
|
||||
e.preventDefault();
|
||||
Canvas.Edit.open(sel[0]);
|
||||
}
|
||||
break;
|
||||
case 'ArrowUp': e.preventDefault(); Canvas.Actions.nudge(0, -1, e.shiftKey); break;
|
||||
case 'ArrowDown': e.preventDefault(); Canvas.Actions.nudge(0, 1, e.shiftKey); break;
|
||||
case 'ArrowLeft': e.preventDefault(); Canvas.Actions.nudge(-1, 0, e.shiftKey); break;
|
||||
case 'ArrowRight': e.preventDefault(); Canvas.Actions.nudge(1, 0, e.shiftKey); break;
|
||||
case 't': Canvas.Edit.open(Canvas.Actions.addText().id); break;
|
||||
case 'f': Canvas.Actions.addFile(); break;
|
||||
case 'l': Canvas.Actions.addLink(); break;
|
||||
case 'g': Canvas.Actions.addGroup(); break;
|
||||
case '9': View.fit(Doc.nodes()); break;
|
||||
case '0': View.resetZoom(); break;
|
||||
case '+':
|
||||
case '=': View.zoomBy(1.2); break;
|
||||
case '-': View.zoomBy(1 / 1.2); break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- boot ---
|
||||
|
||||
Doc.init(data.doc, data.hash);
|
||||
Nodes.seed(data.rendered, Doc.get());
|
||||
Nodes.init(document.getElementById('canvas-nodes'), document.getElementById('canvas-groups'));
|
||||
Edges.init(document.getElementById('canvas-edges'));
|
||||
View.init(surface, world);
|
||||
Canvas.Interact.init(surface, marquee);
|
||||
Save.init(data.postUrl, data.name);
|
||||
|
||||
Doc.onChange(refresh);
|
||||
Select.onChange(function () {
|
||||
Nodes.syncSelection();
|
||||
Edges.syncSelection();
|
||||
updateToolbar();
|
||||
});
|
||||
View.onChange(updateZoom);
|
||||
Save.onState(function (state, message) { setStatus(message); });
|
||||
|
||||
wireToolbar();
|
||||
wireKeys();
|
||||
|
||||
refresh();
|
||||
View.fit(Doc.nodes());
|
||||
updateZoom();
|
||||
surface.focus();
|
||||
})();
|
||||
Reference in New Issue
Block a user