Files
datascape/assets/canvas/nodes.js
T
2026-09-22 21:28:28 +02:00

306 lines
11 KiB
JavaScript

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