|
132 | 132 | html, body { |
133 | 133 | height: 100%; |
134 | 134 | overflow: hidden; |
135 | | - scroll-behavior: smooth; |
136 | 135 | background-color: var(--bg-light); |
137 | 136 | } |
138 | 137 |
|
|
337 | 336 | transition: opacity 0.3s ease-in-out, visibility 0.3s ease-in-out; |
338 | 337 | will-change: opacity; |
339 | 338 | overflow-y: auto; |
340 | | - scroll-behavior: smooth; |
341 | 339 | -webkit-overflow-scrolling: touch; |
| 340 | + overscroll-behavior: contain; |
342 | 341 | padding-bottom: 52px; |
343 | 342 | scrollbar-width: thin; |
344 | 343 | scrollbar-color: transparent transparent; |
|
975 | 974 | .content { |
976 | 975 | grid-column: 2 / 3; |
977 | 976 | } |
| 977 | + |
| 978 | + .cursor-follower { |
| 979 | + display: none !important; |
| 980 | + } |
978 | 981 | } |
979 | 982 |
|
980 | 983 |
|
|
985 | 988 | html, body { |
986 | 989 | overflow: auto; |
987 | 990 | height: auto; |
| 991 | + overscroll-behavior: contain; |
988 | 992 | } |
989 | 993 |
|
990 | 994 | .mobile-top-bar { |
|
2214 | 2218 | if (targetSection) { |
2215 | 2219 | targetSection.classList.add('active'); |
2216 | 2220 | if (typeof badgeRectsDirty !== 'undefined') badgeRectsDirty = true; |
| 2221 | + // Reset smooth scroller to current scroll position of newly active section |
| 2222 | + const scroller = smoothScrollInstances.get(targetSection); |
| 2223 | + if (scroller) { |
| 2224 | + scroller.targetScroll = targetSection.scrollTop; |
| 2225 | + scroller.currentScroll = targetSection.scrollTop; |
| 2226 | + } |
2217 | 2227 | } |
2218 | 2228 | navItems.forEach(nav => { |
2219 | 2229 | nav.classList.remove('active'); |
|
2338 | 2348 |
|
2339 | 2349 |
|
2340 | 2350 | /* ============================================ |
2341 | | - 7. SCROLL INDICATOR |
| 2351 | + 7. SMOOTH SCROLL ENGINE (iPhone-style damped) |
| 2352 | + ============================================ */ |
| 2353 | + const smoothScrollInstances = new Map(); |
| 2354 | + |
| 2355 | + class SmoothScroller { |
| 2356 | + constructor(el) { |
| 2357 | + this.el = el; |
| 2358 | + this.targetScroll = el.scrollTop; |
| 2359 | + this.currentScroll = el.scrollTop; |
| 2360 | + this.running = false; |
| 2361 | + this.lerpFactor = 0.1; // deceleration: lower = smoother/slower stop |
| 2362 | + this.maxWheelDelta = 120; // cap per wheel event (prevents overshooting) |
| 2363 | + this.touchMultiplier = 1.0; // touch swipe sensitivity |
| 2364 | + this.maxTouchVelocity = 6; // cap touch fling speed |
| 2365 | + this.velocityDecay = 0.92; // touch momentum decay per frame |
| 2366 | + |
| 2367 | + // Touch state |
| 2368 | + this._touchStartY = 0; |
| 2369 | + this._touchLastY = 0; |
| 2370 | + this._touchVelocity = 0; |
| 2371 | + this._touchTimestamp = 0; |
| 2372 | + this._isTouching = false; |
| 2373 | + this._touchMomentum = false; |
| 2374 | + |
| 2375 | + // Bind handlers |
| 2376 | + this._onWheel = this._onWheel.bind(this); |
| 2377 | + this._onTouchStart = this._onTouchStart.bind(this); |
| 2378 | + this._onTouchMove = this._onTouchMove.bind(this); |
| 2379 | + this._onTouchEnd = this._onTouchEnd.bind(this); |
| 2380 | + this._onKeyDown = this._onKeyDown.bind(this); |
| 2381 | + this._tick = this._tick.bind(this); |
| 2382 | + |
| 2383 | + this._attach(); |
| 2384 | + } |
| 2385 | + |
| 2386 | + _attach() { |
| 2387 | + this.el.addEventListener('wheel', this._onWheel, { passive: false }); |
| 2388 | + this.el.addEventListener('touchstart', this._onTouchStart, { passive: true }); |
| 2389 | + this.el.addEventListener('touchmove', this._onTouchMove, { passive: false }); |
| 2390 | + this.el.addEventListener('touchend', this._onTouchEnd, { passive: true }); |
| 2391 | + } |
| 2392 | + |
| 2393 | + destroy() { |
| 2394 | + this.el.removeEventListener('wheel', this._onWheel); |
| 2395 | + this.el.removeEventListener('touchstart', this._onTouchStart); |
| 2396 | + this.el.removeEventListener('touchmove', this._onTouchMove); |
| 2397 | + this.el.removeEventListener('touchend', this._onTouchEnd); |
| 2398 | + this.running = false; |
| 2399 | + } |
| 2400 | + |
| 2401 | + get maxScroll() { |
| 2402 | + return Math.max(0, this.el.scrollHeight - this.el.clientHeight); |
| 2403 | + } |
| 2404 | + |
| 2405 | + get isScrollable() { |
| 2406 | + return this.el.scrollHeight > this.el.clientHeight; |
| 2407 | + } |
| 2408 | + |
| 2409 | + _clamp(val) { |
| 2410 | + return Math.max(0, Math.min(val, this.maxScroll)); |
| 2411 | + } |
| 2412 | + |
| 2413 | + scrollTo(target, immediate) { |
| 2414 | + this.targetScroll = this._clamp(target); |
| 2415 | + if (immediate) { |
| 2416 | + this.currentScroll = this.targetScroll; |
| 2417 | + this.el.scrollTop = this.currentScroll; |
| 2418 | + } |
| 2419 | + this._touchMomentum = false; |
| 2420 | + this._startLoop(); |
| 2421 | + } |
| 2422 | + |
| 2423 | + _onWheel(e) { |
| 2424 | + if (!this.isScrollable) return; |
| 2425 | + // Don't hijack horizontal scrolling |
| 2426 | + if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return; |
| 2427 | + e.preventDefault(); |
| 2428 | + |
| 2429 | + // Clamp the delta to prevent overshooting from fast flicks |
| 2430 | + let delta = e.deltaY; |
| 2431 | + if (e.deltaMode === 1) delta *= 40; // line mode |
| 2432 | + if (e.deltaMode === 2) delta *= this.el.clientHeight; // page mode |
| 2433 | + delta = Math.max(-this.maxWheelDelta, Math.min(this.maxWheelDelta, delta)); |
| 2434 | + |
| 2435 | + this.targetScroll = this._clamp(this.targetScroll + delta); |
| 2436 | + this._touchMomentum = false; |
| 2437 | + this._startLoop(); |
| 2438 | + } |
| 2439 | + |
| 2440 | + _onTouchStart(e) { |
| 2441 | + if (!this.isScrollable) return; |
| 2442 | + const touch = e.touches[0]; |
| 2443 | + this._touchStartY = touch.clientY; |
| 2444 | + this._touchLastY = touch.clientY; |
| 2445 | + this._touchVelocity = 0; |
| 2446 | + this._touchTimestamp = performance.now(); |
| 2447 | + this._isTouching = true; |
| 2448 | + this._touchMomentum = false; |
| 2449 | + // Sync target to current actual position to stop any ongoing animation |
| 2450 | + this.targetScroll = this.el.scrollTop; |
| 2451 | + this.currentScroll = this.el.scrollTop; |
| 2452 | + } |
| 2453 | + |
| 2454 | + _onTouchMove(e) { |
| 2455 | + if (!this._isTouching || !this.isScrollable) return; |
| 2456 | + const touch = e.touches[0]; |
| 2457 | + const now = performance.now(); |
| 2458 | + const dt = now - this._touchTimestamp; |
| 2459 | + const dy = this._touchLastY - touch.clientY; |
| 2460 | + |
| 2461 | + // Only prevent default if we're actually scrolling vertically |
| 2462 | + if (Math.abs(dy) > 0) { |
| 2463 | + e.preventDefault(); |
| 2464 | + } |
| 2465 | + |
| 2466 | + // Calculate velocity (px/ms) |
| 2467 | + if (dt > 0) { |
| 2468 | + this._touchVelocity = (dy / dt) * this.touchMultiplier; |
| 2469 | + } |
| 2470 | + |
| 2471 | + this._touchLastY = touch.clientY; |
| 2472 | + this._touchTimestamp = now; |
| 2473 | + |
| 2474 | + // Apply touch movement directly (1:1 tracking while finger is down) |
| 2475 | + this.targetScroll = this._clamp(this.targetScroll + dy); |
| 2476 | + this.currentScroll = this.targetScroll; |
| 2477 | + this.el.scrollTop = this.currentScroll; |
| 2478 | + updateScrollIndicator(); |
| 2479 | + } |
| 2480 | + |
| 2481 | + _onTouchEnd() { |
| 2482 | + if (!this._isTouching) return; |
| 2483 | + this._isTouching = false; |
| 2484 | + |
| 2485 | + // Cap the velocity for damped momentum |
| 2486 | + this._touchVelocity = Math.max( |
| 2487 | + -this.maxTouchVelocity, |
| 2488 | + Math.min(this.maxTouchVelocity, this._touchVelocity) |
| 2489 | + ); |
| 2490 | + |
| 2491 | + // If there's meaningful velocity, start momentum |
| 2492 | + if (Math.abs(this._touchVelocity) > 0.05) { |
| 2493 | + this._touchMomentum = true; |
| 2494 | + this._startLoop(); |
| 2495 | + } |
| 2496 | + } |
| 2497 | + |
| 2498 | + _onKeyDown(e) { |
| 2499 | + if (!this.isScrollable) return; |
| 2500 | + |
| 2501 | + let delta = 0; |
| 2502 | + const pageStep = this.el.clientHeight * 0.85; |
| 2503 | + |
| 2504 | + switch (e.key) { |
| 2505 | + case 'ArrowDown': delta = 60; break; |
| 2506 | + case 'ArrowUp': delta = -60; break; |
| 2507 | + case 'PageDown': delta = pageStep; break; |
| 2508 | + case 'PageUp': delta = -pageStep; break; |
| 2509 | + case ' ': |
| 2510 | + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; |
| 2511 | + delta = e.shiftKey ? -pageStep : pageStep; |
| 2512 | + break; |
| 2513 | + case 'Home': |
| 2514 | + this.targetScroll = 0; |
| 2515 | + this._touchMomentum = false; |
| 2516 | + this._startLoop(); |
| 2517 | + e.preventDefault(); |
| 2518 | + return; |
| 2519 | + case 'End': |
| 2520 | + this.targetScroll = this.maxScroll; |
| 2521 | + this._touchMomentum = false; |
| 2522 | + this._startLoop(); |
| 2523 | + e.preventDefault(); |
| 2524 | + return; |
| 2525 | + default: return; |
| 2526 | + } |
| 2527 | + |
| 2528 | + e.preventDefault(); |
| 2529 | + this.targetScroll = this._clamp(this.targetScroll + delta); |
| 2530 | + this._touchMomentum = false; |
| 2531 | + this._startLoop(); |
| 2532 | + } |
| 2533 | + |
| 2534 | + _startLoop() { |
| 2535 | + if (!this.running) { |
| 2536 | + this.running = true; |
| 2537 | + requestAnimationFrame(this._tick); |
| 2538 | + } |
| 2539 | + } |
| 2540 | + |
| 2541 | + _tick() { |
| 2542 | + if (!this.running) return; |
| 2543 | + |
| 2544 | + if (this._touchMomentum) { |
| 2545 | + // Damped momentum from touch fling |
| 2546 | + this._touchVelocity *= this.velocityDecay; |
| 2547 | + this.targetScroll = this._clamp(this.targetScroll + this._touchVelocity * 16); |
| 2548 | + |
| 2549 | + // Stop when velocity is negligible |
| 2550 | + if (Math.abs(this._touchVelocity) < 0.01) { |
| 2551 | + this._touchMomentum = false; |
| 2552 | + } |
| 2553 | + } |
| 2554 | + |
| 2555 | + // Lerp toward target |
| 2556 | + const diff = this.targetScroll - this.currentScroll; |
| 2557 | + |
| 2558 | + if (Math.abs(diff) < 0.5) { |
| 2559 | + // Close enough, snap and stop |
| 2560 | + this.currentScroll = this.targetScroll; |
| 2561 | + this.el.scrollTop = this.currentScroll; |
| 2562 | + this.running = false; |
| 2563 | + updateScrollIndicator(); |
| 2564 | + return; |
| 2565 | + } |
| 2566 | + |
| 2567 | + this.currentScroll += diff * this.lerpFactor; |
| 2568 | + this.el.scrollTop = this.currentScroll; |
| 2569 | + updateScrollIndicator(); |
| 2570 | + |
| 2571 | + requestAnimationFrame(this._tick); |
| 2572 | + } |
| 2573 | + } |
| 2574 | + |
| 2575 | + // Initialize smooth scrollers for all sections |
| 2576 | + function initSmoothScrollers() { |
| 2577 | + sections.forEach(section => { |
| 2578 | + if (!smoothScrollInstances.has(section)) { |
| 2579 | + const scroller = new SmoothScroller(section); |
| 2580 | + smoothScrollInstances.set(section, scroller); |
| 2581 | + } |
| 2582 | + }); |
| 2583 | + } |
| 2584 | + |
| 2585 | + initSmoothScrollers(); |
| 2586 | + |
| 2587 | + // Global keyboard scroll handler (sections don't receive focus natively) |
| 2588 | + document.addEventListener('keydown', function(e) { |
| 2589 | + const activeSection = document.querySelector('section.active'); |
| 2590 | + if (!activeSection) return; |
| 2591 | + const scroller = smoothScrollInstances.get(activeSection); |
| 2592 | + if (scroller) scroller._onKeyDown(e); |
| 2593 | + }); |
| 2594 | + |
| 2595 | + |
| 2596 | + /* ============================================ |
| 2597 | + 7b. SCROLL INDICATOR |
2342 | 2598 | ============================================ */ |
2343 | 2599 | const scrollIndicatorDown = document.getElementById('scroll-indicator-down'); |
2344 | 2600 | const scrollIndicatorUp = document.getElementById('scroll-indicator-up'); |
|
2348 | 2604 | function updateScrollIndicator() { |
2349 | 2605 | const activeSection = document.querySelector('section.active'); |
2350 | 2606 | if (!activeSection) return; |
| 2607 | + const scroller = smoothScrollInstances.get(activeSection); |
2351 | 2608 | const isScrollable = activeSection.scrollHeight > activeSection.clientHeight; |
2352 | | - const isAtBottom = activeSection.scrollTop + activeSection.clientHeight >= activeSection.scrollHeight - 20; |
2353 | | - const isAtTop = activeSection.scrollTop <= 20; |
2354 | | - const currentScrollTop = activeSection.scrollTop; |
| 2609 | + const currentScrollTop = scroller ? scroller.currentScroll : activeSection.scrollTop; |
| 2610 | + const isAtBottom = currentScrollTop + activeSection.clientHeight >= activeSection.scrollHeight - 20; |
| 2611 | + const isAtTop = currentScrollTop <= 20; |
2355 | 2612 |
|
2356 | 2613 | if (currentScrollTop < lastScrollTop) { |
2357 | 2614 | scrollDirection = 'up'; |
|
2373 | 2630 | if (scrollIndicatorDown) { |
2374 | 2631 | scrollIndicatorDown.addEventListener('click', () => { |
2375 | 2632 | const activeSection = document.querySelector('section.active'); |
2376 | | - if (activeSection) { |
| 2633 | + if (!activeSection) return; |
| 2634 | + const scroller = smoothScrollInstances.get(activeSection); |
| 2635 | + if (scroller) { |
| 2636 | + scroller.scrollTo(scroller.maxScroll); |
| 2637 | + } else { |
2377 | 2638 | activeSection.scrollTo({ top: activeSection.scrollHeight, behavior: 'smooth' }); |
2378 | 2639 | } |
2379 | 2640 | }); |
|
2382 | 2643 | if (scrollIndicatorUp) { |
2383 | 2644 | scrollIndicatorUp.addEventListener('click', () => { |
2384 | 2645 | const activeSection = document.querySelector('section.active'); |
2385 | | - if (activeSection) { |
| 2646 | + if (!activeSection) return; |
| 2647 | + const scroller = smoothScrollInstances.get(activeSection); |
| 2648 | + if (scroller) { |
| 2649 | + scroller.scrollTo(0); |
| 2650 | + } else { |
2386 | 2651 | activeSection.scrollTo({ top: 0, behavior: 'smooth' }); |
2387 | 2652 | } |
2388 | 2653 | }); |
2389 | 2654 | } |
2390 | 2655 |
|
2391 | | - sections.forEach(section => { |
2392 | | - section.addEventListener('scroll', updateScrollIndicator); |
2393 | | - }); |
2394 | | - |
2395 | 2656 |
|
2396 | 2657 |
|
2397 | 2658 | /* ============================================ |
|
0 commit comments