PageSourceSearch

https://tools.hoisum.hk/js/marketing.js?v=20260703a

js hoisum.hk collected 2026-10-02 01:54:50 UTC 9,270 bytes, 221 lines download raw bytes

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.