| 1 |
"use strict"; |
| 2 |
/** |
| 3 |
* Reveal Swipe Cards Widget JavaScript. |
| 4 |
* |
| 5 |
* Handles reveal animations triggered by hover or scroll using IntersectionObserver. |
| 6 |
* Pro features (touch swipe, sequence mode) are handled in this file when Pro is enabled. |
| 7 |
* |
| 8 |
* @package King_Addons |
| 9 |
*/ |
| 10 |
(($) => { |
| 11 |
/** |
| 12 |
* Clamp a value between min and max. |
| 13 |
* |
| 14 |
* @param {number} value - Value to clamp. |
| 15 |
* @param {number} min - Minimum value. |
| 16 |
* @param {number} max - Maximum value. |
| 17 |
* @returns {number} Clamped value. |
| 18 |
*/ |
| 19 |
const clamp = (value, min, max) => Math.min(Math.max(value, min), max); |
| 20 |
|
| 21 |
/** |
| 22 |
* Parse JSON settings from data attribute. |
| 23 |
* |
| 24 |
* @param {HTMLElement} root - Root element. |
| 25 |
* @returns {Object} Parsed settings. |
| 26 |
*/ |
| 27 |
const parseSettings = (root) => { |
| 28 |
if (!root || !root.dataset) { |
| 29 |
return {}; |
| 30 |
} |
| 31 |
|
| 32 |
try { |
| 33 |
return JSON.parse(root.dataset.settings || "{}"); |
| 34 |
} catch (e) { |
| 35 |
return {}; |
| 36 |
} |
| 37 |
}; |
| 38 |
|
| 39 |
/** |
| 40 |
* Check if user prefers reduced motion. |
| 41 |
* |
| 42 |
* @returns {boolean} True if reduced motion preferred. |
| 43 |
*/ |
| 44 |
const prefersReducedMotion = () => |
| 45 |
window.matchMedia && |
| 46 |
window.matchMedia("(prefers-reduced-motion: reduce)").matches; |
| 47 |
|
| 48 |
/** |
| 49 |
* Check if device supports touch. |
| 50 |
* |
| 51 |
* @returns {boolean} True if touch device. |
| 52 |
*/ |
| 53 |
const isTouchDevice = () => |
| 54 |
"ontouchstart" in window || navigator.maxTouchPoints > 0; |
| 55 |
|
| 56 |
/** |
| 57 |
* Initialize widget instance. |
| 58 |
* |
| 59 |
* @param {jQuery} $scope - Widget scope. |
| 60 |
*/ |
| 61 |
const initWidget = ($scope) => { |
| 62 |
const root = $scope.find(".kng-rsc").first()[0]; |
| 63 |
if (!root || root.dataset.kngRscInit === "yes") { |
| 64 |
return; |
| 65 |
} |
| 66 |
|
| 67 |
root.dataset.kngRscInit = "yes"; |
| 68 |
const options = parseSettings(root); |
| 69 |
|
| 70 |
const cards = Array.from(root.querySelectorAll(".kng-rsc-card")); |
| 71 |
if (!cards.length) { |
| 72 |
return; |
| 73 |
} |
| 74 |
|
| 75 |
const isEditor = |
| 76 |
window.elementorFrontend && |
| 77 |
elementorFrontend.isEditMode && |
| 78 |
elementorFrontend.isEditMode(); |
| 79 |
|
| 80 |
const reducedMotion = prefersReducedMotion(); |
| 81 |
const touchEnabled = isTouchDevice(); |
| 82 |
|
| 83 |
const trigger = options.trigger || "hover"; |
| 84 |
const direction = options.direction || "left"; |
| 85 |
const duration = options.duration || 500; |
| 86 |
const resetOnLeave = options.resetOnLeave !== false; |
| 87 |
const scrollThreshold = options.scrollThreshold || 0.3; |
| 88 |
const scrollOnce = options.scrollOnce !== false; |
| 89 |
const scrollReset = options.scrollReset === true; |
| 90 |
|
| 91 |
// Pro settings |
| 92 |
const isPro = root.classList.contains("kng-rsc--pro"); |
| 93 |
const maskShape = options.maskShape || "rect"; |
| 94 |
const blurEdge = options.blurEdge || 0; |
| 95 |
const touchSwipe = isPro && options.touchSwipe === true && touchEnabled; |
| 96 |
const sequenceMode = isPro ? options.sequenceMode || "off" : "off"; |
| 97 |
const staggerDelay = options.staggerDelay || 100; |
| 98 |
|
| 99 |
// State tracking |
| 100 |
const cardStates = new Map(); |
| 101 |
let activeCardIndex = -1; |
| 102 |
let sequenceRevealed = 0; |
| 103 |
|
| 104 |
/** |
| 105 |
* Set CSS custom properties for mask shape (Pro). |
| 106 |
* |
| 107 |
* @param {HTMLElement} card - Card element. |
| 108 |
*/ |
| 109 |
const setMaskProperties = (card) => { |
| 110 |
if (!isPro) return; |
| 111 |
|
| 112 |
card.style.setProperty("--kng-rsc-mask-shape", maskShape); |
| 113 |
|
| 114 |
if (blurEdge > 0) { |
| 115 |
card.style.setProperty("--kng-rsc-blur-edge", `${blurEdge}px`); |
| 116 |
card.classList.add("kng-rsc-card--blur-edge"); |
| 117 |
} |
| 118 |
}; |
| 119 |
|
| 120 |
/** |
| 121 |
* Reveal a card. |
| 122 |
* |
| 123 |
* @param {HTMLElement} card - Card element. |
| 124 |
* @param {boolean} instant - Skip animation. |
| 125 |
*/ |
| 126 |
const revealCard = (card, instant = false) => { |
| 127 |
if (cardStates.get(card) === "revealed") return; |
| 128 |
|
| 129 |
cardStates.set(card, "revealed"); |
| 130 |
card.classList.add("is-revealed"); |
| 131 |
|
| 132 |
if (instant || reducedMotion) { |
| 133 |
card.classList.add("is-instant"); |
| 134 |
} else { |
| 135 |
card.classList.remove("is-instant"); |
| 136 |
} |
| 137 |
}; |
| 138 |
|
| 139 |
/** |
| 140 |
* Hide a card (reset reveal). |
| 141 |
* |
| 142 |
* @param {HTMLElement} card - Card element. |
| 143 |
* @param {boolean} instant - Skip animation. |
| 144 |
*/ |
| 145 |
const hideCard = (card, instant = false) => { |
| 146 |
if (cardStates.get(card) === "hidden") return; |
| 147 |
|
| 148 |
cardStates.set(card, "hidden"); |
| 149 |
card.classList.remove("is-revealed"); |
| 150 |
|
| 151 |
if (instant || reducedMotion) { |
| 152 |
card.classList.add("is-instant"); |
| 153 |
} else { |
| 154 |
card.classList.remove("is-instant"); |
| 155 |
} |
| 156 |
}; |
| 157 |
|
| 158 |
/** |
| 159 |
* Toggle card reveal state. |
| 160 |
* |
| 161 |
* @param {HTMLElement} card - Card element. |
| 162 |
*/ |
| 163 |
const toggleCard = (card) => { |
| 164 |
if (cardStates.get(card) === "revealed") { |
| 165 |
hideCard(card); |
| 166 |
} else { |
| 167 |
revealCard(card); |
| 168 |
} |
| 169 |
}; |
| 170 |
|
| 171 |
/** |
| 172 |
* Handle hover enter. |
| 173 |
* |
| 174 |
* @param {MouseEvent} event - Mouse event. |
| 175 |
*/ |
| 176 |
const handleMouseEnter = (event) => { |
| 177 |
const card = event.currentTarget; |
| 178 |
|
| 179 |
if (sequenceMode === "active-one") { |
| 180 |
// Close previous active card |
| 181 |
if (activeCardIndex >= 0 && cards[activeCardIndex] !== card) { |
| 182 |
hideCard(cards[activeCardIndex]); |
| 183 |
} |
| 184 |
activeCardIndex = cards.indexOf(card); |
| 185 |
} |
| 186 |
|
| 187 |
revealCard(card); |
| 188 |
}; |
| 189 |
|
| 190 |
/** |
| 191 |
* Handle hover leave. |
| 192 |
* |
| 193 |
* @param {MouseEvent} event - Mouse event. |
| 194 |
*/ |
| 195 |
const handleMouseLeave = (event) => { |
| 196 |
if (!resetOnLeave) return; |
| 197 |
if (sequenceMode === "active-one") return; |
| 198 |
|
| 199 |
const card = event.currentTarget; |
| 200 |
hideCard(card); |
| 201 |
}; |
| 202 |
|
| 203 |
/** |
| 204 |
* Handle focus for keyboard accessibility. |
| 205 |
* |
| 206 |
* @param {FocusEvent} event - Focus event. |
| 207 |
*/ |
| 208 |
const handleFocus = (event) => { |
| 209 |
const card = event.currentTarget; |
| 210 |
revealCard(card); |
| 211 |
}; |
| 212 |
|
| 213 |
/** |
| 214 |
* Handle blur for keyboard accessibility. |
| 215 |
* |
| 216 |
* @param {FocusEvent} event - Blur event. |
| 217 |
*/ |
| 218 |
const handleBlur = (event) => { |
| 219 |
if (!resetOnLeave) return; |
| 220 |
if (sequenceMode === "active-one") return; |
| 221 |
|
| 222 |
const card = event.currentTarget; |
| 223 |
hideCard(card); |
| 224 |
}; |
| 225 |
|
| 226 |
/** |
| 227 |
* Setup hover triggers. |
| 228 |
*/ |
| 229 |
const setupHoverTriggers = () => { |
| 230 |
cards.forEach((card) => { |
| 231 |
card.addEventListener("mouseenter", handleMouseEnter); |
| 232 |
card.addEventListener("mouseleave", handleMouseLeave); |
| 233 |
card.addEventListener("focusin", handleFocus); |
| 234 |
card.addEventListener("focusout", handleBlur); |
| 235 |
}); |
| 236 |
}; |
| 237 |
|
| 238 |
/** |
| 239 |
* Setup scroll triggers using IntersectionObserver. |
| 240 |
*/ |
| 241 |
const setupScrollTriggers = () => { |
| 242 |
if (!("IntersectionObserver" in window)) { |
| 243 |
// Fallback: reveal all cards immediately |
| 244 |
cards.forEach((card) => revealCard(card, true)); |
| 245 |
return; |
| 246 |
} |
| 247 |
|
| 248 |
const observerOptions = { |
| 249 |
root: null, |
| 250 |
rootMargin: "0px", |
| 251 |
threshold: scrollThreshold, |
| 252 |
}; |
| 253 |
|
| 254 |
const handleIntersect = (entries, observer) => { |
| 255 |
entries.forEach((entry) => { |
| 256 |
const card = entry.target; |
| 257 |
const cardIndex = cards.indexOf(card); |
| 258 |
|
| 259 |
if (entry.isIntersecting) { |
| 260 |
// Sequence mode: stagger reveal |
| 261 |
if (sequenceMode === "stagger" && !isEditor) { |
| 262 |
const delay = cardIndex * staggerDelay; |
| 263 |
setTimeout(() => { |
| 264 |
revealCard(card); |
| 265 |
sequenceRevealed++; |
| 266 |
}, delay); |
| 267 |
} else { |
| 268 |
revealCard(card); |
| 269 |
} |
| 270 |
|
| 271 |
if (scrollOnce) { |
| 272 |
observer.unobserve(card); |
| 273 |
} |
| 274 |
} else if (!scrollOnce && scrollReset) { |
| 275 |
hideCard(card); |
| 276 |
} |
| 277 |
}); |
| 278 |
}; |
| 279 |
|
| 280 |
const observer = new IntersectionObserver(handleIntersect, observerOptions); |
| 281 |
cards.forEach((card) => observer.observe(card)); |
| 282 |
}; |
| 283 |
|
| 284 |
/** |
| 285 |
* Setup touch swipe (Pro). |
| 286 |
*/ |
| 287 |
const setupTouchSwipe = () => { |
| 288 |
if (!touchSwipe) return; |
| 289 |
|
| 290 |
cards.forEach((card) => { |
| 291 |
let startX = 0; |
| 292 |
let startY = 0; |
| 293 |
let currentX = 0; |
| 294 |
let currentY = 0; |
| 295 |
let isDragging = false; |
| 296 |
const threshold = 0.4; // 40% swipe to trigger |
| 297 |
const velocityThreshold = 0.5; |
| 298 |
let startTime = 0; |
| 299 |
|
| 300 |
const isHorizontal = direction === "left" || direction === "right"; |
| 301 |
const overlay = card.querySelector(".kng-rsc-card__overlay"); |
| 302 |
|
| 303 |
const handleTouchStart = (e) => { |
| 304 |
const touch = e.touches[0]; |
| 305 |
startX = touch.clientX; |
| 306 |
startY = touch.clientY; |
| 307 |
startTime = Date.now(); |
| 308 |
isDragging = true; |
| 309 |
card.classList.add("is-dragging"); |
| 310 |
}; |
| 311 |
|
| 312 |
const handleTouchMove = (e) => { |
| 313 |
if (!isDragging) return; |
| 314 |
|
| 315 |
const touch = e.touches[0]; |
| 316 |
currentX = touch.clientX - startX; |
| 317 |
currentY = touch.clientY - startY; |
| 318 |
|
| 319 |
const delta = isHorizontal ? currentX : currentY; |
| 320 |
const size = isHorizontal ? card.offsetWidth : card.offsetHeight; |
| 321 |
const progress = clamp(Math.abs(delta) / size, 0, 1); |
| 322 |
|
| 323 |
// Determine if swipe direction matches reveal direction |
| 324 |
let isCorrectDirection = false; |
| 325 |
if (direction === "left" && currentX > 0) isCorrectDirection = true; |
| 326 |
if (direction === "right" && currentX < 0) isCorrectDirection = true; |
| 327 |
if (direction === "top" && currentY > 0) isCorrectDirection = true; |
| 328 |
if (direction === "bottom" && currentY < 0) isCorrectDirection = true; |
| 329 |
|
| 330 |
// Prevent page scroll if swiping in correct direction |
| 331 |
if (isCorrectDirection && Math.abs(delta) > 10) { |
| 332 |
e.preventDefault(); |
| 333 |
} |
| 334 |
|
| 335 |
// Update overlay position for visual feedback |
| 336 |
if (overlay && isCorrectDirection) { |
| 337 |
const transformValue = isHorizontal |
| 338 |
? `translateX(${progress * 100}%)` |
| 339 |
: `translateY(${progress * 100}%)`; |
| 340 |
overlay.style.transform = transformValue; |
| 341 |
} |
| 342 |
}; |
| 343 |
|
| 344 |
const handleTouchEnd = () => { |
| 345 |
if (!isDragging) return; |
| 346 |
|
| 347 |
isDragging = false; |
| 348 |
card.classList.remove("is-dragging"); |
| 349 |
|
| 350 |
const delta = isHorizontal ? currentX : currentY; |
| 351 |
const size = isHorizontal ? card.offsetWidth : card.offsetHeight; |
| 352 |
const progress = Math.abs(delta) / size; |
| 353 |
const elapsed = Date.now() - startTime; |
| 354 |
const velocity = Math.abs(delta) / elapsed; |
| 355 |
|
| 356 |
// Snap decision |
| 357 |
if (progress > threshold || velocity > velocityThreshold) { |
| 358 |
revealCard(card); |
| 359 |
} else { |
| 360 |
hideCard(card); |
| 361 |
} |
| 362 |
|
| 363 |
// Reset overlay transform |
| 364 |
if (overlay) { |
| 365 |
overlay.style.transform = ""; |
| 366 |
} |
| 367 |
|
| 368 |
startX = 0; |
| 369 |
startY = 0; |
| 370 |
currentX = 0; |
| 371 |
currentY = 0; |
| 372 |
}; |
| 373 |
|
| 374 |
card.addEventListener("touchstart", handleTouchStart, { passive: true }); |
| 375 |
card.addEventListener("touchmove", handleTouchMove, { passive: false }); |
| 376 |
card.addEventListener("touchend", handleTouchEnd, { passive: true }); |
| 377 |
card.addEventListener("touchcancel", handleTouchEnd, { passive: true }); |
| 378 |
}); |
| 379 |
}; |
| 380 |
|
| 381 |
// Initialize cards |
| 382 |
cards.forEach((card, index) => { |
| 383 |
cardStates.set(card, "hidden"); |
| 384 |
setMaskProperties(card); |
| 385 |
|
| 386 |
// Set direction-specific class |
| 387 |
card.classList.add(`kng-rsc-card--direction-${direction}`); |
| 388 |
|
| 389 |
// Pro: mask shape class |
| 390 |
if (isPro && maskShape !== "rect") { |
| 391 |
card.classList.add(`kng-rsc-card--mask-${maskShape}`); |
| 392 |
} |
| 393 |
}); |
| 394 |
|
| 395 |
// Setup triggers based on configuration |
| 396 |
if (trigger === "hover" || trigger === "both") { |
| 397 |
setupHoverTriggers(); |
| 398 |
} |
| 399 |
|
| 400 |
if ((trigger === "scroll" || trigger === "both") && !isEditor) { |
| 401 |
setupScrollTriggers(); |
| 402 |
} |
| 403 |
|
| 404 |
// Setup touch swipe (Pro) |
| 405 |
if (touchSwipe && !isEditor) { |
| 406 |
setupTouchSwipe(); |
| 407 |
} |
| 408 |
|
| 409 |
// Reduced motion: instant reveal or simplified |
| 410 |
if (reducedMotion) { |
| 411 |
root.classList.add("kng-rsc--reduced-motion"); |
| 412 |
} |
| 413 |
}; |
| 414 |
|
| 415 |
// Initialize on Elementor frontend ready |
| 416 |
$(window).on("elementor/frontend/init", () => { |
| 417 |
elementorFrontend.hooks.addAction( |
| 418 |
"frontend/element_ready/king-addons-reveal-swipe-cards.default", |
| 419 |
($scope) => { |
| 420 |
elementorFrontend.elementsHandler.addHandler( |
| 421 |
elementorModules.frontend.handlers.Base.extend({ |
| 422 |
onInit: function () { |
| 423 |
initWidget(this.$element); |
| 424 |
}, |
| 425 |
}), |
| 426 |
{ $element: $scope } |
| 427 |
); |
| 428 |
} |
| 429 |
); |
| 430 |
}); |
| 431 |
})(jQuery); |
| 432 |
|