scradar
Version:
CSS-first scroll interaction library with progress-based animations
1,450 lines (1,366 loc) • 53.2 kB
JavaScript
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 &