/** * Mobile portfolio interactions. * ----------------------------- * Slider + Flip/GSAP modal (no Three.js, no hover cursor). * Active only below the desktop breakpoint. */ (function () { gsap.registerPlugin(Flip, Observer); // ============================================================================= // VIEWPORT GUARD — mobile / tablet only // ============================================================================= const MOBILE_MQ = window.matchMedia("(max-width: 991px)"); function isMobileViewport() { return MOBILE_MQ.matches; } function onDomReady(callback) { if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", callback, { once: true }); } else { callback(); } } // Shared: ignore tap-open when the carousel just dragged let carouselDidDrag = false; // ============================================================================= // WORKS CAROUSEL — seamless loop with the five project items. // Each item wraps by coordinate; no clones, DOM reorder, or empty track edges. // ============================================================================= const CMS_GAP = 1; const CMS_VISIBLE = 2.2; const CMS_SNAP_DURATION = 0.55; const CMS_SNAP_EASE = "power3.out"; const CMS_VELOCITY_THRESHOLD = 1100; function initWorksCarousel() { const wrapper = document.querySelector(".cms-wrapper-m"); const list = wrapper?.querySelector(".cms-list-m"); const slides = list ? Array.from(list.querySelectorAll(".project_component-m")) : []; const items = slides.map((slide) => slide.querySelector(".cms-item-m")); if ( !wrapper || !list || slides.length === 0 || items.some((item) => !item) ) { return; } let cardW = 0; let step = 0; let loopWidth = 0; let offset = 0; let pressOffset = 0; let active = Math.floor(slides.length / 2); const offsetState = { value: 0 }; function modulo(value, length) { return ((value % length) + length) % length; } function measure() { cardW = (wrapper.clientWidth - CMS_GAP * Math.floor(CMS_VISIBLE)) / CMS_VISIBLE; step = cardW + CMS_GAP; loopWidth = step * slides.length; wrapper.style.setProperty("--cms-card-w", `${cardW}px`); } function centerLeft() { return wrapper.clientWidth / 2 - cardW / 2; } function virtualIndexFor(value) { return Math.round((centerLeft() - value) / step); } function setActiveClass(index) { slides.forEach((slide, i) => { slide.classList.toggle("is-active", i === index); }); items.forEach((item, i) => { item.classList.toggle("is-active", i === index); }); } function render(value) { offset = value; const wrapX = gsap.utils.wrap(-step, loopWidth - step); slides.forEach((slide, index) => { const naturalX = index * step; const wrappedX = wrapX(naturalX + offset); gsap.set(slide, { x: wrappedX - naturalX }); }); active = modulo(virtualIndexFor(offset), slides.length); setActiveClass(active); } function snapTo(virtualIndex, animate = true) { const destination = centerLeft() - virtualIndex * step; if (!animate) { offsetState.value = destination; render(destination); return; } offsetState.value = offset; gsap.to(offsetState, { value: destination, duration: CMS_SNAP_DURATION, ease: CMS_SNAP_EASE, overwrite: true, onUpdate: () => render(offsetState.value), onComplete: () => { // Keep numbers small without changing any visual position. const normalized = centerLeft() - active * step; offsetState.value = normalized; render(normalized); }, }); } measure(); gsap.set(list, { x: 0 }); snapTo(active, false); Observer.create({ target: wrapper, type: "touch,pointer", dragMinimum: 6, lockAxis: true, onPress() { carouselDidDrag = false; gsap.killTweensOf(offsetState); pressOffset = offset; }, onDrag(self) { carouselDidDrag = true; render(pressOffset + (self.x - self.startX)); }, onDragEnd(self) { let next = virtualIndexFor(offset); if (Math.abs(self.velocityX) > CMS_VELOCITY_THRESHOLD) { next += self.velocityX < 0 ? 1 : -1; } snapTo(next, true); // Suppress the synthetic click that often follows a swipe window.setTimeout(() => { carouselDidDrag = false; }, 50); }, }); window.addEventListener("resize", () => { if (!isMobileViewport()) return; gsap.killTweensOf(offsetState); measure(); snapTo(active, false); }); } // ============================================================================= // MODAL PORTAL — keep component markup in .cms-item-m, move open .popup-m to // so position:fixed / z-index escape the carousel transform + overflow. // ============================================================================= function nextFrames(n = 2) { return new Promise((resolve) => { const step = () => { n -= 1; if (n <= 0) resolve(); else requestAnimationFrame(step); }; requestAnimationFrame(step); }); } function initMobileModal() { const FLIP_DURATION = 0.7; const FLIP_EASE = "power2.inOut"; const CLONE_CLASS = "cms-pop-m-flip-clone"; let activePopup = null; let activeHome = null; let activeClone = null; let activeFlipTween = null; let activeImgTween = null; let activeFromPop = null; let activeToPop = null; let isFlipping = false; let isClosing = false; let pageScrollState = null; let openRunId = 0; const pageScrollKeys = new Set([ "ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", ]); function isInsideModal(target) { return ( target instanceof Element && Boolean(target.closest(".popup-m")) ); } function preventPageScroll(event) { if (isInsideModal(event.target)) return; event.preventDefault(); } function preventPageScrollKeys(event) { if (!pageScrollKeys.has(event.key) || isInsideModal(event.target)) return; event.preventDefault(); } function restoreLockedPagePosition() { if (!pageScrollState) return; if ( window.scrollX !== pageScrollState.x || window.scrollY !== pageScrollState.y ) { window.scrollTo(pageScrollState.x, pageScrollState.y); } } function lockPageScroll() { if (pageScrollState) return; pageScrollState = { x: window.scrollX, y: window.scrollY, }; document.documentElement.classList.add("is-modal-open"); document.addEventListener("wheel", preventPageScroll, { passive: false, capture: true, }); document.addEventListener("touchmove", preventPageScroll, { passive: false, capture: true, }); document.addEventListener("keydown", preventPageScrollKeys, true); window.addEventListener("scroll", restoreLockedPagePosition, { passive: true, }); } function unlockPageScroll() { if (!pageScrollState) return; document.removeEventListener("wheel", preventPageScroll, true); document.removeEventListener("touchmove", preventPageScroll, true); document.removeEventListener("keydown", preventPageScrollKeys, true); window.removeEventListener("scroll", restoreLockedPagePosition); window.scrollTo(pageScrollState.x, pageScrollState.y); document.documentElement.classList.remove("is-modal-open"); pageScrollState = null; } function showPopup(popup) { gsap.killTweensOf(popup); gsap .timeline() .set(popup, { display: "block", opacity: 0 }, 0) .to( popup, { opacity: 1, duration: 0.5, ease: "power1.out", }, 0.2 ); } function hidePopup(popup) { if (!popup) return Promise.resolve(); gsap.killTweensOf(popup); return new Promise((resolve) => { gsap.to(popup, { opacity: 0, duration: 0.5, ease: "power1.out", overwrite: true, onComplete: () => { gsap.set(popup, { display: "none" }); stopModalVideos(popup); resolve(); }, }); }); } function portalToBody(popup) { if (!popup || popup.parentElement === document.body) return; document.body.appendChild(popup); } function restoreToHome(popup, home) { if (!popup || !home) return; if (popup.parentElement === home) return; home.appendChild(popup); } function resetPopupScroll(popup) { [popup, ...popup.querySelectorAll(".pop-m-wrap, .pop-m-layout")].forEach( (el) => { el.scrollTop = 0; el.scrollLeft = 0; } ); } function getModalVideos(popup) { if (!popup) return []; return [...popup.querySelectorAll("video.cursor-video-m")]; } function prepareModalVideos(popup) { getModalVideos(popup).forEach((video) => { video.muted = true; video.playsInline = true; video.loop = true; const lazySrc = video.dataset.src; if (lazySrc && !video.getAttribute("src") && !video.currentSrc) { video.src = lazySrc; video.load(); } }); } function playModalVideos(popup) { getModalVideos(popup).forEach((video) => { const lazySrc = video.dataset.src; if (lazySrc && !video.getAttribute("src") && !video.currentSrc) { video.src = lazySrc; video.load(); } try { video.currentTime = 0; } catch (_) { /* ignore seek before metadata */ } video.play().catch(() => {}); }); } function stopModalVideos(popup) { getModalVideos(popup).forEach((video) => { video.pause(); try { video.currentTime = 0; } catch (_) { /* ignore */ } }); } function makeClone(source) { const clone = source.cloneNode(true); clone.classList.add(CLONE_CLASS); clone.removeAttribute("data-flip-id"); clone.setAttribute("aria-hidden", "true"); const sourceImages = source.querySelectorAll("img"); clone.querySelectorAll("img").forEach((image, index) => { const currentSrc = sourceImages[index]?.currentSrc; if (currentSrc) { image.removeAttribute("srcset"); image.removeAttribute("sizes"); image.src = currentSrc; } image.loading = "eager"; }); document.body.appendChild(clone); Flip.fit(clone, source, { scale: false }); activeClone = clone; return clone; } async function waitForCloneImages(clone) { const images = [...clone.querySelectorAll("img")]; await Promise.all( images.map(async (image) => { if (!image.complete) { await new Promise((resolve) => { image.addEventListener("load", resolve, { once: true }); image.addEventListener("error", resolve, { once: true }); }); } if (typeof image.decode === "function") { await image.decode().catch(() => {}); } }) ); } function clearFlight() { activeFlipTween?.kill(); activeFlipTween = null; if (activeClone) { Flip.killFlipsOf(activeClone); gsap.killTweensOf(activeClone); activeClone.remove(); activeClone = null; } if (activeFromPop) { gsap.set(activeFromPop, { clearProps: "opacity" }); activeFromPop = null; } } function prepareSecondImage(popup) { activeImgTween?.kill(); activeImgTween = null; const images = popup?.querySelectorAll("[img-wrap-move-m]"); if (!images?.length) return; gsap.set(images, { y: "100%", opacity: 0 }); } function animateSecondImage(popup) { const images = popup?.querySelectorAll("[img-wrap-move-m]"); if (!images?.length) return; activeImgTween?.kill(); activeImgTween = gsap.to(images, { y: 0, opacity: 1, delay: 0.3, duration: 0.7, ease: "power2.out", overwrite: true, onComplete: () => { activeImgTween = null; }, }); } function revertSecondImage(popup) { activeImgTween?.kill(); activeImgTween = null; const images = popup?.querySelectorAll("[img-wrap-move-m]"); if (images?.length) { gsap.set(images, { clearProps: "transform,y,opacity" }); } } function resetPopupLayout(popup) { const layout = popup?.querySelector(".pop-m-layout"); if (!layout) return; gsap.killTweensOf(layout); gsap.set(layout, { clearProps: "transform,y" }); } function animateCloseLayout(popup) { const layout = popup?.querySelector(".pop-m-layout"); if (!layout) return Promise.resolve(); gsap.killTweensOf(layout); return new Promise((resolve) => { gsap.to(layout, { y: "50%", duration: 0.55, ease: "power2.inOut", overwrite: true, onComplete: resolve, }); }); } async function closeActivePopup() { openRunId += 1; const popup = activePopup; const home = activeHome; const toPop = activeToPop; activePopup = null; activeHome = null; activeToPop = null; isFlipping = false; isClosing = true; clearFlight(); revertSecondImage(popup); if (toPop) gsap.set(toPop, { clearProps: "opacity" }); // Fade the modal and slide its layout down in parallel. await Promise.all([hidePopup(popup), animateCloseLayout(popup)]); resetPopupLayout(popup); restoreToHome(popup, home); unlockPageScroll(); isClosing = false; } document.querySelectorAll("[pop-open-m]").forEach((button) => { button.addEventListener("click", async () => { if (carouselDidDrag) return; const item = button.closest(".cms-item-m"); const popup = item?.querySelector(".popup-m"); const fromPop = button.querySelector(".cms-pop-m"); const toPop = popup?.querySelector(".img-wrap-m .cms-pop-m"); if (!item || !popup || !fromPop || !toPop) return; if (!item.classList.contains("is-active")) return; if (activePopup || isFlipping || isClosing) return; const runId = ++openRunId; activePopup = popup; activeHome = item; activeToPop = toPop; isFlipping = true; lockPageScroll(); clearFlight(); prepareSecondImage(popup); resetPopupLayout(popup); const clone = makeClone(fromPop); activeFromPop = fromPop; gsap.set(clone, { opacity: 0 }); // Escape the transformed carousel before showing/measuring the fixed modal. portalToBody(popup); resetPopupScroll(popup); prepareModalVideos(popup); // Keep the original visible until the cloned image is ready to paint. await waitForCloneImages(clone); if (runId !== openRunId || activeClone !== clone) return; showPopup(popup); gsap.set(toPop, { opacity: 0 }); // Let display:block and the portal layout settle before Flip measures it. await nextFrames(2); if (runId !== openRunId) return; if (activeClone !== clone) return; // Swap original → decoded clone in one paint, then start Flip next frame. gsap.set(fromPop, { opacity: 0 }); gsap.set(clone, { opacity: 1 }); await nextFrames(1); if (runId !== openRunId || activeClone !== clone) return; // Same timing/ease as desktop; the visible DOM clone replaces Three.js. animateSecondImage(popup); activeFlipTween = Flip.fit(clone, toPop, { scale: false, duration: FLIP_DURATION, ease: FLIP_EASE, onComplete: () => { if (runId !== openRunId || activeClone !== clone) return; activeFlipTween = null; gsap.set(toPop, { opacity: 1 }); requestAnimationFrame(() => { if (runId !== openRunId || activeClone !== clone) return; clone.remove(); activeClone = null; isFlipping = false; playModalVideos(popup); }); }, }); }); }); document.addEventListener("click", (event) => { const closeButton = event.target.closest?.("[pop-close-m]"); if (!closeButton) return; if (!activePopup) return; closeActivePopup(); }); const popups = document.querySelectorAll(".cms-item-m .popup-m"); gsap.set(popups, { display: "none", opacity: 0, }); popups.forEach((popup) => stopModalVideos(popup)); } // ============================================================================= // MOBILE INIT // ============================================================================= onDomReady(() => { if (!isMobileViewport()) return; initWorksCarousel(); initMobileModal(); }); })();