589 lines
20 KiB
HTML
589 lines
20 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh">
|
||
<head>
|
||
<!-- Google tag (gtag.js) -->
|
||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-LVVXH3TL04"></script>
|
||
<script>
|
||
window.dataLayer = window.dataLayer || [];
|
||
function gtag(){dataLayer.push(arguments);}
|
||
gtag('js', new Date());
|
||
gtag('config', 'G-LVVXH3TL04');
|
||
</script>
|
||
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no">
|
||
<title>愈心谷 · 脑图编辑器</title>
|
||
<style>
|
||
:root{--pri:#E54D42;--hero-start:#FF9F80;--bg-start:#FFD1C7;--bg-end:#FFC8B5}
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
body{
|
||
font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;
|
||
background:linear-gradient(180deg,var(--bg-start),var(--bg-end) 50%,#fff5f0);
|
||
height:100vh;display:flex;overflow:hidden;
|
||
}
|
||
|
||
/* ── Sidebar ── */
|
||
.sidebar{
|
||
width:200px;background:#fff;border-right:1px solid #f0e0d8;display:flex;flex-direction:column;
|
||
flex-shrink:0;box-shadow:2px 0 8px rgba(0,0,0,0.03);z-index:10;
|
||
}
|
||
.sidebar .sh{
|
||
padding:16px 14px 10px;font-size:15px;font-weight:600;color:#333;
|
||
border-bottom:1px solid #f5f0eb;
|
||
}
|
||
.sidebar .slist{flex:1;overflow-y:auto;padding:8px 0}
|
||
.sidebar .slist .item{
|
||
padding:10px 14px;cursor:pointer;font-size:13px;color:#555;
|
||
display:flex;align-items:center;justify-content:space-between;
|
||
border-left:3px solid transparent;transition:background 0.1s;
|
||
}
|
||
.sidebar .slist .item:hover{background:#fdf8f5}
|
||
.sidebar .slist .item.active{background:#fff0ed;border-left-color:var(--pri);color:var(--pri);font-weight:600}
|
||
.sidebar .slist .item .del{font-size:11px;color:#ccc;visibility:hidden;cursor:pointer}
|
||
.sidebar .slist .item:hover .del{visibility:visible}
|
||
.sidebar .slist .item .del:hover{color:var(--pri)}
|
||
.sidebar .sb{display:flex;gap:6px;padding:10px 12px;border-top:1px solid #f5f0eb}
|
||
.sidebar .sb button{
|
||
flex:1;padding:8px 0;border:1px solid #ddd;border-radius:6px;
|
||
background:#fff;font-size:12px;cursor:pointer;color:#555;
|
||
}
|
||
.sidebar .sb button.primary{background:var(--pri);color:#fff;border-color:var(--pri)}
|
||
.sidebar .sb button:active{opacity:0.8}
|
||
|
||
/* ── Main ── */
|
||
.main{flex:1;display:flex;flex-direction:column;overflow:hidden}
|
||
.toolbar{
|
||
display:flex;align-items:center;gap:6px;padding:8px 12px;
|
||
background:rgba(255,255,255,0.9);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);
|
||
border-bottom:1px solid #f0e0d8;flex-shrink:0;flex-wrap:wrap;z-index:10;
|
||
}
|
||
.toolbar button{
|
||
padding:6px 12px;border:1px solid #ddd;border-radius:6px;
|
||
background:#fff;font-size:13px;cursor:pointer;color:#555;
|
||
}
|
||
.toolbar button:active{background:#f0ebe5}
|
||
.toolbar button.primary{background:var(--pri);color:#fff;border-color:var(--pri)}
|
||
.toolbar .sep{width:1px;height:20px;background:#ddd;margin:0 2px}
|
||
.toolbar .hint{font-size:11px;color:#aaa;margin-left:auto}
|
||
canvas{flex:1;display:block;cursor:grab}
|
||
canvas:active{cursor:grabbing}
|
||
#editor{position:absolute;display:none;z-index:20}
|
||
#editor input{
|
||
padding:4px 8px;border:2px solid var(--pri);border-radius:4px;
|
||
font-size:13px;outline:none;min-width:80px;background:#fff;
|
||
}
|
||
/* Plus buttons */
|
||
#plusChild,#plusSibling{
|
||
position:absolute;display:none;z-index:25;
|
||
width:20px;height:20px;border-radius:50%;
|
||
color:#fff;font-size:14px;font-weight:bold;line-height:20px;
|
||
text-align:center;cursor:pointer;user-select:none;
|
||
pointer-events:auto;
|
||
}
|
||
|
||
/* Mobile */
|
||
@media(max-width:600px){
|
||
.sidebar{
|
||
position:fixed;left:0;top:0;bottom:0;width:240px;z-index:30;
|
||
transform:translateX(-100%);transition:transform 0.25s ease;
|
||
box-shadow:4px 0 20px rgba(0,0,0,0.1);
|
||
}
|
||
.sidebar.open{transform:translateX(0)}
|
||
.sidebar .sh{font-size:16px;padding:18px 16px 12px}
|
||
.sidebar .slist .item{font-size:14px;padding:12px 16px}
|
||
.sidebar .sb button{padding:10px 0;font-size:13px}
|
||
/* hamburger */
|
||
.hamburger{
|
||
display:flex!important;position:fixed;top:8px;left:8px;z-index:40;
|
||
width:36px;height:36px;align-items:center;justify-content:center;
|
||
background:rgba(255,255,255,0.9);border-radius:8px;
|
||
border:1px solid #e0d5cf;font-size:18px;cursor:pointer;
|
||
-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);
|
||
}
|
||
/* overlay backdrop */
|
||
.overlay{
|
||
display:none;position:fixed;inset:0;z-index:25;
|
||
background:rgba(0,0,0,0.25);
|
||
}
|
||
.overlay.show{display:block}
|
||
/* toolbar compact */
|
||
.toolbar{gap:3px;padding:6px 6px 6px 50px;min-height:42px}
|
||
.toolbar button{padding:6px 10px;font-size:12px;border-radius:8px}
|
||
.toolbar .sep{margin:0 1px}
|
||
.toolbar .hint{font-size:10px;display:none}
|
||
/* canvas */
|
||
canvas{flex:1}
|
||
/* editor bigger for touch */
|
||
#editor input{font-size:16px!important;padding:8px 12px;min-width:100px;border-radius:6px}
|
||
/* node hit area larger on mobile */
|
||
.mobile .toolbar button{min-height:32px}
|
||
}
|
||
/* Desktop: hide hamburger */
|
||
.hamburger{display:none}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- Mobile hamburger -->
|
||
<button class="hamburger" id="hamburger" onclick="toggleSidebar()">☰</button>
|
||
<div class="overlay" id="overlay" onclick="closeSidebar()"></div>
|
||
|
||
<!-- Sidebar -->
|
||
<div class="sidebar">
|
||
<div class="sh">📁 我的脑图</div>
|
||
<div class="slist" id="slist"></div>
|
||
<div class="sb">
|
||
<button class="primary" onclick="newMap()">+ 新建</button>
|
||
<button onclick="goHome()">🏠</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main -->
|
||
<div class="main">
|
||
<div class="toolbar">
|
||
<button onclick="renameMap()">✎ 重命名</button>
|
||
<button onclick="addChild()">+ 子</button>
|
||
<button onclick="addSibling()">+ 同级</button>
|
||
<button onclick="delNode()">✕ 删除</button>
|
||
<span class="sep"></span>
|
||
<button onclick="zoomIn()">🔍+</button>
|
||
<button onclick="zoomOut()">🔍-</button>
|
||
<span class="sep"></span>
|
||
<span class="hint" id="saveHint">💾 已自动保存</span>
|
||
</div>
|
||
<canvas id="c"></canvas>
|
||
</div>
|
||
<!-- Plus buttons (HTML, positioned over canvas) -->
|
||
<div id="plusChild" onclick="addChild()" title="添加子节点">+</div>
|
||
<div id="plusSibling" onclick="addSibling()" title="添加同级节点">+</div>
|
||
<div id="editor"><input id="ei" onblur="commitEdit()" onkeydown="if(event.key==='Enter')commitEdit();if(event.key==='Escape')cancelEdit()"></div>
|
||
|
||
<script>
|
||
var c=document.getElementById('c'),ctx=c.getContext('2d');
|
||
var ei=document.getElementById('ei'),ed=document.getElementById('editor');
|
||
var SCALE=1,ox=0,oy=0;
|
||
var dragging=false,dragX=0,dragY=0;
|
||
var selected=null,editing=null;
|
||
var colors=['#8B5CF6','#4A90E2','#5CB85C','#E8985A','#E54D42','#06B6D4','#F59E0B','#EC4899'];
|
||
var STORAGE_KEY='yuxingu_maps';
|
||
|
||
// ---- Multi-map state ----
|
||
var maps=[],currentIdx=-1;
|
||
|
||
function defaultTree(){
|
||
return {
|
||
id:'root',text:'新建脑图',color:colors[0],children:[]
|
||
};
|
||
}
|
||
|
||
function defaultTemplate(){
|
||
return {
|
||
id:'root',text:'愈心谷小程序',color:colors[0],
|
||
children:[
|
||
{id:'b1',text:'愈心魔方',color:colors[0],children:[
|
||
{id:'b1a',text:'急救魔方'},{id:'b1b',text:'亲子魔方'},{id:'b1c',text:'成长魔方'},
|
||
{id:'b1d',text:'解构魔方'},{id:'b1e',text:'艺术魔方'},{id:'b1f',text:'情感魔方'}
|
||
]},
|
||
{id:'b2',text:'私域活动',color:colors[1],children:[
|
||
{id:'b2a',text:'心理研学'},{id:'b2b',text:'心灵旅修'}
|
||
]},
|
||
{id:'b3',text:'心理咨询',color:colors[2]},
|
||
{id:'b4',text:'心理评估',color:colors[3],children:[
|
||
{id:'b4a',text:'AI面部识别'},{id:'b4b',text:'脑机接口'}
|
||
]},
|
||
{id:'b5',text:'健康管理',color:colors[4]}
|
||
]
|
||
};
|
||
}
|
||
|
||
// ---- Persistence ----
|
||
function loadMaps(){
|
||
try{
|
||
var d=localStorage.getItem(STORAGE_KEY);
|
||
return d?JSON.parse(d):[];
|
||
}catch(e){return [];}
|
||
}
|
||
|
||
function saveMaps(){
|
||
try{localStorage.setItem(STORAGE_KEY,JSON.stringify(maps))}catch(e){}
|
||
// sync to server (all maps)
|
||
fetch('/save',{method:'POST',body:JSON.stringify(maps)}).catch(function(){});
|
||
}
|
||
|
||
function syncServer(){
|
||
fetch('/load').then(function(r){return r.json()}).then(function(data){
|
||
if(Array.isArray(data)&&data.length>0){
|
||
maps=data;saveMaps();renderSidebar();
|
||
if(currentIdx<0||currentIdx>=maps.length)switchMap(0);
|
||
}
|
||
}).catch(function(){});
|
||
}
|
||
|
||
// ---- Sidebar ----
|
||
function renderSidebar(){
|
||
var sl=document.getElementById('slist');
|
||
sl.innerHTML='';
|
||
maps.forEach(function(m,i){
|
||
var d=document.createElement('div');
|
||
d.className='item'+(i===currentIdx?' active':'');
|
||
d.innerHTML='<span>'+escHtml(m.name||'脑图'+(i+1))+'</span><span class="del" data-idx="'+i+'" onclick="event.stopPropagation();deleteMap('+i+')">✕</span>';
|
||
d.onclick=function(){switchMap(i)};
|
||
sl.appendChild(d);
|
||
});
|
||
}
|
||
|
||
function escHtml(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||
|
||
function switchMap(idx){
|
||
if(idx<0||idx>=maps.length)return;
|
||
currentIdx=idx;
|
||
tree=JSON.parse(JSON.stringify(maps[idx].tree));
|
||
selected=null;
|
||
renderSidebar();
|
||
layout();draw();
|
||
// close sidebar on mobile after selection
|
||
if(window.innerWidth<=600)closeSidebar();
|
||
}
|
||
|
||
function newMap(){
|
||
var name=prompt('脑图名称:','脑图'+(maps.length+1));
|
||
if(!name)return;
|
||
maps.push({name:name,tree:defaultTree()});
|
||
saveMaps();
|
||
renderSidebar();
|
||
switchMap(maps.length-1);
|
||
}
|
||
|
||
function renameMap(){
|
||
if(currentIdx<0)return;
|
||
var name=prompt('新名称:',maps[currentIdx].name);
|
||
if(!name)return;
|
||
maps[currentIdx].name=name;
|
||
saveMaps();renderSidebar();
|
||
}
|
||
|
||
function deleteMap(idx){
|
||
if(!confirm('删除「'+maps[idx].name+'」?不可恢复。'))return;
|
||
maps.splice(idx,1);
|
||
saveMaps();renderSidebar();
|
||
if(maps.length===0){maps.push({name:'愈心谷小程序',tree:defaultTemplate()});saveMaps();renderSidebar();}
|
||
if(currentIdx>=maps.length)currentIdx=maps.length-1;
|
||
switchMap(currentIdx);
|
||
}
|
||
|
||
var _saveTimer=null;
|
||
function autoSave(){
|
||
if(currentIdx<0)return;
|
||
maps[currentIdx].tree=JSON.parse(JSON.stringify(exportTree(tree)));
|
||
saveMaps();
|
||
var h=document.getElementById('saveHint');
|
||
if(h){h.textContent='💾 已自动保存';h.style.color='#aaa';
|
||
clearTimeout(_saveTimer);
|
||
_saveTimer=setTimeout(function(){h.textContent='';},2000);}
|
||
}
|
||
|
||
// ---- Tree export ----
|
||
function exportTree(node){
|
||
var o={id:node.id,text:node.text};
|
||
if(node.color)o.color=node.color;
|
||
if(node.children)o.children=node.children.map(exportTree);
|
||
return o;
|
||
}
|
||
|
||
// ---- Layout & Draw ----
|
||
var tree=defaultTemplate();
|
||
var NODE_W=120,NODE_H=34,HGAP=60,VGAP=14;
|
||
|
||
function uid(){return 'n'+Date.now()+Math.random().toString(36).slice(2,6)}
|
||
|
||
function getColor(node,parentColor){
|
||
if(node.color)return node.color;
|
||
if(parentColor)return parentColor;
|
||
return colors[0];
|
||
}
|
||
|
||
function layoutHeight(node){
|
||
if(!node.children||node.children.length===0)return NODE_H;
|
||
var h=0;
|
||
for(var i=0;i<node.children.length;i++)h+=layoutHeight(node.children[i]);
|
||
return h+(node.children.length-1)*VGAP;
|
||
}
|
||
|
||
function layoutNode(node,x,y,parentColor){
|
||
node._x=x;node._y=y;node._w=NODE_W;node._h=NODE_H;
|
||
node._color=getColor(node,parentColor);
|
||
if(node.children&&node.children.length>0){
|
||
var totalH=0;
|
||
for(var i=0;i<node.children.length;i++)totalH+=layoutHeight(node.children[i]);
|
||
totalH+=(node.children.length-1)*VGAP;
|
||
var cy=y-(totalH-NODE_H)/2;
|
||
for(var i=0;i<node.children.length;i++){
|
||
var cnode=node.children[i];
|
||
var ch=layoutHeight(cnode);
|
||
layoutNode(cnode,x+NODE_W+HGAP,cy+(ch-NODE_H)/2,node._color);
|
||
cy+=ch+VGAP;
|
||
}
|
||
}
|
||
}
|
||
|
||
function layout(){
|
||
var totalH=layoutHeight(tree);
|
||
layoutNode(tree,40,-totalH/2,tree.color);
|
||
}
|
||
|
||
function hitTest(mx,my){
|
||
function hit(node){
|
||
if(!node._x)return null;
|
||
if(mx>=node._x&&mx<=node._x+node._w&&my>=node._y&&my<=node._y+node._h)return node;
|
||
if(node.children)for(var i=0;i<node.children.length;i++){var h=hit(node.children[i]);if(h)return h;}
|
||
return null;
|
||
}
|
||
return hit(tree);
|
||
}
|
||
|
||
function draw(){
|
||
var W=c.width=c.offsetWidth||window.innerWidth-200;
|
||
var H=c.height=c.offsetHeight||window.innerHeight-42;
|
||
ctx.clearRect(0,0,W,H);
|
||
ctx.save();
|
||
ctx.translate(W/2+ox,H/2+oy);
|
||
ctx.scale(SCALE,SCALE);
|
||
drawTree(tree);
|
||
ctx.restore();
|
||
updatePlusButtons();
|
||
}
|
||
|
||
function drawTree(node){
|
||
var x=node._x,y=node._y,w=node._w,h=node._h,col=node._color;
|
||
var isRoot=node===tree,isSel=node===selected;
|
||
if(node.children)for(var i=0;i<node.children.length;i++){
|
||
var c=node.children[i];
|
||
ctx.beginPath();
|
||
ctx.moveTo(x+w,y+h/2);
|
||
ctx.bezierCurveTo(
|
||
x+w+HGAP/2,y+h/2,
|
||
c._x-HGAP/2,c._y+c._h/2,
|
||
c._x,c._y+c._h/2
|
||
);
|
||
ctx.strokeStyle=col;ctx.lineWidth=isRoot?3:2;ctx.stroke();
|
||
drawTree(c);
|
||
}
|
||
var rx=6;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x+rx,y);ctx.lineTo(x+w-rx,y);ctx.arcTo(x+w,y,x+w,y+rx,rx);
|
||
ctx.lineTo(x+w,y+h-rx);ctx.arcTo(x+w,y+h,x+w-rx,y+h,rx);
|
||
ctx.lineTo(x+rx,y+h);ctx.arcTo(x,y+h,x,y+h-rx,rx);
|
||
ctx.lineTo(x,y+rx);ctx.arcTo(x,y,x+rx,y,rx);ctx.closePath();
|
||
if(isRoot){
|
||
ctx.fillStyle=col;ctx.fill();
|
||
ctx.strokeStyle='rgba(0,0,0,0.15)';ctx.lineWidth=1;ctx.stroke();
|
||
ctx.fillStyle='#fff';
|
||
}else{
|
||
ctx.fillStyle='#fff';ctx.fill();
|
||
ctx.strokeStyle=col;ctx.lineWidth=isSel?3:1.5;ctx.stroke();
|
||
ctx.fillStyle='#333';
|
||
}
|
||
ctx.font=(isRoot?'bold ':'')+'13px -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif';
|
||
ctx.textAlign='center';ctx.textBaseline='middle';
|
||
ctx.fillText(node.text,x+w/2,y+h/2);
|
||
}
|
||
|
||
function updatePlusButtons(){
|
||
var pc=document.getElementById('plusChild'), ps=document.getElementById('plusSibling');
|
||
if(!selected){
|
||
pc.style.display='none'; ps.style.display='none'; return;
|
||
}
|
||
var rect=c.getBoundingClientRect();
|
||
var n=selected, col=n._color||colors[0];
|
||
// Child + (right edge)
|
||
var cx=rect.left + c.width/2 + ox + (n._x+n._w+8)*SCALE;
|
||
var cy=rect.top + c.height/2 + oy + (n._y+n._h/2)*SCALE;
|
||
pc.style.display='block'; pc.style.left=(cx-10)+'px'; pc.style.top=(cy-10)+'px';
|
||
pc.style.background=col;
|
||
// Sibling + (bottom edge) — not for root
|
||
if(selected!==tree){
|
||
var sx=rect.left + c.width/2 + ox + (n._x+n._w/2)*SCALE;
|
||
var sy=rect.top + c.height/2 + oy + (n._y+n._h+8)*SCALE;
|
||
ps.style.display='block'; ps.style.left=(sx-10)+'px'; ps.style.top=(sy-10)+'px';
|
||
ps.style.background=col;
|
||
}else{ps.style.display='none';}
|
||
}
|
||
|
||
// ---- Canvas events ----
|
||
c.addEventListener('mousedown',function(e){
|
||
var rect=c.getBoundingClientRect();
|
||
var sx=(e.clientX-rect.left-c.width/2-ox)/SCALE;
|
||
var sy=(e.clientY-rect.top-c.height/2-oy)/SCALE;
|
||
var node=hitTest(sx,sy);
|
||
if(node){selected=node;draw();}
|
||
else{dragging=true;dragX=e.clientX;dragY=e.clientY;}
|
||
});
|
||
c.addEventListener('mousemove',function(e){
|
||
if(dragging){ox+=e.clientX-dragX;oy+=e.clientY-dragY;dragX=e.clientX;dragY=e.clientY;draw();}
|
||
});
|
||
c.addEventListener('mouseup',function(){dragging=false;});
|
||
c.addEventListener('mouseleave',function(){dragging=false;});
|
||
c.addEventListener('wheel',function(e){
|
||
e.preventDefault();
|
||
SCALE=Math.max(0.3,Math.min(3,SCALE*(e.deltaY>0?0.9:1.1)));
|
||
draw();
|
||
},{passive:false});
|
||
c.addEventListener('dblclick',function(e){
|
||
var rect=c.getBoundingClientRect();
|
||
var sx=(e.clientX-rect.left-c.width/2-ox)/SCALE;
|
||
var sy=(e.clientY-rect.top-c.height/2-oy)/SCALE;
|
||
var node=hitTest(sx,sy);
|
||
if(node){
|
||
editing=node;
|
||
var cx=rect.left+c.width/2+ox+node._x*SCALE;
|
||
var cy=rect.top+c.height/2+oy+node._y*SCALE;
|
||
ed.style.display='block';ed.style.left=cx+'px';ed.style.top=cy+'px';
|
||
ei.value=node.text;ei.focus();ei.select();
|
||
}
|
||
});
|
||
|
||
// Touch
|
||
c.addEventListener('touchstart',function(e){
|
||
if(e.touches.length===1){
|
||
var rect=c.getBoundingClientRect();
|
||
var sx=(e.touches[0].clientX-rect.left-c.width/2-ox)/SCALE;
|
||
var sy=(e.touches[0].clientY-rect.top-c.height/2-oy)/SCALE;
|
||
var node=hitTest(sx,sy);
|
||
if(node){selected=node;draw();}
|
||
else{dragging=true;dragX=e.touches[0].clientX;dragY=e.touches[0].clientY;}
|
||
}
|
||
},{passive:false});
|
||
c.addEventListener('touchmove',function(e){
|
||
if(dragging&&e.touches.length===1){
|
||
e.preventDefault();
|
||
ox+=e.touches[0].clientX-dragX;oy+=e.touches[0].clientY-dragY;
|
||
dragX=e.touches[0].clientX;dragY=e.touches[0].clientY;draw();
|
||
}
|
||
},{passive:false});
|
||
c.addEventListener('touchend',function(){dragging=false;});
|
||
|
||
// ---- Mobile: sidebar toggle ----
|
||
function toggleSidebar(){
|
||
var sb=document.querySelector('.sidebar');
|
||
var ov=document.getElementById('overlay');
|
||
sb.classList.toggle('open');
|
||
ov.classList.toggle('show');
|
||
}
|
||
function closeSidebar(){
|
||
document.querySelector('.sidebar').classList.remove('open');
|
||
document.getElementById('overlay').classList.remove('show');
|
||
}
|
||
|
||
// ---- Mobile: double-tap to edit ----
|
||
var _lastTap=0;
|
||
c.addEventListener('touchend',function(e){
|
||
var now=Date.now();
|
||
if(now-_lastTap<300){
|
||
// double tap
|
||
e.preventDefault();
|
||
var rect=c.getBoundingClientRect();
|
||
var sx=(e.changedTouches[0].clientX-rect.left-c.width/2-ox)/SCALE;
|
||
var sy=(e.changedTouches[0].clientY-rect.top-c.height/2-oy)/SCALE;
|
||
var node=hitTest(sx,sy);
|
||
if(node){
|
||
editing=node;
|
||
var cx=rect.left+c.width/2+ox+node._x*SCALE;
|
||
var cy=rect.top+c.height/2+oy+node._y*SCALE;
|
||
ed.style.display='block';ed.style.left=cx+'px';ed.style.top=cy+'px';
|
||
ei.value=node.text;ei.focus();ei.select();
|
||
}
|
||
}
|
||
_lastTap=now;
|
||
// close sidebar when tapping canvas
|
||
if(window.innerWidth<=600)closeSidebar();
|
||
});
|
||
|
||
// ---- Mobile: pinch-to-zoom ----
|
||
var _pinchDist=0;
|
||
c.addEventListener('touchstart',function(e){
|
||
if(e.touches.length===2){
|
||
_pinchDist=Math.hypot(
|
||
e.touches[0].clientX-e.touches[1].clientX,
|
||
e.touches[0].clientY-e.touches[1].clientY
|
||
);
|
||
}
|
||
},{passive:false});
|
||
c.addEventListener('touchmove',function(e){
|
||
if(e.touches.length===2){
|
||
e.preventDefault();
|
||
var d=Math.hypot(
|
||
e.touches[0].clientX-e.touches[1].clientX,
|
||
e.touches[0].clientY-e.touches[1].clientY
|
||
);
|
||
var ratio=d/(_pinchDist||1);
|
||
SCALE=Math.max(0.3,Math.min(3,SCALE*ratio));
|
||
_pinchDist=d;
|
||
draw();
|
||
}
|
||
},{passive:false});
|
||
|
||
// ---- Keyboard shortcuts ----
|
||
document.addEventListener('keydown',function(e){
|
||
if(e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA')return; // 编辑中不触发
|
||
if(e.key==='Delete'||e.key==='Backspace'){
|
||
e.preventDefault();
|
||
delNode();
|
||
}
|
||
});
|
||
|
||
// ---- Actions ----
|
||
function commitEdit(){if(editing)editing.text=ei.value||'新节点';ed.style.display='none';editing=null;draw();autoSave();}
|
||
function cancelEdit(){ed.style.display='none';editing=null;}
|
||
|
||
function addChild(){
|
||
if(!selected){alert('请先选中一个节点');return;}
|
||
if(!selected.children)selected.children=[];
|
||
selected.children.push({id:uid(),text:'新节点'});
|
||
layout();draw();selected=selected.children[selected.children.length-1];draw();autoSave();
|
||
}
|
||
|
||
function addSibling(){
|
||
if(!selected||selected===tree){alert('请选中一个非根节点');return;}
|
||
function fp(node,target){
|
||
if(node.children)for(var i=0;i<node.children.length;i++){
|
||
if(node.children[i].id===target.id)return node;
|
||
var f=fp(node.children[i],target);if(f)return f;
|
||
}
|
||
return null;
|
||
}
|
||
var parent=fp(tree,selected);
|
||
if(!parent)return;
|
||
var idx=parent.children.indexOf(selected);
|
||
parent.children.splice(idx+1,0,{id:uid(),text:'新节点'});
|
||
layout();draw();selected=parent.children[idx+1];draw();autoSave();
|
||
}
|
||
|
||
function delNode(){
|
||
if(!selected||selected===tree){alert('不能删除根节点');return;}
|
||
function rm(node,target){
|
||
if(node.children)for(var i=0;i<node.children.length;i++){
|
||
if(node.children[i].id===target.id){node.children.splice(i,1);return true;}
|
||
if(rm(node.children[i],target))return true;
|
||
}
|
||
return false;
|
||
}
|
||
rm(tree,selected);selected=null;layout();draw();autoSave();
|
||
}
|
||
|
||
function zoomIn(){SCALE=Math.min(3,SCALE*1.15);draw();}
|
||
function zoomOut(){SCALE=Math.max(0.3,SCALE*0.85);draw();}
|
||
function goHome(){window.location.href='index.html';}
|
||
|
||
// ---- Init ----
|
||
(function init(){
|
||
maps=loadMaps();
|
||
if(maps.length===0){maps.push({name:'愈心谷小程序',tree:defaultTemplate()});saveMaps();}
|
||
renderSidebar();
|
||
switchMap(0);
|
||
syncServer();
|
||
window.addEventListener('resize',function(){draw();});
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|