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>