UNPKG

scradar

Version:

CSS-first scroll interaction library with progress-based animations

1,450 lines (1,366 loc) 53.2 kB
import { useRef, useMemo, useCallback, useEffect } from 'react'; function parseOptions(str) { try { return JSON.parse(str.replace(/'/g, '"').replace(/([\w\d]+):/g, '"$1":').replace(/:"([^"]+)"/g, (match, p1) => { // Handle already quoted values if (p1.startsWith('"') && p1.endsWith('"')) { return ':' + p1; } return ':"' + p1 + '"'; })); } catch (e) { console.error('🎯 Scradar Parse Error:', { input: str, error: e.message, suggestion: 'Check your data-scradar attribute syntax. Use double quotes or proper JSON format.', example: 'data-scradar="{visibility: true, fill: [\'css\', \'data\']}"' }); return {}; } } function parseElementOptions(element) { let globalConfigs = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; const scradarValue = element.dataset.scradar; const configKey = element.dataset.scradarConfig; // 1. Configuration file check (supports both static and dynamic configs) if (configKey) { // Check global configuration if (window.scradarConfigs && window.scradarConfigs[configKey]) { const config = window.scradarConfigs[configKey]; // If config is a function, call it with the element if (typeof config === 'function') { return config(element); } return config; } // Check Scradar-specific configuration if (globalConfigs.configs && globalConfigs.configs[configKey]) { const config = globalConfigs.configs[configKey]; // If config is a function, call it with the element if (typeof config === 'function') { return config(element); } return config; } } // 2. Direct object check (for Vue reactive objects) if (scradarValue && typeof scradarValue === 'object') { // Handle Vue reactive objects return scradarValue; } // 3. Inline JSON check if (scradarValue) { try { return JSON.parse(scradarValue.replace(/'/g, '"').replace(/([\w\d]+):/g, '"$1":').replace(/:"([^"]+)"/g, (match, p1) => { if (p1.startsWith('"') && p1.endsWith('"')) { return ':' + p1; } return ':"' + p1 + '"'; })); } catch (e) { console.error('🎯 Scradar Parse Error:', { element: element, input: scradarValue, error: e.message, suggestion: 'Check your data-scradar attribute syntax.', example: 'data-scradar="{visibility: true, fill: true}"' }); } } return {}; } function updateDataAndCss(targets, settings, type, value) { if (!settings[type] && type !== 'peak') return; targets = Array.isArray(targets) ? targets : [targets]; const prefix = settings.prefix ? settings.prefix + '-' : ''; let types; if (type === 'peak') { types = ['css']; } else { types = settings[type] || ['css']; } if (!Array.isArray(types)) { console.error('🎯 Scradar Configuration Error:', { type: type, expected: 'Array', received: typeof types, value: types, suggestion: `Use ${type}: ['css'] or ${type}: ['css', 'data']`, element: targets[0] }); return; } types.forEach(outputType => { if (outputType === 'data') { const attrName = prefix + type.replace(/([A-Z])/g, '-$1').toLowerCase(); targets.forEach(el => { el.dataset[attrName] = value; }); } else if (outputType === 'css') { const propName = '--' + prefix + type.replace(/([A-Z])/g, '-$1').toLowerCase(); targets.forEach(el => { el.style.setProperty(propName, value); }); } }); } function capitalize(str) { return str.charAt(0).toUpperCase() + str.slice(1); } function eventSpeaker(target, eventName) { let detail = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; target = Array.isArray(target) ? target : [target]; target.forEach(el => { el.dispatchEvent(new CustomEvent(eventName, { detail })); }); } function throttleRaf(fn) { let ticking = false; return function () { for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { args[_key] = arguments[_key]; } if (!ticking) { window.requestAnimationFrame(() => { fn(...args); ticking = false; }); ticking = true; } }; } function getViewportSize(target, type) { if (target === window) { const dummy = document.createElement('div'); dummy.style[type] = type === 'width' ? '100vw' : '100vh'; dummy.style.position = 'fixed'; dummy.style.pointerEvents = 'none'; document.body.append(dummy); const size = type === 'width' ? dummy.offsetWidth : dummy.offsetHeight; dummy.remove(); return size || (type === 'width' ? window.innerWidth : window.innerHeight); } else { return type === 'width' ? target.offsetWidth : target.offsetHeight; } } class ScradarController { constructor(el, globalOptions, shadowDom) { let globalConfigs = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : {}; this.el = el; this.shadowDom = shadowDom; this.settings = { ...globalOptions, ...parseElementOptions(el, globalConfigs) }; this.init = false; this.triggerId = null; // Container reference this.container = null; this.containerSize = 0; // Progress values this.visibility = 0; this.fill = 0; this.cover = 0; this.enter = 0; this.exit = 0; this.peak = 0; // Offset values this.offsetEnter = 0; this.offsetExit = 0; // State tracking this.wasIn = false; this.wasFull = false; this.isFull = false; // Step tracking this.currentVisibilityStep = null; this.currentFillStep = null; this.currentCoverStep = null; this.currentEnterStep = null; this.currentExitStep = null; this.#init(); } #init() { // Parse progress options this.#parseProgressOptions(); // Parse steps this.#parseSteps(); // Parse peak this.#parsePeak(); // Create trigger if needed this.#createTrigger(); // Set container reference this.#setContainer(); // Initial update this.update(); this.init = true; } #setContainer() { this.container = this.settings.container ? document.querySelector(this.settings.container) : window; // Calculate container size using getViewportSize this.containerSize = this.settings.horizontal ? getViewportSize(this.container, 'width') : getViewportSize(this.container, 'height'); } #parseProgressOptions() { const progressTypes = ['visibility', 'fill', 'cover', 'enter', 'exit', 'offsetEnter', 'offsetExit']; progressTypes.forEach(type => { if (this.settings[type] !== undefined) { if (!this.settings[type]) { delete this.settings[type]; } else if (Array.isArray(this.settings[type])) { const validTypes = this.settings[type].filter(t => ['css', 'data'].includes(t)); this.settings[type] = validTypes.length ? validTypes : ['css']; } else if (this.settings[type] === true) { this.settings[type] = ['css']; } else if (typeof this.settings[type] === 'string') { this.settings[type] = [this.settings[type]]; } } }); } #parseSteps() { const stepTypes = ['visibility', 'fill', 'cover', 'enter', 'exit']; stepTypes.forEach(type => { const key = `${type}Step`; if (Array.isArray(this.settings[key])) { this.settings[key] = [...new Set([-9999, ...this.settings[key], 9999])].sort((a, b) => a - b); this[`current${capitalize(type)}Step`] = 0; } }); } #parsePeak() { if (Array.isArray(this.settings.peak) && this.settings.peak.length === 3) { const [start, peak, end] = this.settings.peak; this.settings.peak = { start, peak, end }; } } #createTrigger() { if (!this.settings.trigger) return; const triggerArea = document.createElement('div'); const triggerSettings = this.settings.trigger.trim().split(' '); Object.assign(triggerArea.style, { position: 'fixed', zIndex: -1, pointerEvents: 'none', opacity: 0, top: triggerSettings[0] || '0', right: triggerSettings[1] || triggerSettings[0] || '0', bottom: triggerSettings[2] || triggerSettings[0] || '0', left: triggerSettings[3] || triggerSettings[1] || triggerSettings[0] || '0' }); triggerArea.id = 'trigger_' + (Math.random() + 1).toString(36).substring(2); triggerArea.setAttribute('tabindex', -1); this.triggerId = triggerArea.id; this.shadowDom.append(triggerArea); } update() { let resize = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false; if (this.settings.unmount && this.init) return; if (!this.settings.mount) { this.settings.unmount = true; } // Handle breakpoints on resize if (this.settings.breakpoint && resize) { this.#applyBreakpoint(window.innerWidth); } // Update container size (resize or container changed) if (resize || !this.containerSize) { this.#updateContainerSize(); } // Get dimensions const rect = this.el.getBoundingClientRect(); const horizontal = this.settings.horizontal; // Calculate sizes using proper container size const containerSize = this.containerSize; const elSize = horizontal ? this.el.offsetWidth : this.el.offsetHeight; const elStart = horizontal ? rect.left : rect.top; const elEnd = elStart + elSize; // Handle delay elements const delayOffset = this.#calculateDelayOffset(elStart, containerSize); const contentSize = elSize - delayOffset; const sizeGap = contentSize - containerSize; // Calculate progress values this.#calculateProgress(elStart, elEnd, elSize, containerSize, sizeGap, delayOffset); // Update data and CSS this.#updateOutputs(); // Check steps this.#checkSteps(); // Handle events this.#handleEvents(elStart, elEnd, containerSize); // Handle trigger collision if (this.triggerId) { this.#handleTriggerCollision(elStart, elEnd); } } #updateContainerSize() { // Container may have changed, so check again this.container = this.settings.container ? document.querySelector(this.settings.container) : window; // Calculate exact size using getViewportSize this.containerSize = this.settings.horizontal ? getViewportSize(this.container, 'width') : getViewportSize(this.container, 'height'); } #applyBreakpoint(windowWidth) { const originalSettings = this.el.dataset.scradar ? parseOptions(this.el.dataset.scradar) : {}; Object.keys(this.settings.breakpoint).map(Number).sort((a, b) => a - b).forEach(bp => { if (windowWidth >= bp) { Object.assign(this.settings, this.settings.breakpoint[bp]); } else { Object.keys(this.settings.breakpoint[bp]).forEach(key => { if (originalSettings[key] !== undefined) { this.settings[key] = originalSettings[key]; } else { delete this.settings[key]; } }); } }); // Re-parse after breakpoint changes this.#parseProgressOptions(); this.#parseSteps(); // Update container after breakpoint changes (container settings may have changed) this.#updateContainerSize(); } #calculateDelayOffset(elStart, containerSize) { if (!this.settings.delay) return 0; const delayElements = this.el.querySelectorAll(`${this.settings.delay}:not(.disabled)`); let offset = 0; delayElements.forEach(delayEl => { const delayRect = delayEl.getBoundingClientRect(); const delaySize = this.settings.horizontal ? delayRect.width : delayRect.height; this.settings.horizontal ? delayRect.left : delayRect.top; const delayPos = this.settings.horizontal ? delayEl.offsetLeft : delayEl.offsetTop; const viewedSize = Math.max(delaySize + delayPos + elStart, 0); if (viewedSize >= 0 && viewedSize <= delaySize) { offset += delaySize - Math.max(Math.min(viewedSize, delaySize), 0); } else if (delayEl.classList.contains('scradar__delay--end') && viewedSize >= containerSize && viewedSize <= containerSize + delaySize) { offset += delaySize - Math.min(viewedSize - containerSize, delaySize); } }); return Math.max(0, offset); } #calculateProgress(elStart, elEnd, elSize, containerSize, sizeGap, delayOffset) { // Optimized visibility calculation: 0 (before) ~ 1 (after) const visibilityDenominator = -containerSize - elSize; this.visibility = Math.max(0, Math.min(1, elEnd / visibilityDenominator + 1)); // Optimized fill calculation: -1 (before) ~ 0 (filling) ~ 1 (after) if (this.settings.fill) { if (elSize > containerSize) { if (elStart <= 0 && elStart >= -sizeGap) { this.fill = 0; } else if (elStart < -sizeGap) { this.fill = 1 - (elStart + elSize) / containerSize; } else { this.fill = -elStart / containerSize; } this.fill = Math.max(-1, Math.min(1, this.fill)); } else { // Element is smaller than container - optimized calculation this.fill = Math.max(-1, Math.min(1, -elStart / elSize)); } } // cover: 0 (before) ~ 1 (after) if (this.settings.cover && elSize > containerSize) { const cover = (elStart + delayOffset) / -sizeGap; this.cover = Math.max(0, Math.min(1, cover)); } else { this.cover = 0; } // Optimized enter & exit calculations if (this.settings.enter || this.settings.exit) { const contentSize = elSize - delayOffset; if (this.settings.enter) { this.enter = elEnd / -containerSize * (containerSize / contentSize) + 1; } if (this.settings.exit) { this.exit = (elStart + sizeGap) / (containerSize + sizeGap); } } // Optimized peak calculation - only when needed if (this.settings.peak) { let peakConfig; // Handle both array and object formats if (Array.isArray(this.settings.peak)) { // Array format: [start, peak, end] const [start, peak, end] = this.settings.peak; peakConfig = { start, peak, end }; } else if (this.settings.peak.peak !== undefined) { // Object format: { start, peak, end } peakConfig = this.settings.peak; } if (peakConfig) { const { start, peak, end } = peakConfig; if (this.visibility < start || this.visibility > end) { this.peak = 0; } else { const peakRange = this.visibility <= peak ? peak - start : end - peak; const peakDiff = this.visibility <= peak ? this.visibility - start : end - this.visibility; this.peak = Math.max(0, Math.min(1, peakDiff / peakRange)); } } } } #updateOutputs() { // Cache receiver elements to avoid repeated DOM queries if (!this._receiverCache && this.settings.receiver) { this._receiverCache = Array.from(document.querySelectorAll(this.settings.receiver)); } const targets = this.settings.receiver ? [this.el, ...this._receiverCache] : [this.el]; // Update progress values if (this.settings.visibility) { updateDataAndCss(targets, this.settings, 'visibility', this.visibility); this.#fireProgressEvent('visibility', this.visibility); } if (this.settings.fill) { updateDataAndCss(targets, this.settings, 'fill', this.fill); this.#fireProgressEvent('fill', this.fill); } if (this.settings.cover) { updateDataAndCss(targets, this.settings, 'cover', this.cover); this.#fireProgressEvent('cover', this.cover); } if (this.settings.enter) { updateDataAndCss(targets, this.settings, 'enter', this.enter); this.#fireProgressEvent('enter', this.enter); } if (this.settings.exit) { updateDataAndCss(targets, this.settings, 'exit', this.exit); this.#fireProgressEvent('exit', this.exit); } if (this.peak !== undefined && this.settings.peak) { updateDataAndCss(targets, this.settings, 'peak', this.peak); this.#fireProgressEvent('peak', this.peak); } // Update offset values if (this.settings.offsetEnter) { const offsetEnter = this.settings.horizontal ? this.el.getBoundingClientRect().left : this.el.getBoundingClientRect().top; updateDataAndCss(targets, this.settings, 'offsetEnter', offsetEnter); this.#fireProgressEvent('offsetEnter', offsetEnter); } if (this.settings.offsetExit) { const rect = this.el.getBoundingClientRect(); const offsetExit = this.settings.horizontal ? this.containerSize - rect.right : this.containerSize - rect.bottom; updateDataAndCss(targets, this.settings, 'offsetExit', offsetExit); this.#fireProgressEvent('offsetExit', offsetExit); } } #fireProgressEvent(type, value) { // Always fire progress events for consistency with documentation eventSpeaker(this.el, `${type}Update`, { value }); } #checkSteps() { ['visibility', 'fill', 'cover', 'enter', 'exit'].forEach(type => { const stepKey = `${type}Step`; if (!Array.isArray(this.settings[stepKey])) return; const progress = this[type]; const steps = this.settings[stepKey]; let currentStep = null; for (let i = 0; i < steps.length - 1; i++) { if (progress >= steps[i] && progress < steps[i + 1]) { currentStep = i; break; } } const currentStepKey = `current${capitalize(type)}Step`; if (currentStep !== null && this[currentStepKey] !== currentStep) { eventSpeaker(this.el, 'stepChange', { type, step: currentStep, prevStep: this[currentStepKey], maxStep: steps.length - 2, isInitial: !this.init }); this.el.dataset[`${type}Step`] = currentStep; this[currentStepKey] = currentStep; } }); } #handleEvents(elStart, elEnd, containerSize) { const isIn = elEnd > 0 && elStart < containerSize; // In/Out events if (isIn !== this.wasIn) { this.el.dataset.scradarIn = isIn ? 1 : 0; if (isIn) { eventSpeaker(this.el, 'scrollEnter', { from: elStart < 0 ? 'top' : 'bottom', isInitial: !this.init }); } else if (!this.settings.once || !this.settings.done) { eventSpeaker(this.el, 'scrollExit', { from: elEnd < containerSize ? 'top' : 'bottom', isInitial: !this.init }); } if (this.settings.once && isIn) { this.settings.done = true; } this.wasIn = isIn; } // Enter/Exit markers if (isIn) { this.el.dataset.scradarEnter = elStart <= 0 ? 1 : 0; this.el.dataset.scradarExit = elEnd >= containerSize ? 1 : 0; // Full In/Out events const isFull = elStart <= 0 && elEnd >= containerSize; if (isFull !== this.wasFull) { if (isFull) { eventSpeaker(this.el, 'fullIn', { from: elStart < 0 ? 'top' : 'bottom', isInitial: !this.init }); } else { eventSpeaker(this.el, 'fullOut', { from: +this.el.dataset.scradarEnter ? 'bottom' : 'top', isInitial: !this.init }); } this.wasFull = isFull; this.isFull = isFull; } } else { this.el.dataset.scradarEnter = 0; this.el.dataset.scradarExit = 0; } } #handleTriggerCollision(elStart, elEnd) { const trigger = this.shadowDom.getElementById(this.triggerId); if (!trigger) return; const triggerRect = trigger.getBoundingClientRect(); const triggerStart = this.settings.horizontal ? triggerRect.left : triggerRect.top; const triggerEnd = this.settings.horizontal ? triggerRect.right : triggerRect.bottom; const isColliding = elStart <= triggerEnd && elEnd >= triggerStart; const wasColliding = +this.el.dataset.collision === 1; const wasFired = +this.el.dataset.scradarFire === 1; if (isColliding && !wasColliding) { this.el.dataset.collision = 1; eventSpeaker(this.el, 'collisionEnter', { from: elStart <= triggerEnd ? 'top' : 'bottom', isInitial: !this.init }); if (!wasFired) { this.el.dataset.scradarFire = 1; eventSpeaker(this.el, 'fire', { from: elStart <= triggerEnd ? 'top' : 'bottom', isInitial: !this.init }); } } else if (!isColliding && wasColliding) { this.el.dataset.collision = 0; eventSpeaker(this.el, 'collisionExit', { from: elEnd >= triggerStart ? 'top' : 'bottom', isInitial: !this.init }); } } destroy() { // Remove all data attributes Object.keys(this.el.dataset).forEach(key => { if (key.startsWith('scradar') || key.includes('Step') || key.includes('progress') || key === 'collision') { delete this.el.dataset[key]; } }); // Remove CSS custom properties const styles = this.el.style; for (let i = styles.length - 1; i >= 0; i--) { const prop = styles[i]; if (prop.startsWith('--')) { styles.removeProperty(prop); } } // Remove trigger from shadow DOM if (this.triggerId && this.shadowDom) { const trigger = this.shadowDom.getElementById(this.triggerId); if (trigger) trigger.remove(); } } } class ScradarDebug { constructor(scradar) { this.scradar = scradar; this.overlay = null; this.isCollapsed = localStorage.getItem('scradar-debug-collapsed') === 'true'; this.isTargetsCollapsed = localStorage.getItem('scradar-targets-collapsed') === 'true'; this.collapsedTargets = JSON.parse(localStorage.getItem('scradar-targets-individual') || '[]'); this.isHidden = localStorage.getItem('scradar-debug-hidden') === 'true'; this.performanceMetrics = { updateCount: 0, lastUpdateTime: 0, avgUpdateTime: 0, maxUpdateTime: 0, elementsCount: 0 }; this.init(); } init() { if (document.getElementById('scradar-debug-overlay')) return; this.overlay = document.createElement('div'); this.overlay.id = 'scradar-debug-overlay'; this.overlay.innerHTML = ` <style> #scradar-debug-overlay { position: fixed; top: 10px; right: 10px; z-index: 999999; background: rgba(0, 0, 0, 0.85); color: #fff; padding: 15px; border-radius: 8px; font-family: 'Monaco', 'Menlo', monospace; font-size: 12px; min-width: 300px; max-width: 420px; max-height: 80vh; overflow-y: auto; pointer-events: all; user-select: text; box-shadow: 0 4px 20px rgba(0,0,0,0.3); transition: all 0.3s ease; } #scradar-debug-overlay.collapsed { min-width: 300px; max-width: 300px; max-height: 60px; overflow: hidden; } #scradar-debug-overlay::-webkit-scrollbar { width: 6px; } #scradar-debug-overlay::-webkit-scrollbar-track { background: rgba(255,255,255,0.1); } #scradar-debug-overlay::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.3); border-radius: 3px; } #scradar-debug-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid rgba(255,255,255,0.2); cursor: pointer; } #scradar-debug-title { font-weight: bold; font-size: 14px; color: #4fc3f7; display: flex; align-items: center; gap: 8px; } #scradar-debug-toggle { font-size: 12px; color: #81c784; transition: transform 0.3s ease; } #scradar-debug-overlay.collapsed #scradar-debug-toggle { transform: rotate(-90deg); } .scradar-debug-performance { background: rgba(76, 175, 80, 0.1); border: 1px solid rgba(76, 175, 80, 0.3); padding: 8px; border-radius: 4px; margin-bottom: 10px; } .scradar-debug-warning { background: rgba(255, 193, 7, 0.1); border: 1px solid rgba(255, 193, 7, 0.3); padding: 8px; border-radius: 4px; margin-bottom: 10px; color: #ffc107; } .scradar-debug-error { background: rgba(244, 67, 54, 0.1); border: 1px solid rgba(244, 67, 54, 0.3); padding: 8px; border-radius: 4px; margin-bottom: 10px; color: #f44336; } #scradar-debug-close { cursor: pointer; padding: 2px 8px; background: rgba(255,255,255,0.1); border-radius: 4px; transition: background 0.2s; } #scradar-debug-close:hover { background: rgba(255,255,255,0.2); } .scradar-debug-section { margin-bottom: 15px; } .scradar-debug-section-header { display: flex; justify-content: space-between; align-items: center; cursor: pointer; padding: 5px 0; border-bottom: 1px solid rgba(255,255,255,0.1); } .scradar-debug-section-header:hover { background: rgba(255,255,255,0.05); } .scradar-debug-section-content { transition: all 0.3s ease; overflow: hidden; } .scradar-debug-section-content.collapsed { max-height: 0; opacity: 0; } .scradar-debug-label { color: #81c784; font-weight: bold; margin-bottom: 5px; } .scradar-debug-value { color: #ffd54f; font-weight: bold; } .scradar-debug-target { background: rgba(255,255,255,0.05); padding: 8px; margin-bottom: 8px; border-radius: 4px; border-left: 3px solid #4fc3f7; } .scradar-debug-target-header { display: flex; justify-content: space-between; align-items: center; cursor: pointer; margin-bottom: 4px; } .scradar-debug-target-header:hover { background: rgba(255,255,255,0.05); border-radius: 2px; padding: 2px; margin: -2px; } .scradar-debug-target-title { color: #4fc3f7; font-weight: bold; } .scradar-debug-target-toggle { font-size: 10px; color: #81c784; transition: transform 0.3s ease; } .scradar-debug-target-content { transition: all 0.3s ease; overflow: hidden; } .scradar-debug-target-content.collapsed { max-height: 0; opacity: 0; } .scradar-debug-progress { display: grid; grid-template-columns: auto 1fr; gap: 4px 8px; font-size: 11px; } .scradar-debug-progress-bar { height: 3px; background: rgba(255,255,255,0.1); border-radius: 2px; overflow: hidden; margin-top: 2px; } .scradar-debug-progress-fill { height: 100%; background: #4fc3f7; transition: width 0.1s; } .scradar-debug-shortcuts { font-size: 10px; color: #aaa; margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255,255,255,0.1); } </style> <div id="scradar-debug-header"> <div id="scradar-debug-title"> 🎯 Scradar Debug <span id="scradar-debug-toggle">▼</span> </div> <div id="scradar-debug-close">✕</div> </div> <div id="scradar-debug-content"> <div class="scradar-debug-performance"> <div class="scradar-debug-label">⚡ Performance</div> <div class="scradar-debug-progress"> <span>Elements:</span> <span class="scradar-debug-value" id="debug-elements">-</span> <span>Updates:</span> <span class="scradar-debug-value" id="debug-updates">-</span> <span>Avg Update:</span> <span class="scradar-debug-value" id="debug-avg">-</span> <span>Max Update:</span> <span class="scradar-debug-value" id="debug-max">-</span> </div> </div> <div class="scradar-debug-section"> <div class="scradar-debug-section-header" id="global-header"> <div class="scradar-debug-label">🌍 Global</div> <span class="scradar-debug-toggle">▼</span> </div> <div class="scradar-debug-section-content" id="global-content"> <div class="scradar-debug-progress"> <span>Scroll Progress:</span> <span class="scradar-debug-value" id="debug-progress">-</span> <span>Direction:</span> <span class="scradar-debug-value" id="debug-direction">-</span> <span>Boundary Target:</span> <span class="scradar-debug-value" id="debug-target">-</span> </div> <div class="scradar-debug-progress-bar"> <div class="scradar-debug-progress-fill" id="debug-progress-bar"></div> </div> </div> </div> <div class="scradar-debug-section"> <div class="scradar-debug-section-header" id="targets-header"> <div class="scradar-debug-label">🎯 Targets</div> <span class="scradar-debug-toggle">▼</span> </div> <div class="scradar-debug-section-content" id="targets-content"> <div id="debug-targets-list"></div> </div> </div> <div class="scradar-debug-shortcuts"> <div>⌘+Shift+D: Toggle Debug</div> <div>⌘+Shift+C: Toggle Collapse</div> </div> </div> `; document.body.append(this.overlay); // Apply saved states if (this.isCollapsed) { this.overlay.classList.add('collapsed'); this.overlay.querySelector('#scradar-debug-toggle').textContent = '▶'; } if (this.isTargetsCollapsed) { this.overlay.querySelector('#targets-content').classList.add('collapsed'); this.overlay.querySelector('#targets-header .scradar-debug-toggle').textContent = '▶'; } // Apply hidden state if (this.isHidden) { this.overlay.style.display = 'none'; } // Event listeners this.overlay.querySelector('#scradar-debug-close').addEventListener('click', () => { this.overlay.style.display = 'none'; this.isHidden = true; localStorage.setItem('scradar-debug-hidden', 'true'); }); this.overlay.querySelector('#scradar-debug-header').addEventListener('click', () => { this.toggleCollapse(); }); this.overlay.querySelector('#global-header').addEventListener('click', () => { this.toggleSection('global'); }); this.overlay.querySelector('#targets-header').addEventListener('click', () => { this.toggleSection('targets'); }); // Keyboard shortcuts document.addEventListener('keydown', e => { if ((e.ctrlKey || e.metaKey) && e.shiftKey) { if (e.key === 'D') { this.isHidden = !this.isHidden; this.overlay.style.display = this.isHidden ? 'none' : 'block'; localStorage.setItem('scradar-debug-hidden', this.isHidden); } else if (e.key === 'C') { this.toggleCollapse(); } } }); this.update(); } toggleCollapse() { this.isCollapsed = !this.isCollapsed; this.overlay.classList.toggle('collapsed', this.isCollapsed); this.overlay.querySelector('#scradar-debug-toggle').textContent = this.isCollapsed ? '▶' : '▼'; // Save state localStorage.setItem('scradar-debug-collapsed', this.isCollapsed); } toggleSection(section) { const content = this.overlay.querySelector(`#${section}-content`); const toggle = this.overlay.querySelector(`#${section}-header .scradar-debug-toggle`); if (section === 'targets') { this.isTargetsCollapsed = !this.isTargetsCollapsed; content.classList.toggle('collapsed', this.isTargetsCollapsed); toggle.textContent = this.isTargetsCollapsed ? '▶' : '▼'; localStorage.setItem('scradar-targets-collapsed', this.isTargetsCollapsed); } else { content.classList.toggle('collapsed'); toggle.textContent = content.classList.contains('collapsed') ? '▶' : '▼'; } } toggleTarget(targetId) { const targetContent = this.overlay.querySelector(`#target-${targetId}-content`); const targetToggle = this.overlay.querySelector(`#target-${targetId}-toggle`); const isCollapsed = targetContent.classList.contains('collapsed'); targetContent.classList.toggle('collapsed', !isCollapsed); targetToggle.textContent = isCollapsed ? '▼' : '▶'; // Update saved state if (isCollapsed) { this.collapsedTargets = this.collapsedTargets.filter(id => id !== targetId); } else { this.collapsedTargets.push(targetId); } localStorage.setItem('scradar-targets-individual', JSON.stringify(this.collapsedTargets)); } update() { if (!this.overlay || this.overlay.style.display === 'none') return; // Performance tracking const startTime = performance.now(); const scrollProgress = +(document.documentElement.dataset.scradarProgress || 0); const scrollDirection = +(document.documentElement.dataset.scradarScroll || 0); const boundaryTarget = document.documentElement.dataset.scradarTarget || '-'; // Update performance metrics this.performanceMetrics.elementsCount = this.scradar.elements.length; const activeElements = this.scradar.elements.filter(el => +el.dataset.scradarIn).length; // Update performance section this.overlay.querySelector('#debug-elements').textContent = `${this.performanceMetrics.elementsCount} (${activeElements} active)`; this.overlay.querySelector('#debug-updates').textContent = this.performanceMetrics.updateCount; this.overlay.querySelector('#debug-avg').textContent = `${this.performanceMetrics.avgUpdateTime.toFixed(2)}ms`; this.overlay.querySelector('#debug-max').textContent = `${this.performanceMetrics.maxUpdateTime.toFixed(2)}ms`; // Update global section this.overlay.querySelector('#debug-progress').textContent = scrollProgress.toFixed(3); this.overlay.querySelector('#debug-direction').textContent = scrollDirection === 1 ? '↓ Down' : scrollDirection === -1 ? '↑ Up' : '• Stop'; this.overlay.querySelector('#debug-target').textContent = boundaryTarget; this.overlay.querySelector('#debug-progress-bar').style.width = `${scrollProgress * 100}%`; // Update targets section const targetsList = this.overlay.querySelector('#debug-targets-list'); let targetsHtml = ''; this.scradar.elements.forEach((el, idx) => { const ctrl = el.scradar; if (!ctrl) return; const targetId = `target-${idx}`; const title = el.dataset.scradarTitle || el.dataset.scradarConfig || el.className || el.tagName.toLowerCase(); const isIn = +el.dataset.scradarIn; const isTargetCollapsed = this.collapsedTargets.includes(targetId); targetsHtml += ` <div class="scradar-debug-target"> <div class="scradar-debug-target-header" onclick="window.scradarDebug.toggleTarget('${targetId}')"> <div class="scradar-debug-target-title"> #${idx + 1} ${title} ${isIn ? '🐵' : '🙈'} </div> <span class="scradar-debug-target-toggle" id="target-${targetId}-toggle">${isTargetCollapsed ? '▶' : '▼'}</span> </div> <div class="scradar-debug-target-content" id="target-${targetId}-content" ${isTargetCollapsed ? 'class="collapsed"' : ''}> <div class="scradar-debug-progress"> ${ctrl.visibility !== undefined ? ` <span>visibility:</span> <span class="scradar-debug-value">${ctrl.visibility.toFixed(3)}</span> ` : ''} ${ctrl.fill !== undefined ? ` <span>fill:</span> <span class="scradar-debug-value">${ctrl.fill.toFixed(3)}</span> ` : ''} ${ctrl.cover !== undefined ? ` <span>cover:</span> <span class="scradar-debug-value">${ctrl.cover.toFixed(3)}</span> ` : ''} ${ctrl.enter !== undefined ? ` <span>enter:</span> <span class="scradar-debug-value">${ctrl.enter.toFixed(3)}</span> ` : ''} ${ctrl.exit !== undefined ? ` <span>exit:</span> <span class="scradar-debug-value">${ctrl.exit.toFixed(3)}</span> ` : ''} ${ctrl.peak !== undefined && ctrl.peak !== 0 ? ` <span>peak:</span> <span class="scradar-debug-value">${ctrl.peak.toFixed(3)}</span> ` : ''} ${ctrl.currentVisibilityStep !== null ? ` <span>step:</span> <span class="scradar-debug-value">${ctrl.currentVisibilityStep}</span> ` : ''} </div> ${ctrl.visibility !== undefined ? ` <div class="scradar-debug-progress-bar"> <div class="scradar-debug-progress-fill" style="width: ${ctrl.visibility * 100}%"></div> </div> ` : ''} </div> </div> `; }); targetsList.innerHTML = targetsHtml; // Update performance metrics const endTime = performance.now(); const updateTime = endTime - startTime; this.performanceMetrics.updateCount++; this.performanceMetrics.lastUpdateTime = updateTime; // Calculate running average this.performanceMetrics.avgUpdateTime = (this.performanceMetrics.avgUpdateTime * (this.performanceMetrics.updateCount - 1) + updateTime) / this.performanceMetrics.updateCount; // Track maximum update time if (updateTime > this.performanceMetrics.maxUpdateTime) { this.performanceMetrics.maxUpdateTime = updateTime; } } destroy() { if (this.overlay) { this.overlay.remove(); this.overlay = null; } } } class Scradar { static version = '1.0.3'; static defaults = { target: '.scradar', root: null, trigger: null, prefix: '', visibility: false, fill: false, cover: false, enter: false, exit: false, offsetEnter: false, offsetExit: false, peak: null, once: false, totalProgress: true, boundary: false, momentum: false, horizontal: false, container: null, receiver: null, delay: null, breakpoint: null, debug: false }; // Global configurations static configs = {}; #elements = []; #options; #root; #scrollHandler = null; #resizeHandler = null; #wheelHandler = null; #keydownHandler = null; #observer = null; #shadowDom = null; #isDestroyed = false; #prevScroll = null; #scrollDir = 0; #momentum = { step: 0, deltaY: 0, firstValue: 0, timer: null, isMomentum: false }; #keydownScrollY = null; #scrollingWithKeydown = false; #debugger = null; #boundaryTarget = null; constructor(target) { let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; // Parse parameters if (typeof target === 'object' && target !== null && !target.nodeType) { options = target; target = null; } this.#options = { ...Scradar.defaults, ...options }; if (typeof target === 'string') { this.#options.target = target; } this.#root = this.#options.root ? document.querySelector(this.#options.root) : window; this.#init(); } #init() { if (this.#isDestroyed) return; // Find targets const selector = this.#options.target || '.scradar'; this.#elements = Array.from(document.querySelectorAll(selector)); // Shadow DOM for triggers const triggerWrapper = document.createElement('div'); triggerWrapper.id = 'scradarTriggerWrapper'; triggerWrapper.style.display = 'none'; document.body.append(triggerWrapper); this.#shadowDom = triggerWrapper.attachShadow({ mode: 'open' }); // Attach controller to each element this.#elements.forEach(el => { el.scradar = new ScradarController(el, this.#options, this.#shadowDom, { configs: Scradar.configs }); }); // Setup observer this.#observer = new IntersectionObserver(this.#onIntersect.bind(this), { root: this.#root === window ? null : this.#root, threshold: [0, 0.00001, 0.99999, 1] }); this.#elements.forEach(el => this.#observer.observe(el)); // Setup event handlers this.#scrollHandler = throttleRaf(this.#onScroll.bind(this)); this.#resizeHandler = throttleRaf(this.#onResize.bind(this)); this.#wheelHandler = throttleRaf(this.#onWheel.bind(this)); this.#keydownHandler = this.#onKeydown.bind(this); const scrollTarget = this.#root === window ? window : this.#root; scrollTarget.addEventListener('scroll', this.#scrollHandler, { passive: true }); window.addEventListener('resize', this.#resizeHandler); window.addEventListener('wheel', this.#wheelHandler, { passive: true }); document.addEventListener('keydown', this.#keydownHandler); // Debug overlay if (this.#options.debug) { this.#debugger = new ScradarDebug(this); // Expose debug instance globally for target toggle functionality window.scradarDebug = this.#debugger; } // Initial calculation this.update(); } #onIntersect(entries) { entries.forEach(entry => { const ctrl = entry.target.scradar; if (!ctrl) return; if (entry.intersectionRatio !== 0 && !ctrl.settings.done) { ctrl.settings.mount = true; ctrl.settings.unmount = false; } else { ctrl.settings.mount = false; } }); } #onScroll() { if (this.#isDestroyed) return; const currentScroll = this.#root === window ? window.scrollY : this.#root.scrollTop; const windowHeight = window.innerHeight; // Scroll direction detection if (this.#prevScroll !== null) { if (currentScroll > this.#prevScroll) { if (this.#scrollDir !== 1) { this.#scrollDir = 1; document.documentElement.dataset.scradarScroll = 1; eventSpeaker(window, 'scrollTurn', { scroll: 1 }); } } else if (currentScroll < this.#prevScroll) { if (this.#scrollDir !== -1) { this.#scrollDir = -1; document.documentElement.dataset.scradarScroll = -1; eventSpeaker(window, 'scrollTurn', { scroll: -1 }); } } else { this.#scrollDir = 0; document.documentElement.dataset.scradarScroll = 0; } } // Reset check for instant scroll to top if (Math.abs(this.#prevScroll - currentScroll) > 300 && currentScroll === 0) { this.#prevScroll = 0; this.update(); return; } this.#prevScroll = currentScroll; // Total progress if (this.#options.totalProgress) { const docHeight = document.documentElement.scrollHeight; const progress = currentScroll / (docHeight - windowHeight); document.documentElement.dataset.scradarProgress = progress; this.progress = progress; } // Update elements this.#elements.forEach(el => el.scradar && el.scradar.update()); // Boundary target detection if (this.#options.boundary) { this.#updateBoundaryTarget(); } // Debug update if (this.#debugger) { this.#debugger.update(); } } #onResize() { if (this.#isDestroyed) return; this.#elements.forEach(el => el.scradar && el.scradar.update(true)); if (this.#debugger) this.#debugger.update(); } #onWheel(e) { if (this.#isDestroyed) return; // Momentum detection this.#momentum.deltaY = e.deltaY; this.#momentum.step++; clearTimeout(this.#momentum.timer); if (this.#momentum.step > 10 && Math.abs(this.#momentum.deltaY) <= 10 || Math.abs(this.#momentum.deltaY) <= 2) { this.#momentum.step = 0; this.#momentum.firstValue = this.#momentum.deltaY; this.#momentum.isMomentum = false; } else if (this.#momentum.step === 1 && Math.abs(this.#momentum.deltaY) > Math.abs(this.#momentum.firstValue)) { this.#momentum.isMomentum = true; eventSpeaker(window, 'momentum', { status: this.#momentum.deltaY > 0 ? 1 : -1 }); } this.#momentum.timer = setTimeout(() => { this.#momentum.step = 0; this.#momentum.isMomentum = false; }, 80); } #onKeydown(e) { if (e.metaKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown') || e.key === 'Tab' || e.key === 'Home' || e.key === 'End') { this.#scrollingWithKeydown = true; this.#keydownScrollY = this.#root === window ? window.scrollY : this.#root.scrollTop; this.#scrollCheck(); } } #scrollCheck() { if (this.#keydownScrollY !== null) { const currentScroll = this.#root === window ? window.scrollY : this.#root.scrollTop; if (this.#scrollingWithKeydown || this.#keydownScrollY !== currentScroll) { this.#scrollingWithKeydown = false; this.#keydownScrollY = currentScroll; throttleRaf(this.#scrollCheck.bind(this))(); } else { this.update(); this.#keydownScrollY = null; } } } #updateBoundaryTarget() { const boundary = typeof this.#options.boundary === 'number' ? this.#options.boundary : 0.5; const windowHeight = window.innerHeight; const boundaryLine = windowHeight * boundary; const activeElements = this.#elements.filter(el => el.dataset.scradarTitle && +el.dataset.scradarIn === 1); if (!activeElements.length) { if (this.#boundaryTarget) { document.documentElement.dataset.scradarTarget = '# ' + this.#boundaryTarget; } } else { const target = activeElements.length === 1 ? activeElements[0] : activeElements.sort((a, b) => { const aRect = a.getBoundingClientRect(); const bRect = b.getBoundingClientRect(); const aDistance = Math.abs(aRect.top + aRect.height / 2 - boundaryLine); const bDistance = Math.abs(bRect.top + bRect.height / 2 - boundaryLine); return aDistance - bDistance; })[0]; this.#boundaryTarget = target.dataset.scradarTitle; document.documentElement.dataset.scradarTarget = this.#boundaryTarget; } } // Public methods get elements() { return this.#elements; } get scroll() { return this.#scrollDir; } update() { if (this.#isDestroyed) return; this.#elements.forEach(el => { if (el.scradar) { el.scradar.settings.unmount = false; el.scradar.update(); } }); if (this.#debugger) this.#debugger.update(); } destroy() { if (this.#isDestroyed) return; const scrollTarget = this.#root === window ? window : this.#root; scrollTarget.removeEventListener('scroll', this.#scrollHandler); window.removeEventListener('resize', this.#resizeHandler); window.removeEventListener('wheel', this.#wheelHandler); document.removeEventListener('keydown', this.#keydownHandler); if (this.#observer) { this.#observer.disconnect(); } if (this.#shadowDom && this.#shadowDom.host) { this.#shadowDom.host.remove(); } this.#elements.forEach(el => { if (el.scradar) { el.scradar.destroy(); delete el.scradar; } }); if (this.#debugger) { this.#debugger.destroy(); } this.#elements = []; this.#isDestroyed = true; } } // Global Scradar instance for performance optimization let globalScradar = null; let instanceCount = 0; // Cleanup function to destroy global instance when no components are using it const cleanupGlobalInstance = () => { if (globalScradar && instanceCount === 0) { globalScradar.destroy(); globalScradar = null; } }; function useScradar() { let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; const scradarRef = useRef(null); const optionsRef = useRef(options); const isInitializedRef = useRef(false); // Memoize options to prevent unnecessary re-initialization const memoizedOptions = useMemo(() => options, [options.target, options.debug, options.boundary, options.totalProgress, options.momentum]); // Initialize Scradar const initializeScradar = useCallback(() => { if (isInitializedRef.current) return; // Use global instance for better performance in SPA if (!globalScradar) { globalScradar = new Scradar(memoizedOptions); } else { // Update existing instance with new options globalScradar.update(); } instanceCount++; scradarRef.current = globalScradar; isInitializedRef.current = true; }, [memoizedOptions]); // Cleanup function const cleanup = useCallback(() => { if (isInitializedRef.current) { instanceCount--; isInitializedRef.current = false; cleanupGlobalInstance(); } }, []); // Initialize on mount useEffect(() => { initializeScradar(); return cleanup; }, [initializeScradar, cleanup]); // Update when options change useEffect(() => { if (scradarRef.current &