Files
pbakaus_impeccable/public/js/effects/split-compare.js
T
Paul BakausandClaude Opus 4.6 a3633c66f0 Fix split-compare clip-path angle to match divider's skewX(-10deg)
The divider uses a fixed CSS angle (skewX(-10deg)) but the clip-path
mask used a fixed percentage offset (skewOffset=8), which only matches
at one specific aspect ratio. Now computes skewOffset dynamically from
the container's actual dimensions via ResizeObserver.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 08:04:24 -07:00

230 lines
7.3 KiB
JavaScript

// ============================================
// SPLIT COMPARE - Reusable before/after split-screen effect
// ============================================
/**
* Initialize split comparison effect on a container
* @param {HTMLElement} container - The container element with .split-container inside
* @param {Object} options - Configuration options
*/
export function initSplitCompare(container, options = {}) {
const {
defaultPosition = 70,
skewAngle = 10, // Degrees — matches CSS skewX(-10deg) on .split-divider
lerpSpeed = 0.15,
animationThreshold = 40, // Re-trigger animations when crossing this threshold
onCrossThreshold = null // Callback when crossing threshold toward "after" side
} = options;
const splitContainer = container.querySelector('.split-container');
const splitAfter = container.querySelector('.split-after');
const splitDivider = container.querySelector('.split-divider');
if (!splitContainer || !splitAfter || !splitDivider) return null;
// Compute skewOffset from container dimensions so clip-path angle matches CSS skewX
const tanAngle = Math.tan(skewAngle * Math.PI / 180);
let skewOffset = 8; // fallback
function recalcSkewOffset() {
const rect = splitContainer.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
skewOffset = 50 * rect.height * tanAngle / rect.width;
}
}
recalcSkewOffset();
const resizeObserver = new ResizeObserver(recalcSkewOffset);
resizeObserver.observe(splitContainer);
let minPosition = -skewOffset;
let maxPosition = 100 + skewOffset;
if (options.minPosition != null) minPosition = options.minPosition;
if (options.maxPosition != null) maxPosition = options.maxPosition;
let isHovering = false;
let currentX = defaultPosition;
let targetX = defaultPosition;
let animationId = null;
let wasAboveThreshold = defaultPosition > animationThreshold;
function updateSplit(percent) {
const minPos = options.minPosition != null ? minPosition : -skewOffset;
const maxPos = options.maxPosition != null ? maxPosition : 100 + skewOffset;
const clampedX = Math.max(minPos, Math.min(maxPos, percent));
// Check if we crossed the threshold toward the "after" side (moving left)
const isAboveThreshold = clampedX > animationThreshold;
if (wasAboveThreshold && !isAboveThreshold) {
// Crossed threshold - re-trigger animations
retriggerAnimations();
if (onCrossThreshold) onCrossThreshold(clampedX);
}
wasAboveThreshold = isAboveThreshold;
// Angled clip-path matching divider's skewX — offset computed from actual dimensions
splitAfter.style.clipPath = `polygon(${clampedX + skewOffset}% 0%, 100% 0%, 100% 100%, ${clampedX - skewOffset}% 100%)`;
splitDivider.style.left = `${clampedX}%`;
}
function retriggerAnimations() {
// Re-trigger CSS animations in the "after" content.
// If there's a canvas (e.g. overdrive shader), we can't clone-and-replace
// because that destroys JS-driven animations. In that case, retrigger
// individual elements. Otherwise, use the fast clone approach.
const afterContent = splitAfter.querySelector('.split-content');
if (!afterContent) return;
const hasCanvas = afterContent.querySelector('canvas, .od-burn, .od-sparks');
if (hasCanvas) {
// Safe path: retrigger CSS animations individually, skip canvas
afterContent.querySelectorAll('*').forEach(el => {
if (el.tagName === 'CANVAS') return;
const anim = getComputedStyle(el).animationName;
if (anim && anim !== 'none') {
el.style.animation = 'none';
el.offsetHeight;
el.style.animation = '';
}
});
} else {
// Fast path: clone and replace to restart all CSS animations
const clone = afterContent.cloneNode(true);
afterContent.parentNode.replaceChild(clone, afterContent);
}
}
function animate() {
const diff = targetX - currentX;
if (Math.abs(diff) > 0.1) {
currentX += diff * lerpSpeed;
updateSplit(currentX);
animationId = requestAnimationFrame(animate);
} else {
currentX = targetX;
updateSplit(currentX);
animationId = null;
}
}
function startAnimation() {
if (!animationId) {
animationId = requestAnimationFrame(animate);
}
}
function handleMouseEnter() {
isHovering = true;
}
function handleMouseLeave() {
isHovering = false;
targetX = defaultPosition;
startAnimation();
}
function handleMouseMove(e) {
if (isHovering) {
const rect = splitContainer.getBoundingClientRect();
const range = 100 + 2 * skewOffset;
targetX = ((e.clientX - rect.left) / rect.width) * range - skewOffset;
startAnimation();
}
}
let touchStartX = 0;
let touchStartY = 0;
let isDragging = false;
const DRAG_THRESHOLD = 10; // Minimum horizontal movement to start dragging
function handleTouchStart(e) {
const touch = e.touches[0];
touchStartX = touch.clientX;
touchStartY = touch.clientY;
isDragging = false;
isHovering = true;
}
function handleTouchEnd() {
isHovering = false;
isDragging = false;
targetX = defaultPosition;
startAnimation();
}
function handleTouchMove(e) {
const touch = e.touches[0];
const deltaX = Math.abs(touch.clientX - touchStartX);
const deltaY = Math.abs(touch.clientY - touchStartY);
// Only start dragging if horizontal movement is greater than vertical
// This allows vertical scrolling to pass through
if (!isDragging) {
if (deltaX > DRAG_THRESHOLD && deltaX > deltaY) {
isDragging = true;
} else if (deltaY > DRAG_THRESHOLD) {
// User is scrolling vertically, don't interfere
return;
} else {
// Not enough movement yet
return;
}
}
// Only prevent default when actively dragging horizontally
if (isDragging) {
e.preventDefault();
const rect = splitContainer.getBoundingClientRect();
targetX = ((touch.clientX - rect.left) / rect.width) * 100;
startAnimation();
}
}
// Use the parent container for mouse events to create a larger hit area
const hitArea = splitContainer.parentElement || splitContainer;
// Attach listeners — mouse events on the wider hit area
hitArea.addEventListener('mouseenter', handleMouseEnter);
hitArea.addEventListener('mouseleave', handleMouseLeave);
hitArea.addEventListener('mousemove', handleMouseMove);
splitContainer.addEventListener('touchstart', handleTouchStart);
splitContainer.addEventListener('touchend', handleTouchEnd);
splitContainer.addEventListener('touchmove', handleTouchMove, { passive: false });
// Initialize
updateSplit(defaultPosition);
// Return cleanup function
return {
destroy() {
hitArea.removeEventListener('mouseenter', handleMouseEnter);
hitArea.removeEventListener('mouseleave', handleMouseLeave);
hitArea.removeEventListener('mousemove', handleMouseMove);
splitContainer.removeEventListener('touchstart', handleTouchStart);
splitContainer.removeEventListener('touchend', handleTouchEnd);
splitContainer.removeEventListener('touchmove', handleTouchMove);
if (animationId) cancelAnimationFrame(animationId);
resizeObserver.disconnect();
},
setPosition(percent) {
targetX = percent;
startAnimation();
}
};
}
/**
* Initialize all split comparisons on the page
*/
export function initAllSplitCompare(selector = '.split-comparison', options = {}) {
const containers = document.querySelectorAll(selector);
const instances = [];
containers.forEach(container => {
const instance = initSplitCompare(container, options);
if (instance) instances.push(instance);
});
return instances;
}