// 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(); })();