/* 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 = `
`; 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 += ``; }else if(S.layout==="tree"){ const my = (y1+y2)/2; paths += ``; }else{ paths += ``; } }); 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(); } } }; }