All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 8s
252 lines
10 KiB
JavaScript
252 lines
10 KiB
JavaScript
/* widget: Mindmap */
|
||
/* =========================================================
|
||
Mindmap widget — spider, tree or word web
|
||
==========================================================*/
|
||
function mountMind(root, initState){
|
||
const S = { layout:"spider", nodes:[{id:1, x:0.5, y:0.45, t:"", parent:null}], nextId:2 };
|
||
if(initState){
|
||
S.layout = initState.layout || "spider";
|
||
if(Array.isArray(initState.nodes) && initState.nodes.length) S.nodes = initState.nodes;
|
||
S.nextId = initState.nextId || (Math.max(...S.nodes.map(n=>n.id))+1);
|
||
}
|
||
const PALETTE = ["#3b7dd8","#3fae6a","#e0554d","#9b59b6","#f28c38"];
|
||
|
||
root.innerHTML = `
|
||
<div class="mm spider">
|
||
<div class="tseg mm-seg">
|
||
<button data-m="spider"></button><button data-m="tree"></button><button data-m="web"></button>
|
||
</div>
|
||
<div class="mm-hint"></div>
|
||
<div class="mm-stage"><svg></svg></div>
|
||
</div>`;
|
||
const mmEl = root.querySelector(".mm");
|
||
/* toolbar row wrapper */
|
||
const topRow = document.createElement("div");
|
||
topRow.className = "mm-top";
|
||
mmEl.insertBefore(topRow, mmEl.firstChild);
|
||
const mmGrip = document.createElement("span");
|
||
mmGrip.className = "mm-grip"; mmGrip.textContent = "⠿";
|
||
topRow.appendChild(mmGrip);
|
||
topRow.appendChild(root.querySelector(".mm-seg"));
|
||
topRow.appendChild(root.querySelector(".mm-hint"));
|
||
mmGrip.addEventListener("pointerdown", e=>{
|
||
e.preventDefault(); e.stopPropagation();
|
||
const mr = mmEl.getBoundingClientRect();
|
||
const sc = mr.width / mmEl.offsetWidth; /* effective zoom on this widget */
|
||
const ox = e.clientX/sc - topRow.offsetLeft, oy = e.clientY/sc - topRow.offsetTop;
|
||
const mv = ev=>{
|
||
topRow.style.left = Math.max(0, Math.min(mmEl.offsetWidth - topRow.offsetWidth, ev.clientX/sc - ox))+"px";
|
||
topRow.style.top = Math.max(0, Math.min(mmEl.offsetHeight - topRow.offsetHeight, ev.clientY/sc - oy))+"px";
|
||
};
|
||
const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); };
|
||
document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up);
|
||
});
|
||
|
||
const stage = root.querySelector(".mm-stage"),
|
||
svg = stage.querySelector("svg");
|
||
|
||
function nodeById(id){ return S.nodes.find(n=>n.id===id); }
|
||
function depth(n){ let d=0, c=n; while(c && c.parent!=null){ c = nodeById(c.parent); d++; } return d; }
|
||
|
||
function drawLines(){
|
||
const w = stage.clientWidth, h = stage.clientHeight;
|
||
let paths = "";
|
||
S.nodes.forEach(n=>{
|
||
if(n.parent==null) return;
|
||
const p = nodeById(n.parent);
|
||
if(!p) return;
|
||
const x1 = p.x*w, y1 = p.y*h, x2 = n.x*w, y2 = n.y*h;
|
||
const col = n.lc || PALETTE[(depth(n)-1) % PALETTE.length];
|
||
const dash = n.ls==="dash" ? ' stroke-dasharray="11 8"' : n.ls==="dot" ? ' stroke-dasharray="2.5 7"' : "";
|
||
if(S.layout==="spider"){
|
||
const mx = (x1+x2)/2 + (y2-y1)*0.18, my = (y1+y2)/2 - (x2-x1)*0.18;
|
||
paths += `<path d="M${x1},${y1} Q${mx},${my} ${x2},${y2}" fill="none" stroke="${col}" stroke-width="3"${dash}/>`;
|
||
}else if(S.layout==="tree"){
|
||
const my = (y1+y2)/2;
|
||
paths += `<path d="M${x1},${y1} L${x1},${my} L${x2},${my} L${x2},${y2}" fill="none" stroke="${col}" stroke-width="3"${dash}/>`;
|
||
}else{
|
||
paths += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${col}" stroke-width="3"${dash}/>`;
|
||
}
|
||
});
|
||
svg.innerHTML = paths;
|
||
}
|
||
|
||
function applyNodeStyle(n, d){
|
||
if(n.parent!=null) d.style.borderColor = n.c || PALETTE[(depth(n)-1) % PALETTE.length];
|
||
else if(n.c) d.style.borderColor = n.c;
|
||
d.style.fontSize = (n.fs || (n.parent==null ? 17 : 15))+"px";
|
||
}
|
||
function buildNode(n){
|
||
const d = document.createElement("div");
|
||
d.className = "mm-node" + (n.parent==null ? " root" : "");
|
||
applyNodeStyle(n, d);
|
||
d.dataset.id = n.id;
|
||
const span = document.createElement("span");
|
||
span.contentEditable = "true";
|
||
span.spellcheck = false;
|
||
span.textContent = n.t || (n.parent==null ? T("mmRoot") : "");
|
||
span.addEventListener("input", ()=>{ n.t = span.textContent; });
|
||
span.addEventListener("keydown", e=>e.stopPropagation());
|
||
span.addEventListener("pointerdown", e=>e.stopPropagation());
|
||
d.appendChild(span);
|
||
const add = document.createElement("button");
|
||
add.className = "mm-add"; add.textContent = "+";
|
||
add.addEventListener("pointerdown", e=>e.stopPropagation());
|
||
add.addEventListener("click", ()=>{
|
||
const a = Math.random()*Math.PI*2;
|
||
S.nodes.push({
|
||
id: S.nextId++, parent: n.id,
|
||
x: Math.min(.95, Math.max(.05, n.x + Math.cos(a)*0.17)),
|
||
y: Math.min(.92, Math.max(.06, n.y + Math.sin(a)*0.2)),
|
||
t: ""
|
||
});
|
||
render();
|
||
});
|
||
d.appendChild(add);
|
||
const cog = document.createElement("button");
|
||
cog.className = "mm-cog"; cog.textContent = "🎨";
|
||
cog.addEventListener("pointerdown", e=>e.stopPropagation());
|
||
cog.addEventListener("click", ()=>openNodePop(n, d));
|
||
d.appendChild(cog);
|
||
if(n.parent!=null){
|
||
const del = document.createElement("button");
|
||
del.className = "mm-del"; del.textContent = "✕";
|
||
del.addEventListener("pointerdown", e=>e.stopPropagation());
|
||
del.addEventListener("click", ()=>{
|
||
const doomed = new Set([n.id]);
|
||
let grew = true;
|
||
while(grew){
|
||
grew = false;
|
||
S.nodes.forEach(m=>{
|
||
if(m.parent!=null && doomed.has(m.parent) && !doomed.has(m.id)){ doomed.add(m.id); grew = true; }
|
||
});
|
||
}
|
||
S.nodes = S.nodes.filter(m=>!doomed.has(m.id));
|
||
render();
|
||
});
|
||
d.appendChild(del);
|
||
}
|
||
/* drag */
|
||
d.addEventListener("pointerdown", e=>{
|
||
if(e.pointerType==="mouse" && e.button!==0) return;
|
||
e.preventDefault();
|
||
const r = stage.getBoundingClientRect();
|
||
const mv = ev=>{
|
||
n.x = Math.min(.98, Math.max(.02, (ev.clientX-r.left)/r.width));
|
||
n.y = Math.min(.96, Math.max(.04, (ev.clientY-r.top)/r.height));
|
||
d.style.left = (n.x*100)+"%";
|
||
d.style.top = (n.y*100)+"%";
|
||
drawLines();
|
||
};
|
||
const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); };
|
||
document.addEventListener("pointermove",mv);
|
||
document.addEventListener("pointerup",up);
|
||
});
|
||
d.style.left = (n.x*100)+"%";
|
||
d.style.top = (n.y*100)+"%";
|
||
return d;
|
||
}
|
||
|
||
/* per-node settings popover: colour, font size, line colour & style */
|
||
function closeNodePop(){ stage.querySelectorAll(".mm-pop").forEach(x=>x.remove()); }
|
||
function openNodePop(n, nodeEl){
|
||
closeNodePop();
|
||
const pop = document.createElement("div");
|
||
pop.className = "mm-pop";
|
||
const w = stage.clientWidth, h = stage.clientHeight;
|
||
pop.style.left = Math.min(n.x*w + 30, w-190)+"px";
|
||
pop.style.top = Math.min(n.y*h + 24, h-150)+"px";
|
||
pop.addEventListener("pointerdown", e=>e.stopPropagation());
|
||
|
||
const restyle = ()=>{ applyNodeStyle(n, nodeEl); drawLines(); refreshPop(); };
|
||
const row = (lbl, title)=>{
|
||
const r = document.createElement("div");
|
||
r.className = "mp-row"; r.title = title;
|
||
const l = document.createElement("span");
|
||
l.className = "mp-lbl"; l.textContent = lbl;
|
||
r.appendChild(l);
|
||
pop.appendChild(r);
|
||
return r;
|
||
};
|
||
/* node colour */
|
||
const r1 = row("⬤", T("nodeColor"));
|
||
PALETTE.concat(["#26344a"]).forEach(c=>{
|
||
const b = document.createElement("button");
|
||
b.className = "mp-sw"; b.dataset.v = c; b.style.background = c;
|
||
b.addEventListener("click", ()=>{ n.c = (n.c===c ? null : c); restyle(); });
|
||
r1.appendChild(b);
|
||
});
|
||
const xBtn = document.createElement("button");
|
||
xBtn.className = "mp-x"; xBtn.textContent = "✕";
|
||
xBtn.addEventListener("click", closeNodePop);
|
||
r1.appendChild(xBtn);
|
||
/* font size */
|
||
const r2 = row("A", T("nodeFont"));
|
||
[["A",13],["A",16],["A",20]].forEach(([txt,fs],i)=>{
|
||
const b = document.createElement("button");
|
||
b.className = "mp-fs"; b.dataset.v = fs;
|
||
b.textContent = txt; b.style.fontSize = (11+i*3)+"px";
|
||
b.addEventListener("click", ()=>{ n.fs = fs; restyle(); });
|
||
r2.appendChild(b);
|
||
});
|
||
if(n.parent!=null){
|
||
/* line colour */
|
||
const r3 = row("╱", T("lineColor"));
|
||
PALETTE.concat(["#26344a"]).forEach(c=>{
|
||
const b = document.createElement("button");
|
||
b.className = "mp-sw"; b.dataset.v = "l"+c; b.style.background = c;
|
||
b.addEventListener("click", ()=>{ n.lc = (n.lc===c ? null : c); restyle(); });
|
||
r3.appendChild(b);
|
||
});
|
||
/* line style */
|
||
const r4 = row("┄", T("lineStyle"));
|
||
[["solid",""],["dash","dash"],["dot","dot"]].forEach(([id,val])=>{
|
||
const b = document.createElement("button");
|
||
b.className = "mp-ls"; b.dataset.ls = id; b.dataset.v = "s"+id;
|
||
b.appendChild(document.createElement("i"));
|
||
b.addEventListener("click", ()=>{ n.ls = val || null; restyle(); });
|
||
r4.appendChild(b);
|
||
});
|
||
}
|
||
function refreshPop(){
|
||
pop.querySelectorAll(".mp-sw").forEach(b=>{
|
||
const v = b.dataset.v;
|
||
b.classList.toggle("on", v.startsWith("l") ? n.lc===v.slice(1) : n.c===v);
|
||
});
|
||
pop.querySelectorAll(".mp-fs").forEach(b=>
|
||
b.classList.toggle("on", +b.dataset.v === (n.fs || (n.parent==null?17:15))));
|
||
pop.querySelectorAll(".mp-ls").forEach(b=>
|
||
b.classList.toggle("on", (n.ls||"") === (b.dataset.ls==="solid" ? "" : b.dataset.ls)));
|
||
}
|
||
refreshPop();
|
||
stage.appendChild(pop);
|
||
}
|
||
document.addEventListener("pointerdown", e=>{
|
||
if(!stage.querySelector(".mm-pop")) return;
|
||
if(!e.target.closest(".mm-pop") && !e.target.closest(".mm-cog")) closeNodePop();
|
||
});
|
||
|
||
function render(){
|
||
mmEl.className = "mm "+S.layout;
|
||
root.querySelectorAll(".mm-seg button").forEach(b=>{
|
||
b.textContent = T(b.dataset.m==="spider" ? "mmSpider" : b.dataset.m==="tree" ? "mmTree" : "mmWeb");
|
||
b.classList.toggle("on", b.dataset.m===S.layout);
|
||
});
|
||
root.querySelector(".mm-hint").textContent = T("mmHint");
|
||
closeNodePop();
|
||
stage.querySelectorAll(".mm-node").forEach(x=>x.remove());
|
||
S.nodes.forEach(n=>stage.appendChild(buildNode(n)));
|
||
drawLines();
|
||
}
|
||
root.querySelectorAll(".mm-seg button").forEach(b=>{
|
||
b.addEventListener("click", ()=>{ S.layout = b.dataset.m; render(); });
|
||
});
|
||
new ResizeObserver(drawLines).observe(stage);
|
||
document.addEventListener("langchange", render);
|
||
render();
|
||
return {
|
||
getState: ()=>({ layout:S.layout, nodes:S.nodes, nextId:S.nextId }),
|
||
setState: st=>{ if(st && st.nodes){ S.layout=st.layout||"spider"; S.nodes=st.nodes; S.nextId=st.nextId||S.nextId; render(); } }
|
||
};
|
||
}
|
||
|