// nodes.js — node elements and their content. // // Content HTML is cached by *content*, not by node id. That is what makes undo // free: undoing a text edit restores the previous string, whose rendered HTML // is already in the cache, so no request is made. Keying by node id would turn // every Ctrl+Z into a render round trip. // // The server pre-renders every text and file node and ships the result with the // page, so a canvas opens with the cache already warm and issues no content // requests at all until something is edited. window.Canvas = window.Canvas || {}; Canvas.Nodes = (function () { var Doc = Canvas.Doc; var Select = Canvas.Select; var nodeLayer = null; var groupLayer = null; var els = {}; // node id -> element var cache = {}; // content key -> rendered HTML var inflight = {}; // content key -> Promise var SIDES = ['top', 'right', 'bottom', 'left']; var CORNERS = ['nw', 'ne', 'se', 'sw']; // SEP separates the fields of a content key. A NUL can never appear in a // path or in markdown, so no two different nodes can collide on one key. var SEP = ''; // contentKey identifies what a node displays. Null for nodes the browser // builds itself (link cards, groups), which never need the server. function contentKey(n) { if (n.type === 'text') return 't' + SEP + (n.text || ''); if (n.type === 'file') return 'f' + SEP + (n.file || '') + SEP + (n.subpath || ''); return null; } // decorate applies the same link treatment page content gets: a favicon in // front of external links, and a new tab so following one does not throw // away unsaved canvas state. function decorate(root) { root.querySelectorAll('a[href^="http"]').forEach(function (a) { a.target = '_blank'; a.rel = 'noopener'; if (a.querySelector('img.link-favicon')) return; var img = document.createElement('img'); img.className = 'link-favicon'; img.src = 'https://icons.duckduckgo.com/ip3/' + new URL(a.href).hostname + '.ico'; img.width = 16; img.height = 16; img.alt = ''; a.prepend(img); }); } function fetchContent(n, key) { if (inflight[key]) return inflight[key]; var body = new URLSearchParams(); body.set('type', n.type); if (n.type === 'file') { body.set('file', n.file || ''); body.set('subpath', n.subpath || ''); } else { body.set('text', n.text || ''); } var p = fetch('/_canvas/render', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() }).then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); }).then(function (html) { cache[key] = html; delete inflight[key]; return html; }).catch(function (err) { delete inflight[key]; throw err; }); inflight[key] = p; return p; } function applyContent(el, n) { var body = el.querySelector('.canvas-node-body'); if (!body) return; var key = contentKey(n); if (key === null) { buildStatic(body, n); return; } if (body.dataset.key === key) return; body.dataset.key = key; if (Object.prototype.hasOwnProperty.call(cache, key)) { body.innerHTML = cache[key]; body.classList.remove('canvas-broken'); decorate(body); return; } body.textContent = '...'; fetchContent(n, key).then(function (html) { // The node may have been edited again (or deleted) while the render // was in flight; only the response matching the current content wins. if (body.dataset.key !== key) return; body.innerHTML = html; body.classList.remove('canvas-broken'); decorate(body); }).catch(function () { if (body.dataset.key !== key) return; body.textContent = 'could not render this node'; body.classList.add('canvas-broken'); }); } // buildStatic renders the node types that need no server round trip: for // now just the link card (favicon, host, url). function buildStatic(body, n) { if (n.type !== 'link') { body.textContent = ''; return; } if (body.dataset.url === n.url) return; body.dataset.url = n.url; body.textContent = ''; var host = ''; try { host = new URL(n.url).hostname; } catch (e) { host = n.url; } var a = document.createElement('a'); a.href = n.url; a.target = '_blank'; a.rel = 'noopener'; a.className = 'canvas-link-card'; var img = document.createElement('img'); img.className = 'link-favicon'; img.src = 'https://icons.duckduckgo.com/ip3/' + host + '.ico'; img.width = 32; img.height = 32; img.alt = ''; var hostEl = document.createElement('span'); hostEl.className = 'canvas-link-host'; hostEl.textContent = host; var urlEl = document.createElement('span'); urlEl.className = 'canvas-link-url truncate'; urlEl.textContent = n.url; a.appendChild(img); a.appendChild(hostEl); a.appendChild(urlEl); body.appendChild(a); } function createEl(n) { var el = document.createElement('div'); el.className = 'canvas-node canvas-node-' + n.type; el.dataset.id = n.id; if (n.type === 'file') { var title = document.createElement('div'); title.className = 'canvas-node-title truncate'; el.appendChild(title); } if (n.type === 'group') { var label = document.createElement('div'); label.className = 'canvas-group-label truncate'; el.appendChild(label); } var body = document.createElement('div'); body.className = 'canvas-node-body content'; el.appendChild(body); SIDES.forEach(function (side) { var port = document.createElement('span'); port.className = 'canvas-port canvas-port-' + side; port.dataset.side = side; el.appendChild(port); }); CORNERS.forEach(function (corner) { var handle = document.createElement('span'); handle.className = 'canvas-handle canvas-handle-' + corner; handle.dataset.corner = corner; el.appendChild(handle); }); return el; } function applyGeom(el, n) { el.style.left = n.x + 'px'; el.style.top = n.y + 'px'; el.style.width = n.width + 'px'; el.style.height = n.height + 'px'; } function applyColor(el, n) { var c = n.color || ''; if (c && c.charAt(0) === '#') { el.dataset.color = 'custom'; el.style.setProperty('--node-accent', c); } else if (c) { el.dataset.color = c; el.style.removeProperty('--node-accent'); } else { delete el.dataset.color; el.style.removeProperty('--node-accent'); } } function applyMeta(el, n) { if (n.type === 'file') { var title = el.querySelector('.canvas-node-title'); if (title) { var name = (n.file || '').split('/').filter(Boolean).pop() || n.file || ''; title.textContent = name + (n.subpath || ''); title.title = (n.file || '') + (n.subpath || ''); } } if (n.type === 'group') { var label = el.querySelector('.canvas-group-label'); if (label) label.textContent = n.label || ''; if (n.background) { // Reuse the thumbnail endpoint rather than the original: a group // background is decoration and never needs full resolution. el.style.backgroundImage = 'url("/_thumb' + encodeURI(n.background) + '?w=1200")'; el.dataset.bgStyle = n.backgroundStyle || 'cover'; } else { el.style.removeProperty('background-image'); delete el.dataset.bgStyle; } } } return { init: function (nodeLayerEl, groupLayerEl) { nodeLayer = nodeLayerEl; groupLayer = groupLayerEl; }, // seed primes the cache from the server's pre-rendered payload so the // first paint needs no requests. seed: function (rendered, doc) { if (!rendered) return; doc.nodes.forEach(function (n) { var key = contentKey(n); if (key !== null && Object.prototype.hasOwnProperty.call(rendered, n.id)) { cache[key] = rendered[n.id]; } }); }, el: function (id) { return els[id] || null; }, contentKey: contentKey, // render reconciles the DOM against the document: create what is new, // update what changed, drop what is gone. Groups live in their own // layer so they always paint behind the edges and the other nodes. render: function () { var seen = {}; Doc.nodes().forEach(function (n) { seen[n.id] = true; var el = els[n.id]; // A type change means a different element shape; rebuild rather // than try to morph one into the other. if (el && el.className.indexOf('canvas-node-' + n.type) === -1) { el.remove(); el = null; } if (!el) { el = createEl(n); els[n.id] = el; (n.type === 'group' ? groupLayer : nodeLayer).appendChild(el); } applyGeom(el, n); applyColor(el, n); applyMeta(el, n); applyContent(el, n); el.classList.toggle('is-selected', Select.hasNode(n.id)); }); Object.keys(els).forEach(function (id) { if (seen[id]) return; els[id].remove(); delete els[id]; }); }, // syncGeom writes one node's geometry straight to its element. Used on // every frame of a drag or resize, where a full render would be wasted // work (nothing else has changed). syncGeom: function (id) { var n = Doc.node(id); var el = els[id]; if (n && el) applyGeom(el, n); }, syncSelection: function () { Object.keys(els).forEach(function (id) { els[id].classList.toggle('is-selected', Select.hasNode(id)); }); } }; })();