1/* ===== Scroll-triggered reveal ===== */ 2(function(){ 3 if(!('IntersectionObserver' in window)) { 4 document.querySelectorAll('.reveal').forEach(el => el.classList.add('in')); 5 return; 6 } 7 const obs = new IntersectionObserver((entries) => { 8 entries.forEach(e => { 9 if(e.isIntersecting){ 10 e.target.classList.add('in'); 11 obs.unobserve(e.target); 12 } 13 }); 14 }, { threshold: 0.12, rootMargin: '0px 0px -60px 0px' }); 15 document.querySelectorAll('.reveal').forEach(el => obs.observe(el)); 16})(); 17 18/* ===== HOW IT WORKS stepper sequential reveal ===== */ 19(function(){ 20 const track = document.querySelector('.pm-track'); 21 if(!track) return; 22 if(!('IntersectionObserver' in window)){ track.classList.add('in'); return; } 23 const o = new IntersectionObserver((entries) => { 24 entries.forEach(e => { 25 if(e.isIntersecting){ track.classList.add('in'); o.unobserve(track); } 26 }); 27 }, { threshold: 0.5 }); 28 o.observe(track); 29})(); 30 31/* ===== Number counter animation ===== */ 32(function(){ 33 function animateCount(el, target, duration){ 34 const start = performance.now(); 35 const from = 0; 36 const suffix = (el.dataset.suffix || ''); 37 function frame(now){ 38 const p = Math.min(1, (now - start) / duration); 39 const eased = 1 - Math.pow(1 - p, 3); 40 const val = Math.round(from + (target - from) * eased); 41 el.textContent = val + suffix; 42 if(p < 1) requestAnimationFrame(frame); 43 } 44 requestAnimationFrame(frame); 45 } 46 function setupCount(el){ 47 const raw = el.textContent.trim(); 48 const m = raw.match(/^([0-9.]+)(.*)$/); 49 if(!m) return; 50 const target = parseInt(m[1], 10); 51 el.dataset.suffix = m[2] || ''; 52 el.textContent = '0' + (m[2] || ''); 53 if(!('IntersectionObserver' in window)){ animateCount(el, target, 900); return; } 54 const o = new IntersectionObserver((entries) => { 55 entries.forEach(e => { 56 if(e.isIntersecting){ animateCount(el, target, 1100); o.unobserve(el); } 57 }); 58 }, { threshold: 0.6 }); 59 o.observe(el); 60 } 61 document.querySelectorAll('.mfw3-stat .num').forEach(setupCount); 62 document.querySelectorAll('.cw-stat .v').forEach(setupCount); 63 document.querySelectorAll('.cw-stage .n').forEach(el => { 64 if(/^[0-9]+$/.test(el.textContent.trim())) setupCount(el); 65 }); 66})(); 67 68/* ===== Flow-section showcase carousel (scroll-snap + arrows + dots) ===== */ 69(function(){ 70 const wrap = document.querySelector('.mf-carousel'); 71 const track = wrap && wrap.querySelector('.mf-showcases'); 72 if(!wrap || !track) return; 73 const slides = Array.from(track.children); 74 if(slides.length < 2) return; 75 const prev = wrap.querySelector('.mf-car-btn.prev'); 76 const next = wrap.querySelector('.mf-car-btn.next'); 77 const dots = Array.from(wrap.querySelectorAll('.mf-car-dot')); 78 79 function nearest(x){ 80 let best = 0, bestD = Infinity; 81 slides.forEach((s, i) => { 82 const d = Math.abs(s.offsetLeft - x); 83 if(d < bestD){ bestD = d; best = i; } 84 }); 85 return best; 86 } 87 function current(){ return nearest(track.scrollLeft); } 88 function go(i){ 89 const target = slides[Math.max(0, Math.min(slides.length - 1, i))]; 90 track.scrollTo({ left: target.offsetLeft, behavior: 'smooth' }); 91 } 92 function sync(){ 93 const i = current(); 94 dots.forEach((d, j) => { 95 const active = j === i; 96 d.classList.toggle('on', active); 97 d.setAttribute('aria-current', String(active)); 98 }); 99 if(prev) prev.disabled = i === 0; 100 if(next) next.disabled = i === slides.length - 1; 101 } 102 103 if(prev) prev.addEventListener('click', () => go(current() - 1)); 104 if(next) next.addEventListener('click', () => go(current() + 1)); 105 dots.forEach((d, i) => d.addEventListener('click', () => go(i))); 106 107 // Mouse drag-to-scroll â plain mice have no horizontal wheel, so without 108 // this the arrows are a desktop user's only way to move the carousel. 109 // Touch/pen keep native scrolling (pointerType guard). 110 let dragX = null, dragLeft = 0, snapTimer = 0; 111 track.addEventListener('dragstart', (e) => e.preventDefault()); // <img> is natively draggable 112 track.addEventListener('pointerdown', (e) => { 113 if(e.pointerType !== 'mouse' || e.button !== 0) return; 114 dragX = e.clientX; 115 dragLeft = track.scrollLeft; 116 track.classList.add('dragging'); 117 track.style.scrollSnapType = 'none'; // snap fights direct scrollLeft writes 118 }); 119 window.addEventListener('pointermove', (e) => { 120 if(dragX === null) return; 121 track.scrollLeft = dragLeft - (e.clientX - dragX); 122 });
123 function endDrag(){ 124 if(dragX === null) return; 125 dragX = null; 126 track.classList.remove('dragging'); 127 const delta = track.scrollLeft - dragLeft; 128 const startIdx = nearest(dragLeft); 129 let i = current(); 130 // A deliberate short drag that didn't cross the midpoint still means 131 // "next/previous slide" â don't rubber-band back. 132 if(i === startIdx && Math.abs(delta) > 60) i = startIdx + (delta > 0 ? 1 : -1); 133 go(i); 134 // Keep snap OFF until our smooth scroll settles â re-enabling it 135 // immediately makes the browser re-snap on its own, racing (and 136 // usually beating) the scrollTo above. 137 const done = () => { 138 track.style.scrollSnapType = ''; 139 // If the smooth scroll got interrupted (tab backgrounded, second 140 // gesture), don't leave the track resting between snap points. 141 const targetLeft = slides[current()].offsetLeft; 142 if(Math.abs(track.scrollLeft - targetLeft) > 1) track.scrollLeft = targetLeft; 143 track.removeEventListener('scrollend', done); 144 clearTimeout(snapTimer); 145 }; 146 if('onscrollend' in track) track.addEventListener('scrollend', done); 147 snapTimer = setTimeout(done, 700); // fallback where scrollend is unsupported 148 } 149 window.addEventListener('pointerup', endDrag); 150 window.addEventListener('pointercancel', endDrag); 151 let raf = 0; 152 track.addEventListener('scroll', () => { 153 if(raf) return; 154 raf = requestAnimationFrame(() => { raf = 0; sync(); }); 155 }, { passive: true }); 156 window.addEventListener('resize', sync); 157 sync(); 158})(); 159 160/* ===== Nav toggle + CTA navigation bindings ===== 161 CSP-safe replacement for inline onclick handlers. The site's /_headers CSP 162 has no 'unsafe-inline' in script-src, so inline on* attributes are blocked in 163 production; these listeners run from this allowed external script instead. */ 164(function(){ 165 const toggle = document.querySelector('.nav-toggle'); 166 if(toggle) toggle.addEventListener('click', toggleMenu); 167 // Tapping a menu link should close the dropdown so it doesn't stay floating 168 // over the page the user just navigated to (links now point to real pages 169 // like /pricing/ and /features/*, not in-page anchors). 170 document.querySelectorAll('.nav-links a').forEach(a => { 171 a.addEventListener('click', () => { 172 const links = document.querySelector('.nav-links'); 173 if(links) links.classList.remove('open'); 174 if(toggle) toggle.setAttribute('aria-expanded', 'false'); 175 }); 176 }); 177 document.querySelectorAll('[data-nav]').forEach(el => { 178 el.addEventListener('click', () => { window.location = el.getAttribute('data-nav'); }); 179 }); 180})(); 181 182/* ===== Mobile nav ===== 183 Styling lives in the page's .nav-links.open CSS (index.html); JS only 184 toggles the class â inline styles here previously forced a white panel 185 that the dark-nav theme's pale link color was unreadable on. */ 186function toggleMenu(){ 187 const links = document.querySelector('.nav-links'); 188 if(!links) return; 189 const open = links.classList.toggle('open'); 190 const toggle = document.querySelector('.nav-toggle'); 191 if(toggle) toggle.setAttribute('aria-expanded', String(open)); 192} 193 194/* ===== Hero parse flow â redirect to /app/parse with URL ===== */ 195(function(){ 196 function goToParse(inputEl) { 197 const raw = (inputEl?.value || '').trim(); 198 if (!raw) { window.location = 'https://hoisum.hk/app'; return; } 199 if (/^https?:\/\//i.test(raw)) { 200 window.location = 'https://hoisum.hk/app/parse?url=' + encodeURIComponent(raw); 201 } else if (/^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+(\/\S*)?$/i.test(raw)) { 202 // Bare domain â the SPA only accepts http(s) URLs, so add the scheme here. 203 window.location = 'https://hoisum.hk/app/parse?url=' + encodeURIComponent('https://' + raw); 204 } else { 205 try { sessionStorage.setItem('hs_hero_paste', raw); } catch(e) {} 206 window.location = 'https://hoisum.hk/app/parse?source=paste'; 207 } 208 } 209 210 const jdInput = document.getElementById('jd-input'); 211 const jdBtn = document.getElementById('jd-btn'); 212 const btnPaste = document.getElementById('btn-paste'); 213 if(jdBtn) jdBtn.addEventListener('click', () => goToParse(jdInput)); 214 if(jdInput) jdInput.addEventListener('keydown', e => { if(e.key === 'Enter'){ e.preventDefault(); goToParse(jdInput); } }); 215 if(btnPaste) btnPaste.addEventListener('click', e => { e.preventDefault(); window.location = 'https://hoisum.hk/app/parse?source=paste'; }); 216 217 const jdInput2 = document.getElementById('jd-input-2'); 218 const jdBtn2 = document.getElementById('jd-btn-2'); 219 if(jdBtn2) jdBtn2.addEventListener('click', () => goToParse(jdInput2)); 220 if(jdInput2) jdInput2.addEventListener('keydown', e => { if(e.key === 'Enter'){ e.preventDefault(); goToParse(jdInput2); } }); 221})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.