← Commit history

1.1.1: embed export gets live-SymView pin hovers (highlight + tooltip); fixes embed svg selection (pan/zoom)

John Lauer ·9b50712d03 ·3mo ago ·parent 73fb468
6 files changed +118−5
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-symbol"-version = "1.1.0"+version = "1.1.1" edition = "2021" description = "Create and preview KiCad/Fusion 360 schematic symbols with interactive viewer" 
VERSION+1−1
@@ -1 +1 @@-1.1.0+1.1.1
bin/adom-symbol
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-symbol",-  "version": "1.1.0",+  "version": "1.1.1",   "type": "app",   "description": "KiCad symbol creator with interactive viewer and delivery to KiCad/Fusion 360",   "dependencies": {
src/embed.html+105−2
@@ -40,6 +40,19 @@   .pin.on .d{display:block}   .empty{color:var(--muted);font-size:12px;padding:14px}   @media(max-width:620px){aside{flex-basis:200px}}+  /* pin hover — highlight + tooltip, parity with the live SymView */+  .vp svg text,.vp svg g.stroked-text{pointer-events:none}+  .hit{fill:rgba(0,230,220,0);stroke:none;cursor:pointer}+  .vp svg g.stroked-text.hl path{stroke:var(--accent)!important;fill:var(--accent)!important}+  .vp svg path.hl{stroke:var(--accent)!important;stroke-width:.22!important}+  #pinmark{pointer-events:none;fill:rgba(0,230,220,.18);stroke:var(--accent);stroke-width:.12;opacity:0;transition:opacity .1s}+  #tip{position:fixed;z-index:9999;max-width:300px;background:rgba(22,27,34,.97);backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);border:1px solid rgba(0,184,177,.34);border-radius:10px;padding:9px 12px;color:var(--text);font-size:12px;line-height:1.5;pointer-events:none;opacity:0;transition:opacity .12s;box-shadow:0 12px 40px rgba(0,0,0,.55)}+  #tip.show{opacity:1}+  #tip .r1{display:flex;align-items:baseline;gap:8px;justify-content:space-between}+  #tip .nm{font-weight:700;font-size:13px}+  #tip .pn{font-family:'JetBrains Mono',ui-monospace,monospace;font-size:11px;color:var(--muted);white-space:nowrap}+  #tip .badge{display:inline-block;margin-top:5px;font-size:10px;font-weight:700;padding:2px 7px;border-radius:5px;letter-spacing:.2px}+  #tip .desc{margin-top:6px;color:#aeb6c2;font-size:11.5px} </style></head> <body>   <header><span class="dot"></span><span class="b">adom-symbol <span>· interactive</span></span><span class="nm">__NAME__</span></header>@@ -60,10 +73,13 @@       <div class="pins" id="pins"></div>     </aside>   </main>+  <div id="tip"></div> <script>-var PINS=__PINS__;+var PINS=__PINS__, OFF=__OFFSET__; (function(){-  var vp=document.getElementById('vp'), svg=vp.querySelector('svg');+  // The symbol svg is the DIRECT child of #vp; querySelector('svg') alone would+  // grab a toolbar button's zoom icon (also broke pan/zoom before this fix).+  var vp=document.getElementById('vp'), svg=vp.querySelector(':scope > svg')||vp.querySelector('svg');   if(svg){ svg.removeAttribute('width'); svg.removeAttribute('height'); }   var scale=1,tx=0,ty=0;   function apply(){ if(svg) svg.style.transform='translate('+tx+'px,'+ty+'px) scale('+scale+')'; }@@ -93,12 +109,99 @@ var PINS=__PINS__;         +'<span class="ty" style="color:'+col+';border-color:'+col+'66">'+esc(p.type||'')+'</span></div>'         +(p.desc?'<div class="d">'+esc(p.desc)+'</div>':'');       el.onclick=function(){ el.classList.toggle('on'); };+      el.addEventListener('mouseenter',function(){ highlightPin(p); });+      el.addEventListener('mouseleave',function(){ clearHL(); });       box.appendChild(el);     });   }   function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];}); }   document.getElementById('q').addEventListener('input',function(e){ render(e.target.value); });   render('');++  // ── pin hover: highlight + tooltip on the SVG, parity with the live SymView ──+  var NS='http://www.w3.org/2000/svg';+  var tip=document.getElementById('tip');+  var textMap=new Map();+  function norm(s){ return String(s||'').replace(/[~{}]/g,'').trim(); }+  // Pair each invisible <text opacity="0"> with the visible <g class="stroked-text">+  // glyph that follows it in document order (works for rotated top/bottom pins too).+  function glyphLabels(){+    var out=[], pending=null;+    if(!svg) return out;+    svg.querySelectorAll('text, g.stroked-text').forEach(function(n){+      if(n.classList && n.classList.contains('stroked-text')){+        if(pending){ var k=norm(pending.textContent); if(k) out.push({k:k,group:n}); pending=null; }+      } else if(n.getAttribute && n.getAttribute('opacity')==='0'){ pending=n; }+    });+    return out;+  }+  function highlightPin(p){+    clearHL();+    [p.name,p.number].forEach(function(k){ var a=textMap.get(norm(k)); if(a) a.forEach(function(g){ g.classList.add('hl'); }); });+    var m=document.getElementById('pinmark');+    if(m && OFF){ m.setAttribute('cx',OFF.x+p.x); m.setAttribute('cy',OFF.y-p.y); m.setAttribute('r',0.7); m.style.opacity='1'; }+  }+  function clearHL(){+    if(svg) svg.querySelectorAll('.hl').forEach(function(e){ e.classList.remove('hl'); });+    var m=document.getElementById('pinmark'); if(m) m.style.opacity='0';+  }+  function showTip(p,x,y){+    var col=p.color||'#8b949e';+    var h='<div class="r1"><span class="nm">'+esc(p.name||'(unnamed)')+'</span><span class="pn">Pin '+esc(p.number)+'</span></div>';+    if(p.type) h+='<span class="badge" style="background:'+col+'22;color:'+col+';border:1px solid '+col+'44">'+esc(p.type)+'</span>';+    if(p.desc) h+='<div class="desc">'+esc(p.desc)+'</div>';+    tip.innerHTML=h; tip.classList.add('show'); posTip(x,y);+  }+  function posTip(x,y){+    var w=tip.offsetWidth||260, hh=tip.offsetHeight||110, tx=x+14, ty=y+14;+    if(tx+w>innerWidth-14) tx=x-w-14;+    if(ty+hh>innerHeight-14) ty=y-hh-14;+    tip.style.left=Math.max(8,tx)+'px'; tip.style.top=Math.max(8,ty)+'px';+  }+  function hideTip(){ tip.classList.remove('show'); clearHL(); }+  // One transparent hit-rect per pin label (number + name), matched to each pin's+  // glyph by proximity to the pin tip so duplicate names (e.g. many GND) resolve+  // to the right pin. Rects live in SVG space so they pan/zoom with the drawing.+  function buildHits(){+    if(!svg || !OFF) return;+    if(!document.getElementById('pinmark')){ var pm=document.createElementNS(NS,'circle'); pm.id='pinmark'; svg.appendChild(pm); }+    var gl=glyphLabels();+    textMap=new Map();+    gl.forEach(function(o){ if(!textMap.has(o.k)) textMap.set(o.k,[]); textMap.get(o.k).push(o.group); });+    var labels=[];+    gl.forEach(function(o){ var b; try{ b=o.group.getBBox(); }catch(e){ return; } if(!b||!b.width) return;+      labels.push({k:o.k, box:b, cx:b.x+b.width/2, cy:b.y+b.height/2, used:false}); });+    function pick(k,ax,ay){ k=norm(k); var best=null,bd=1e9;+      labels.forEach(function(l){ if(l.used||l.k!==k) return; var d=Math.hypot(l.cx-ax,l.cy-ay); if(d<bd){ bd=d; best=l; } });+      if(best) best.used=true; return best; }+    var hideT=null;+    function addBox(b,p){+      var pad=0.45, r=document.createElementNS(NS,'rect');+      r.setAttribute('class','hit');+      r.setAttribute('x',b.x-pad); r.setAttribute('y',b.y-pad);+      r.setAttribute('width',b.width+pad*2); r.setAttribute('height',b.height+pad*2); r.setAttribute('rx',.3);+      r.addEventListener('mouseenter',function(ev){ clearTimeout(hideT); showTip(p,ev.clientX,ev.clientY); highlightPin(p); });+      r.addEventListener('mousemove',function(ev){ if(tip.classList.contains('show')) posTip(ev.clientX,ev.clientY); });+      r.addEventListener('mouseleave',function(){ clearTimeout(hideT); hideT=setTimeout(hideTip,45); });+      svg.appendChild(r);+    }+    PINS.forEach(function(p){+      var tipX=OFF.x+p.x, tipY=OFF.y-p.y, L=p.length||2.54, dx=0, dy=0;+      if(p.angle===0) dx=1; else if(p.angle===180) dx=-1; else if(p.angle===90) dy=-1; else if(p.angle===270) dy=1;+      var nl=pick(p.number, tipX+dx*L*0.5, tipY+dy*L*0.5-(dx!==0?0.9:0));   // number: over the wire+      var ml=pick(p.name,   tipX+dx*(L+2), tipY+dy*(L+2));                  // name: inside the body+      if(nl) addBox(nl.box,p);+      if(ml) addBox(ml.box,p);+      // Discrete parts hide pin name+number → no glyphs. Fall back to a box on the wire.+      if(!nl && !ml){+        var ex=tipX+dx*L, ey=tipY+dy*L, HT=1.0;+        addBox({x:Math.min(tipX,ex)-(dx===0?HT:0.3), y:Math.min(tipY,ey)-(dy===0?HT:0.3),+                width:Math.abs(ex-tipX)+(dx===0?HT*2:0.6), height:Math.abs(ey-tipY)+(dy===0?HT*2:0.6)}, p);+      }+    });+  }+  buildHits();+   apply(); })(); </script>
src/main.rs+10
@@ -452,14 +452,24 @@ pub(crate) fn build_embed_html(name: &str, svg: &str, meta: &parse::SymbolMeta)             serde_json::json!({                 "number": p.number, "name": p.name, "type": p.r#type,                 "color": p.color, "desc": p.desc,+                // geometry so the embed can place pin-hover hit zones exactly+                // where the live SymView does (KiCad symbol-local mm, Y-up).+                "x": p.x, "y": p.y, "angle": p.angle, "length": p.length,             })         })         .collect();     let pins_json = serde_json::to_string(&pins).unwrap_or_else(|_| "[]".into());+    // The symbol->SVG-unit offset that aligns pin geometry to the rendered glyphs+    // (same helper the live server hands the app as `state.offset`).+    let offset_json = match parse::compute_offset(svg, &meta.pins) {+        Some((x, y)) => format!("{{\"x\":{x},\"y\":{y}}}"),+        None => "null".into(),+    };     include_str!("embed.html")         .replace("__NAME__", &esc(name))         .replace("__SVG__", svg)         .replace("__PINS__", &pins_json)+        .replace("__OFFSET__", &offset_json) }  fn fetch_lib_from_service(library: &str) -> Result<String> {