|
| 1 | +(function(){ |
| 2 | + const nav = document.getElementById('site-nav'); |
| 3 | + const toggle = document.querySelector('.nav-toggle'); |
| 4 | + const links = nav ? Array.from(nav.querySelectorAll('a[href^="#"]')) : []; |
| 5 | + |
| 6 | + if (toggle && nav) { |
| 7 | + toggle.addEventListener('click', () => { |
| 8 | + const isOpen = nav.classList.toggle('open'); |
| 9 | + toggle.setAttribute('aria-expanded', String(isOpen)); |
| 10 | + }); |
| 11 | + } |
| 12 | + |
| 13 | + // Smooth scroll and close mobile menu |
| 14 | + links.forEach(link => { |
| 15 | + link.addEventListener('click', (e) => { |
| 16 | + const id = link.getAttribute('href'); |
| 17 | + if (!id || id.length < 2) return; |
| 18 | + const target = document.querySelector(id); |
| 19 | + if (!target) return; |
| 20 | + e.preventDefault(); |
| 21 | + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); |
| 22 | + nav.classList.remove('open'); |
| 23 | + if (toggle) toggle.setAttribute('aria-expanded', 'false'); |
| 24 | + history.replaceState(null, '', id); |
| 25 | + }); |
| 26 | + }); |
| 27 | + |
| 28 | + // Active section highlighting (scroll spy) |
| 29 | + // Track intersection ratios and highlight the link for the section |
| 30 | + // that occupies the most space in the viewport. |
| 31 | + const sections = Array.from(document.querySelectorAll('main section[id]')) |
| 32 | + .filter(sec => links.some(a => a.getAttribute('href') === `#${sec.id}`)); |
| 33 | + |
| 34 | + const ratioById = Object.fromEntries(sections.map(s => [s.id, 0])); |
| 35 | + |
| 36 | + const updateActive = () => { |
| 37 | + let bestId = null; |
| 38 | + let bestRatio = 0; |
| 39 | + for (const [id, r] of Object.entries(ratioById)) { |
| 40 | + if (r > bestRatio) { bestRatio = r; bestId = id; } |
| 41 | + } |
| 42 | + if (!bestId) return; |
| 43 | + const activeHref = `#${bestId}`; |
| 44 | + links.forEach(a => a.classList.toggle('active', a.getAttribute('href') === activeHref)); |
| 45 | + }; |
| 46 | + |
| 47 | + const observer = new IntersectionObserver((entries) => { |
| 48 | + entries.forEach(entry => { |
| 49 | + const id = entry.target.getAttribute('id'); |
| 50 | + ratioById[id] = entry.intersectionRatio; |
| 51 | + }); |
| 52 | + updateActive(); |
| 53 | + }, { root: null, rootMargin: '0px 0px -35% 0px', threshold: [0, 0.1, 0.25, 0.5, 0.75, 1] }); |
| 54 | + |
| 55 | + sections.forEach(sec => observer.observe(sec)); |
| 56 | + |
| 57 | + // Initialize once in case we load mid-page |
| 58 | + updateActive(); |
| 59 | + |
| 60 | + // Footer year |
| 61 | + const yearEl = document.getElementById('year'); |
| 62 | + if (yearEl) yearEl.textContent = String(new Date().getFullYear()); |
| 63 | + |
| 64 | + // No slider needed now (single demo video placeholder) |
| 65 | + |
| 66 | + // Auto-resize embedded iframes to fit their content (opt-in via data-auto-height="true") |
| 67 | + const embeddedIframes = Array.from(document.querySelectorAll('iframe.embedded-iframe[data-auto-height="true"]')); |
| 68 | + const computeDocHeight = (doc) => { |
| 69 | + try { |
| 70 | + if (!doc) return 0; |
| 71 | + const body = doc.body; |
| 72 | + const html = doc.documentElement; |
| 73 | + if (!body || !html) return 0; |
| 74 | + // Use scrollHeight to allow shrink as content collapses |
| 75 | + return Math.max(body.scrollHeight, html.scrollHeight); |
| 76 | + } catch { return 0; } |
| 77 | + }; |
| 78 | + const setIframeHeight = (iframe, height) => { |
| 79 | + if (!iframe) return; |
| 80 | + const target = Math.max(0, Math.floor(height || 0)); |
| 81 | + if (target > 0) iframe.style.height = `${target}px`; |
| 82 | + }; |
| 83 | + |
| 84 | + // Strategy 1: Listen for postMessage from subpages (works with file:// as well) |
| 85 | + window.addEventListener('message', (event) => { |
| 86 | + // Do not rely on origin; instead, verify the source matches one of our iframes |
| 87 | + const data = event.data; |
| 88 | + if (!data || typeof data !== 'object' || data.type !== 'subpage:height') return; |
| 89 | + const target = embeddedIframes.find((f) => { |
| 90 | + try { return f.contentWindow === event.source; } catch { return false; } |
| 91 | + }); |
| 92 | + if (!target) return; |
| 93 | + const h = Number(data.height); |
| 94 | + if (Number.isFinite(h) && h > 0) setIframeHeight(target, h); |
| 95 | + }); |
| 96 | + |
| 97 | + // Strategy 2: Same-origin direct measurement with ResizeObserver |
| 98 | + embeddedIframes.forEach((iframe) => { |
| 99 | + const attachObservers = () => { |
| 100 | + let ro = null; let mo = null; |
| 101 | + try { |
| 102 | + const doc = iframe.contentDocument; |
| 103 | + const body = doc && doc.body; |
| 104 | + if (!body) return; |
| 105 | + // Initial set |
| 106 | + setIframeHeight(iframe, computeDocHeight(doc)); |
| 107 | + // ResizeObserver for layout changes |
| 108 | + ro = new ResizeObserver(() => setIframeHeight(iframe, computeDocHeight(doc))); |
| 109 | + ro.observe(body); |
| 110 | + // MutationObserver as a fallback for DOM changes |
| 111 | + mo = new MutationObserver(() => setIframeHeight(iframe, computeDocHeight(doc))); |
| 112 | + mo.observe(body, { attributes:true, childList:true, subtree:true, characterData:true }); |
| 113 | + // A couple of delayed reads to catch late layout |
| 114 | + setTimeout(() => setIframeHeight(iframe, computeDocHeight(doc)), 50); |
| 115 | + setTimeout(() => setIframeHeight(iframe, computeDocHeight(doc)), 250); |
| 116 | + } catch (_) { |
| 117 | + // Cross-origin: skip direct access |
| 118 | + } |
| 119 | + |
| 120 | + // Cleanup on reload of iframe |
| 121 | + iframe.addEventListener('load', () => { |
| 122 | + if (ro) try { ro.disconnect(); } catch {} |
| 123 | + if (mo) try { mo.disconnect(); } catch {} |
| 124 | + // Re-attach for new document |
| 125 | + attachObservers(); |
| 126 | + }, { once: true }); |
| 127 | + }; |
| 128 | + const docReady = () => { |
| 129 | + try { |
| 130 | + const rs = iframe.contentDocument && iframe.contentDocument.readyState; |
| 131 | + return rs === 'interactive' || rs === 'complete'; |
| 132 | + } catch { return false; } |
| 133 | + }; |
| 134 | + if (docReady()) attachObservers(); |
| 135 | + else iframe.addEventListener('load', attachObservers, { once: true }); |
| 136 | + }); |
| 137 | + |
| 138 | + // Copy BibTeX button |
| 139 | + const copyBtn = document.querySelector('.bibtex-copy-btn'); |
| 140 | + if (copyBtn) { |
| 141 | + copyBtn.addEventListener('click', async () => { |
| 142 | + const pre = document.querySelector('.bibtex-wrap pre.bibtex'); |
| 143 | + if (!pre) return; |
| 144 | + const text = pre.textContent || ''; |
| 145 | + try { |
| 146 | + await navigator.clipboard.writeText(text); |
| 147 | + copyBtn.textContent = 'Copied'; |
| 148 | + setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500); |
| 149 | + } catch (_) { |
| 150 | + // Fallback: select and copy |
| 151 | + const range = document.createRange(); |
| 152 | + range.selectNodeContents(pre); |
| 153 | + const sel = window.getSelection(); |
| 154 | + sel.removeAllRanges(); |
| 155 | + sel.addRange(range); |
| 156 | + try { document.execCommand('copy'); copyBtn.textContent = 'Copied'; } catch {} |
| 157 | + setTimeout(() => { copyBtn.textContent = 'Copy'; sel.removeAllRanges(); }, 1500); |
| 158 | + } |
| 159 | + }); |
| 160 | + } |
| 161 | +})(); |
| 162 | + |
0 commit comments