canvas-viewer
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canvas Viewer</title>
<style>
body{margin:0;font-family:system-ui,-apple-system,sans-serif;background:#f5f5f5;overflow:hidden}
#toolbar{position:fixed;top:0;left:0;right:0;z-index:100;background:#fff;border-bottom:1px solid #ddd;padding:8px 12px;display:flex;gap:8px;align-items:center;font-size:13px}
#toolbar button{padding:4px 12px;cursor:pointer;border:1px solid #ccc;background:#fff;border-radius:4px}
#toolbar button:hover{background:#f0f0f0}
#container{position:fixed;top:40px;left:0;right:0;bottom:0;overflow:hidden;cursor:grab}
#container.dragging{cursor:grabbing}
#viewport{position:absolute;left:0;top:0;width:0;height:0;transform-origin:0 0}
svg.edges{position:absolute;left:0;top:0;overflow:visible;pointer-events:none}
svg.edges path{stroke:#8a8a8a;stroke-width:2;fill:none}
svg.edges polygon{fill:#8a8a8a}
.cn{position:absolute;background:#fff;border:1px solid #d0d0d0;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08);display:flex;flex-direction:column;overflow:hidden}
.cn .ct{padding:6px 10px;font-weight:600;font-size:13px;border-bottom:1px solid #eee;background:#fafafa;cursor:move}
.cn .cb{padding:8px 12px;font-size:13px;overflow:auto;flex:1}
.cn img{width:100%;height:100%;object-fit:contain}
.cn.group{background:rgba(90,130,210,.06);border:1px dashed rgba(90,130,210,.4);box-shadow:none}
.cn.group .ct{background:rgba(90,130,210,.12);border:none}
.cn a{color:#3370ff;text-decoration:none}
.cn a:hover{text-decoration:underline}
#status{color:#999;font-size:12px}
</style>
</head>
<body>
<div id="toolbar">
<strong>Canvas Viewer</strong>
<input type="file" id="fileInput" accept=".canvas,application/json" style="display:none">
<button onclick="document.getElementById('fileInput').click()">打开本地 .canvas</button>
<button id="zin">+</button>
<button id="zout">-</button>
<button id="fit">适应窗口</button>
<span id="status"></span>
</div>
<div id="container"><div id="viewport"><svg class="edges" id="edges"></svg></div></div>
<script src="https://cdn.jsdelivr.net/npm/markdown-it@14.1.0/dist/markdown-it.min.js"></script>
<script>
/* 配置:画板里引用的图片如果和 .canvas 不在同一目录,请改成你的图片根目录,例如
const IMG_BASE = 'https://www.yunqishi.site/upload/'; */
const IMG_BASE = './';
const md = window.markdownit ? window.markdownit({html:true, linkify:true, breaks:true}) : null;
const container = document.getElementById('container');
const viewport = document.getElementById('viewport');
const edgesSvg = document.getElementById('edges');
const statusEl = document.getElementById('status');
let scale=1, tx=0, ty=0, dragging=false, sx=0, sy=0;
function applyTransform(){
viewport.style.transform = 'translate('+tx+'px,'+ty+'px) scale('+scale+')';
}
container.addEventListener('mousedown', e=>{ dragging=true; sx=e.clientX-tx; sy=e.clientY-ty; container.classList.add('dragging'); });
window.addEventListener('mousemove', e=>{ if(!dragging) return; tx=e.clientX-sx; ty=e.clientY-sy; applyTransform(); });
window.addEventListener('mouseup', ()=>{ dragging=false; container.classList.remove('dragging'); });
container.addEventListener('wheel', e=>{
e.preventDefault();
const f = e.deltaY<0 ? 1.1 : 0.9;
scale = Math.min(3, Math.max(0.05, scale*f));
applyTransform();
},{passive:false});
document.getElementById('zin').onclick = ()=>{ scale=Math.min(3,scale*1.2); applyTransform(); };
document.getElementById('zout').onclick = ()=>{ scale=Math.max(0.05,scale/1.2); applyTransform(); };
document.getElementById('fit').onclick = ()=>fitView();
function fitView(){
const nodes = viewport.querySelectorAll('.cn');
if(!nodes.length) return;
let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
nodes.forEach(n=>{
const x=parseFloat(n.dataset.x), y=parseFloat(n.dataset.y),
w=parseFloat(n.dataset.w), h=parseFloat(n.dataset.h);
minX=Math.min(minX,x); minY=Math.min(minY,y);
maxX=Math.max(maxX,x+w); maxY=Math.max(maxY,y+h);
});
const pad=40, cw=container.clientWidth, ch=container.clientHeight;
scale = Math.min(cw/(maxX-minX+pad*2), ch/(maxY-minY+pad*2), 1.5);
tx = -minX*scale + (cw-(maxX-minX)*scale)/2;
ty = -minY*scale + (ch-(maxY-minY)*scale)/2;
applyTransform();
}
function renderNode(n){
const d=document.createElement('div');
d.className='cn';
d.dataset.x=n.x; d.dataset.y=n.y; d.dataset.w=n.width||200; d.dataset.h=n.height||80;
d.style.left=(n.x)+'px'; d.style.top=(n.y)+'px';
d.style.width=(n.width||200)+'px'; d.style.height=(n.height||80)+'px';
if(n.type==='group'){
d.classList.add('group');
const t=document.createElement('div'); t.className='ct'; t.textContent=n.label||'分组';
d.appendChild(t);
} else if(n.type==='text'){
const c=document.createElement('div'); c.className='cb';
c.innerHTML = md ? md.render(n.text||'') : (n.text||'');
d.appendChild(c);
} else if(n.type==='link'){
const c=document.createElement('div'); c.className='cb';
const a=document.createElement('a'); a.href=n.url; a.target='_blank'; a.textContent=n.url;
c.appendChild(a); d.appendChild(c);
} else if(n.type==='file'){
const path = n.file || '';
const lower = path.toLowerCase();
if(/\.(png|jpe?g|gif|svg|webp|bmp)$/.test(lower)){
const img=document.createElement('img'); img.src=IMG_BASE+path; d.appendChild(img);
} else {
const c=document.createElement('div'); c.className='cb';
c.textContent = '📄 ' + path;
d.appendChild(c);
}
}
viewport.appendChild(d);
return d;
}
function renderEdges(data){
edgesSvg.innerHTML='';
const byId={};
data.nodes.forEach(n=>byId[n.id]=n);
(data.edges||[]).forEach(e=>{
const a=byId[e.fromNode], b=byId[e.toNode];
if(!a||!b) return;
const x1=a.x+(a.width||200)/2, y1=a.y+(a.height||80)/2;
const x2=b.x+(b.width||200)/2, y2=b.y+(b.height||80)/2;
const dx=x2-x1, dy=y2-y1, len=Math.sqrt(dx*dx+dy*dy)||1;
// 缩短线,避免箭头被节点盖住
const ux=dx/len, uy=dy/len, inset=12;
const sx2=x2-ux*inset, sy2=y2-uy*inset;
const ns1=x1+ux*inset, ny1=y1+uy*inset;
const p=document.createElementNS('http://www.w3.org/2000/svg','path');
// 简单贝塞尔曲线,根据方向决定弯曲
const mx=(ns1+sx2)/2, my=(ny1+sy2)/2;
const bend = Math.max(20, len*0.1);
const nx = -(uy), nny = ux;
p.setAttribute('d','M'+ns1+' '+ny1+' Q'+(mx+nx*bend)+' '+(my+nny*bend)+' '+sx2+' '+sy2);
edgesSvg.appendChild(p);
// 箭头
const ang=Math.atan2(dy,dx);
const poly=document.createElementNS('http://www.w3.org/2000/svg','polygon');
const arr=8;
poly.setAttribute('points',
(x2)+','+(y2)+' '+
(x2-arr*Math.cos(ang-0.4))+','+(y2-arr*Math.sin(ang-0.4))+' '+
(x2-arr*Math.cos(ang+0.4))+','+(y2-arr*Math.sin(ang+0.4)));
edgesSvg.appendChild(poly);
});
}
function render(data){
viewport.innerHTML='';
const svg=document.createElementNS('http://www.w3.org/2000/svg','svg');
svg.setAttribute('class','edges'); svg.setAttribute('id','edges');
viewport.appendChild(svg);
const _edges=svg;
// 先画边,再画节点(让节点盖在线上)
renderEdgesInto(_edges, data);
(data.nodes||[]).forEach(renderNode);
fitView();
statusEl.textContent = '节点 '+ (data.nodes||[]).length + ' / 连线 '+ (data.edges||[]).length;
}
function renderEdgesInto(svgEl, data){
const byId={};
data.nodes.forEach(n=>byId[n.id]=n);
(data.edges||[]).forEach(e=>{
const a=byId[e.fromNode], b=byId[e.toNode];
if(!a||!b) return;
const x1=a.x+(a.width||200)/2, y1=a.y+(a.height||80)/2;
const x2=b.x+(b.width||200)/2, y2=b.y+(b.height||80)/2;
const dx=x2-x1, dy=y2-y1, len=Math.sqrt(dx*dx+dy*dy)||1;
const ux=dx/len, uy=dy/len, inset=12;
const sx2=x2-ux*inset, sy2=y2-uy*inset;
const ns1=x1+ux*inset, ny1=y1+uy*inset;
const p=document.createElementNS('http://www.w3.org/2000/svg','path');
const mx=(ns1+sx2)/2, my=(ny1+sy2)/2;
const bend=Math.max(20,len*0.1);
const nx=-uy, nny=ux;
p.setAttribute('d','M'+ns1+' '+ny1+' Q'+(mx+nx*bend)+' '+(my+nny*bend)+' '+sx2+' '+sy2);
svgEl.appendChild(p);
const ang=Math.atan2(dy,dx), arr=8;
const poly=document.createElementNS('http://www.w3.org/2000/svg','polygon');
poly.setAttribute('points',
x2+','+y2+' '+
(x2-arr*Math.cos(ang-0.4))+','+(y2-arr*Math.sin(ang-0.4))+' '+
(x2-arr*Math.cos(ang+0.4))+','+(y2-arr*Math.sin(ang+0.4)));
svgEl.appendChild(poly);
});
}
/* 加载 */
document.getElementById('fileInput').addEventListener('change', e=>{
const f=e.target.files[0]; if(!f) return;
const r=new FileReader();
r.onload=()=>{ try{ render(JSON.parse(r.result)); }catch(err){ statusEl.textContent='解析失败: '+err.message; } };
r.readAsText(f);
});
// 支持 URL 参数 ?file=xxx.canvas
const params=new URLSearchParams(location.search);
const fileUrl=params.get('file');
if(fileUrl){
statusEl.textContent='加载中…';
fetch(fileUrl).then(r=>{ if(!r.ok) throw new Error('HTTP '+r.status); return r.json(); })
.then(render)
.catch(err=>{ statusEl.textContent='加载失败: '+err.message; });
}
</script>
</body>
</html>
