Created
November 11, 2025 08:00
-
-
Save xlplugins/f8a4e39affdf751f38e0ed06acea3ce7 to your computer and use it in GitHub Desktop.
Funnelkit Cart: FKCart Draggable Icon Positioning
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * 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