Skip to content

Instantly share code, notes, and snippets.

@xlplugins
Created November 11, 2025 08:00
Show Gist options
  • Save xlplugins/f8a4e39affdf751f38e0ed06acea3ce7 to your computer and use it in GitHub Desktop.
Save xlplugins/f8a4e39affdf751f38e0ed06acea3ce7 to your computer and use it in GitHub Desktop.
Funnelkit Cart: FKCart Draggable Icon Positioning
/**
* FKCart Draggable Icon Positioning System
*
* Enables drag-and-drop positioning for the floating WooCommerce cart icon
* with intelligent auto-snap to screen edges and persistent position storage.
*
* Features:
* - Touch/pointer drag & drop positioning
* - Auto-snap to left/right edges with smooth animation
* - Position saved in localStorage (persists across sessions)
* - WooCommerce add-to-cart event integration
* - Mobile and tablet optimized
* - Cross-browser compatible (Safari, Chrome, Firefox, Edge)
* - Memory-leak prevention and performance optimized
* - Scroll lock during drag (prevents page scroll interference)
* - Click detection (tap vs drag) for cart opening
*
* @package FKCart
* @since 1.0.0
* @author Cursor AI Development
*/
class FKCart_Drag {
public function __construct() {
add_action( 'wp_footer', array( $this, 'footer_scripts' ), 10000 );
}
public function footer_scripts() {
?>
<script>
(function fkcartFinal(){
const FK_SEL = '#fkcart-floating-toggler, .fkcart-toggler';
const STORAGE = 'fkcart_toggler_sidepos:v6';
const EDGE = 10;
const THRESH = 2;
const SNAP_MS = 400; // Increased for smoother animation
const LOG = false;
const log = (...a)=> LOG && console.log('[fkcart]', ...a);
// Prevent selection and native gestures on the toggler
const style = document.createElement('style');
style.textContent = `
${FK_SEL}, ${FK_SEL} *{
-webkit-user-select:none!important; user-select:none!important;
-webkit-touch-callout:none!important; -webkit-user-drag:none!important;
touch-action:none!important;
}`;
document.head.appendChild(style);
let dragging=false, moved=false, startX=0, startY=0, baseLeft=0, baseTop=0, dx=0, dy=0;
let target=null, suppressClickUntil=0, bodyScrollY=0, scrollLocked=false, isAnimating=false;
let animationSafetyTimeout=null, cleanupTimeout=null, pointerResetTimeout=null;
// ----- positioning helpers -----
function ensureFixed(el){
const cs = getComputedStyle(el);
if (cs.position !== 'fixed') el.style.position = 'fixed';
const r = el.getBoundingClientRect();
el.style.left = r.left + 'px';
el.style.top = r.top + 'px';
el.style.right = 'auto';
el.style.bottom= 'auto';
el.style.transform = 'none';
el.style.willChange = 'transform';
// lock marker to avoid vendor code moving it
el.dataset.fkcartLock = '1';
}
function clampTop(el, top){
const vh = window.innerHeight, r = el.getBoundingClientRect();
const maxTop = vh - EDGE - r.height;
return Math.min(Math.max(top, EDGE), Math.max(EDGE, maxTop));
}
function computeSnapLeft(el, side){
const vw = window.innerWidth, r = el.getBoundingClientRect();
return side === 'left' ? EDGE : (vw - EDGE - r.width);
}
function nearestSideFromLeft(el, left){
const vw = window.innerWidth, r = el.getBoundingClientRect();
const center = (left !== null && left !== undefined ? left : r.left) + r.width/2;
return center < vw/2 ? 'left' : 'right';
}
function setLeftTop(el, left, top){
el.style.left = Math.round(left) + 'px';
el.style.top = Math.round(top) + 'px';
}
function save(top, side){
localStorage.setItem(STORAGE, JSON.stringify({ top, side }));
}
function restore(el){
// Don't restore during animation to prevent jumping
if (isAnimating) return false;
const saved = JSON.parse(localStorage.getItem(STORAGE) || 'null');
if (!saved) return false;
ensureFixed(el);
const top = clampTop(el, saved.top);
const left = computeSnapLeft(el, saved.side);
setLeftTop(el, left, top);
return true;
}
function initialFromDataPosition(el){
const pos = (el.getAttribute('data-position') || '').toLowerCase().trim();
if (!/^bottom-(left|right)$/.test(pos)) return false;
ensureFixed(el);
const r = el.getBoundingClientRect();
const vh = window.innerHeight, vw = window.innerWidth;
const top = vh - EDGE - r.height;
const left = pos.endsWith('left') ? EDGE : (vw - EDGE - r.width);
setLeftTop(el, left, top);
return true;
}
// ----- safe scroll lock (iOS-safe) -----
function lockBodyScroll(){
if (scrollLocked) return;
try {
bodyScrollY = window.scrollY || document.documentElement.scrollTop || 0;
document.body.style.position = 'fixed';
document.body.style.top = `-${bodyScrollY}px`;
document.body.style.left = '0';
document.body.style.right = '0';
document.body.style.width = '100%';
document.documentElement.style.overscrollBehavior = 'none';
scrollLocked = true;
} catch(e) {
log('Error locking scroll:', e);
}
}
function unlockBodyScroll(){
if (!scrollLocked) return;
const savedScrollY = bodyScrollY;
try {
scrollLocked = false;
document.body.style.position = '';
document.body.style.top = '';
document.body.style.left = '';
document.body.style.right = '';
document.body.style.width = '';
document.documentElement.style.overscrollBehavior = '';
requestAnimationFrame(() => {
window.scrollTo(0, savedScrollY);
});
} catch(e) {
log('Error unlocking scroll:', e);
scrollLocked = false;
}
}
// ----- event flow -----
function startDragAt(x, y, pointerId){
if (dragging) return; // Prevent double-start
const el = document.querySelector(FK_SEL);
if (!el) return;
if (!el.dataset.fkcartPlaced) {
const ok = restore(el) || initialFromDataPosition(el);
el.dataset.fkcartPlaced = ok ? '1' : '1';
} else {
ensureFixed(el);
}
const r = el.getBoundingClientRect();
target = el; dragging = true; moved = false; dx = 0; dy = 0;
startX = x; startY = y;
baseLeft = r.left; baseTop = r.top;
// prepare transform-based drag
el.style.transition = 'none';
el.style.willChange = 'transform';
el.style.transform = 'translate3d(0,0,0)';
// Don't lock scroll yet - wait for actual movement
if (pointerId != null) {
try {
if (el.setPointerCapture) el.setPointerCapture(pointerId);
} catch(_){}
}
}
function moveDragTo(x, y, prevent){
if (!dragging || !target) return;
dx = x - startX; dy = y - startY;
// ALWAYS apply transform immediately for smooth dragging (no stuck feeling)
if (prevent) prevent();
target.style.transform = `translate3d(${Math.round(dx)}px, ${Math.round(dy)}px, 0)`;
// Check if movement threshold exceeded (for scroll locking only)
if (!moved && (Math.abs(dx) > THRESH || Math.abs(dy) > THRESH)) {
moved = true;
// Lock scroll only when actual movement detected
if (!scrollLocked) lockBodyScroll();
}
}
function endDrag(pointerId){
const el = target;
if (!el) return;
// Release pointer and unlock scroll first
try {
if (pointerId != null && el.releasePointerCapture) {
el.releasePointerCapture(pointerId);
}
} catch(_){}
unlockBodyScroll();
// Was this a real drag or just a tap?
const wasDragged = moved;
// Mark as not dragging
dragging = false;
target = null;
// If no movement, this was just a tap - allow click to proceed
if (!wasDragged) {
moved = false;
return; // Let the click event fire normally
}
// Calculate current visual position
const visualLeft = baseLeft + dx;
const visualTop = baseTop + dy;
const finalTop = clampTop(el, visualTop);
const side = nearestSideFromLeft(el, visualLeft);
const snapLeft = computeSnapLeft(el, side);
// Prevent external repositioning during animation
isAnimating = true;
// IMPORTANT: Save the final position FIRST (before animation starts)
// This prevents restore() from loading old position during animation
save(finalTop, side);
// Clear any existing safety timeout to prevent memory leak
if (animationSafetyTimeout) clearTimeout(animationSafetyTimeout);
// Safety: auto-reset animation flag after timeout (extended time for safety)
animationSafetyTimeout = setTimeout(() => {
isAnimating = false;
animationSafetyTimeout = null;
}, SNAP_MS + 1000);
// Mark element as locked during animation
el.dataset.fkcartLock = '1';
el.dataset.fkcartAnimating = '1';
// Commit the current vertical position immediately (no transition)
el.style.transition = 'none';
el.style.transform = 'translate3d(0, 0, 0)';
setLeftTop(el, visualLeft, finalTop);
// Animate ONLY horizontal movement to snap position (smooth slide to edge)
requestAnimationFrame(() => {
el.style.transition = `left ${SNAP_MS}ms cubic-bezier(0.4, 0.0, 0.2, 1)`;
el.style.left = snapLeft + 'px';
suppressClickUntil = Date.now() + SNAP_MS + 100;
// After animation: cleanup
// Clear any existing cleanup timeout to prevent memory leak
if (cleanupTimeout) clearTimeout(cleanupTimeout);
cleanupTimeout = setTimeout(() => {
const node = el.isConnected ? el : (document.querySelector(FK_SEL) || null);
if (node && node.style) {
node.style.transition = '';
node.style.transform = '';
node.style.willChange = '';
delete node.dataset.fkcartAnimating;
}
moved = false;
isAnimating = false; // Animation complete
cleanupTimeout = null; // Clear reference
}, SNAP_MS + 50);
});
}
// Pointer events + Touch fallback
let pointerHandled = false;
document.addEventListener('pointerdown', (e)=>{
const el = e.target && e.target.closest ? e.target.closest(FK_SEL) : null;
if (!el) return;
if (e.button !== undefined && e.button !== 0) return;
pointerHandled = true;
// Don't prevent default on pointerdown - let click events work
startDragAt(e.clientX, e.clientY, e.pointerId);
}, {capture:true, passive:true});
document.addEventListener('pointermove', (e)=>{
if (!dragging) return;
e.preventDefault();
moveDragTo(e.clientX, e.clientY, function() { e.preventDefault(); });
}, {passive:false});
document.addEventListener('pointerup', (e)=> dragging && endDrag(e.pointerId), {passive:true});
document.addEventListener('pointercancel',(e)=> dragging && endDrag(e.pointerId), {passive:true});
document.addEventListener('touchstart', (e)=>{
// Clear any existing timeout to prevent memory leak
if (pointerResetTimeout) clearTimeout(pointerResetTimeout);
// Reset pointer flag after a short delay
pointerResetTimeout = setTimeout(() => {
pointerHandled = false;
pointerResetTimeout = null;
}, 100);
// Avoid conflicts with pointer events (Chrome/Android uses pointer events)
if (dragging || pointerHandled) return;
const t = e.target && e.target.closest ? e.target.closest(FK_SEL) : null;
if (!t) return;
const touch = e.changedTouches && e.changedTouches[0];
if (!touch) return;
// Don't prevent default on touchstart - let click/tap events work
startDragAt(touch.clientX, touch.clientY, null);
}, {capture:true, passive:true});
document.addEventListener('touchmove', (e)=>{
if (!dragging) return;
const touch = e.changedTouches && e.changedTouches[0];
if (!touch) return;
e.preventDefault();
moveDragTo(touch.clientX, touch.clientY, function() { e.preventDefault(); });
}, {passive:false});
document.addEventListener('touchend', ()=> dragging && endDrag(null), {passive:true});
document.addEventListener('touchcancel',()=> dragging && endDrag(null), {passive:true});
// suppress click after drag, but allow normal clicks/taps
document.addEventListener('click', (e)=>{
const el = e.target && e.target.closest && e.target.closest(FK_SEL);
if (!el) return;
// Only suppress if this was after a drag
if (Date.now() < suppressClickUntil) {
e.stopPropagation();
e.preventDefault();
}
// Otherwise, let the click through to open the cart
}, true);
// ----- defend against external reposition on scroll/mutations -----
let reapplyThrottle = null;
function reapplySavedOrData(){
// Throttle to prevent excessive calls (performance)
if (reapplyThrottle) return;
reapplyThrottle = setTimeout(() => { reapplyThrottle = null; }, 50);
const el = document.querySelector(FK_SEL);
// Don't interfere during dragging or snap animation
if (!el || dragging || isAnimating || el.dataset.fkcartAnimating) return;
// If vendor code wrote bottom/right or transform while not dragging, put ours back.
const cs = getComputedStyle(el);
const hasBottomRight = (cs.bottom !== 'auto' || cs.right !== 'auto');
const movedByVendor = hasBottomRight || cs.transform !== 'none';
if (movedByVendor || el.dataset.fkcartLock !== '1') {
log('reapply (scroll/mutation)');
if (!restore(el)) initialFromDataPosition(el);
}
}
// Scroll handler (debounced via rAF)
let raf = null;
window.addEventListener('scroll', ()=>{
if (raf || dragging || isAnimating) return;
raf = requestAnimationFrame(()=>{ raf=null; reapplySavedOrData(); });
}, {passive:true});
// Mutation observer: if style/attrs/class change, re-apply ours
// Optimized: only observe the cart icon element, not entire document
const mo = new MutationObserver((mutations)=>{
if (dragging || isAnimating) return;
for (const m of mutations) {
if (m.type === 'attributes' && (m.attributeName === 'style' || m.attributeName === 'class' || m.attributeName === 'data-position')) {
reapplySavedOrData();
break;
}
}
});
let observedElement = null;
// Start observing when element exists
const observeCart = ()=>{
const el = document.querySelector(FK_SEL);
if (el && el !== observedElement) {
// Disconnect from old element if any
try { mo.disconnect(); } catch(e){}
// Observe new element
try {
mo.observe(el, { attributes:true, attributeFilter:['style','class','data-position'] });
observedElement = el;
} catch(e){ log('Observer error:', e); }
}
};
observeCart();
// On load, place it
window.addEventListener('load', ()=>{
const el = document.querySelector(FK_SEL);
if (el && !restore(el)) initialFromDataPosition(el);
});
// Handle WooCommerce add to cart - icon gets replaced, restore position immediately
if (typeof jQuery !== 'undefined') {
jQuery(document.body).on('added_to_cart', function() {
// Icon was replaced by WooCommerce, restore position after short delay
setTimeout(() => {
const el = document.querySelector(FK_SEL);
if (el && !el.dataset.fkcartPlaced) {
if (!restore(el)) initialFromDataPosition(el);
el.dataset.fkcartPlaced = '1';
observeCart(); // Re-observe the new element
}
}, 50);
});
// Also handle fragment refresh (WooCommerce updates cart via AJAX)
jQuery(document.body).on('wc_fragments_refreshed wc_fragments_loaded', function() {
setTimeout(() => {
const el = document.querySelector(FK_SEL);
if (el && !el.dataset.fkcartPlaced) {
if (!restore(el)) initialFromDataPosition(el);
el.dataset.fkcartPlaced = '1';
observeCart(); // Re-observe the new element
}
}, 50);
});
}
// Cleanup on page unload to prevent memory leaks and stuck browser
window.addEventListener('beforeunload', ()=>{
// Clear all timeouts
if (animationSafetyTimeout) clearTimeout(animationSafetyTimeout);
if (cleanupTimeout) clearTimeout(cleanupTimeout);
if (pointerResetTimeout) clearTimeout(pointerResetTimeout);
if (reapplyThrottle) clearTimeout(reapplyThrottle);
if (raf) cancelAnimationFrame(raf);
// Disconnect mutation observer
try {
mo.disconnect();
observedElement = null;
} catch(e){}
// Remove jQuery event handlers to prevent memory leaks
if (typeof jQuery !== 'undefined') {
try {
jQuery(document.body).off('added_to_cart wc_fragments_refreshed wc_fragments_loaded');
} catch(e){}
}
// Unlock scroll if locked
if (scrollLocked) {
try {
document.body.style.position = '';
document.body.style.top = '';
document.body.style.left = '';
document.body.style.right = '';
document.body.style.width = '';
document.documentElement.style.overscrollBehavior = '';
} catch(e){}
}
// Clear DOM references
target = null;
dragging = false;
isAnimating = false;
});
// Additional safety: disconnect observer on page hide
document.addEventListener('visibilitychange', ()=>{
if (document.hidden && scrollLocked) {
unlockBodyScroll();
}
});
})();
</script>
<?php
}
}
new FKCart_Drag();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment