import "@hotwired/turbo-rails" import { Application } from "@hotwired/stimulus" import LocaleController from "locale_controller" import ImageController from "image_controller" const application = Application.start() application.register("locale", LocaleController) application.register("image", ImageController) // Configure Stimulus development experience application.debug = false window.Stimulus = application export { application } var animationElements var originalHeights document.addEventListener('turbo:render', (event) => { console.info('turbo:render') let errorElements = document.querySelectorAll('.field_with_errors') if (errorElements.length > 0) { animationElements = document.querySelectorAll('.animation-element') gsap.set(animationElements, { opacity: 1 }) } }) // document.addEventListener("turbo:before-stream-render", animateElements) document.addEventListener("turbo:load", animateElements) function animateElements(event) { console.info(event.type) // Copy link to clipboard document.querySelectorAll('[data-share-title]').forEach((share_btn) => { share_btn.addEventListener('click', (e) => { if (navigator.share) { navigator.share({ title: e.currentTarget.getAttribute('data-share-title'), text: e.currentTarget.getAttribute('data-share-text'), url: e.currentTarget.getAttribute('data-share-url'), }).then(() => { console.log('Thanks for sharing!') }) .catch(console.error) } else { navigator.clipboard.writeText(window.location.href) alert('URL copied to clipboard!') } }) }) originalHeights = [] animationElements = document.querySelectorAll('.animation-element') animationElements.forEach((animationElement, index) => { const height = animationElement.offsetHeight originalHeights.push(height) // console.log(`Element ${index + 1} height: ${height}px`) }) gsap.set(animationElements, { height: 0, opacity: 0, overflow: 'hidden' }) let typewriterElements = document.querySelectorAll('.typewriter-text') if (typewriterElements.length > 0) { gsap.set(typewriterElements, { opacity: 0 }) } // Start the sequential animation animateElementsSequentially() } function animateElementsSequentially(index = 0) { if (index >= animationElements.length) { console.log('All animations complete') return } // 'margin-top': (index == (animationElements.length-1) ? "12px" : "8px"), gsap.to(animationElements[index], { height: originalHeights[index], opacity: 1, duration: (0.004 * originalHeights[index]), delay: (index > 0 ? 0.4 : 0), ease: "power2.out", onComplete: () => { // Focus input field animationElements[index].querySelector('input[type=text]')?.focus() // Typewriter const typewriterTexts = animationElements[index].querySelectorAll('.typewriter-text') if (typewriterTexts.length > 0) { const typewriter = typeWriterConsoleSequential(typewriterTexts[0].parentElement, { baseSpeed: 10, onAllComplete: () => { if (index + 1 < animationElements.length) { let nextAnimationElementInputField = animationElements[index+1].querySelector('input[type=text]') if (nextAnimationElementInputField) { animationElements[index].querySelectorAll('.typewriter-cursor').forEach((node) => { node.classList.remove('typewriter-cursor') }) } } // Start next element after typewriter finishes animateElementsSequentially(index + 1) } }) typewriter.start() return } // % Answer Distributions const answerDistributions = animationElements[index].querySelectorAll('.answer-distribution') if (answerDistributions.length > 0 ) { const containerHeight = animationElements[index].offsetHeight - 8 // - padding const percentages = Array.from(answerDistributions).map(bar => parseInt(bar.dataset.value)) const maxPercentage = Math.max(...percentages) const targetHeights = [] answerDistributions.forEach((bar, i) => { const percentage = parseInt(bar.dataset.value) const relativePercentage = (percentage / maxPercentage) * 100 const calculatedHeight = (relativePercentage / 100) * containerHeight const computedStyle = window.getComputedStyle(bar) const minHeight = parseInt(computedStyle.minHeight) || 0 let finalMinHeight = minHeight if (bar.querySelector('.explanation')) { finalMinHeight = Math.max(minHeight, 160) } targetHeights[i] = Math.max(calculatedHeight, finalMinHeight) }) const percentageValues = percentages.map((val, idx) => ({ value: val, index: idx })) const sortedPercentages = [...percentageValues].sort((a, b) => b.value - a.value) const highestBar = sortedPercentages[0] const lowestBar = sortedPercentages[sortedPercentages.length - 1] let equalHeight = answerDistributions[0].offsetHeight answerDistributions.forEach((bar, i) => { let fakeHeight, finalHeight if (i === highestBar.index) { fakeHeight = containerHeight * 0.3 } else if (i === lowestBar.index) { fakeHeight = containerHeight * 0.8 } else { fakeHeight = containerHeight * 0.5 } finalHeight = targetHeights[i] gsap.to(bar, { keyframes: [ { height: fakeHeight, duration: 0.4, ease: "power2.out" }, { height: equalHeight, duration: 0.4, ease: "power2.inOut" }, { height: finalHeight, duration: 0.4, ease: "power2.out" } ], onComplete: () => { if (bar.dataset.answered != undefined) { bar.classList.add('answered') } gsap.to(bar.querySelectorAll('.percentage'), { opacity: 1, duration: 0.4, onComplete: function () { const explanationElement = this.targets()[0].nextElementSibling if (explanationElement) { explanationElement.style.display = 'block' } } }) if (i == (answerDistributions.length - 1)) { setTimeout(() => animateElementsSequentially(index + 1), 1000) } } }) }) return } animateElementsSequentially(index + 1) } }) } function typeWriterConsoleSequential(container, options = {}) { const { selector = '.typewriter-text', baseSpeed = 60, divDelay = 400, showCursor = true, onDivComplete = null, onAllComplete = null } = options const textDivs = container.querySelectorAll(selector) if (textDivs.length === 0) return null let currentDivIndex = 0 let isActive = false let animationId = null let timeoutIds = new Set() // Track all timeouts for cleanup // Cleanup function for Turbo navigation function cleanup() { isActive = false if (animationId) { cancelAnimationFrame(animationId) animationId = null } // Clear all timeouts timeoutIds.forEach(id => clearTimeout(id)) timeoutIds.clear() } // Set up Turbo event listeners for cleanup function setupTurboListeners() { const turboEvents = ['turbo:before-visit', 'turbo:before-cache', 'beforeunload'] turboEvents.forEach(eventName => { document.addEventListener(eventName, cleanup, { once: false }) }) // Store cleanup function on container for manual cleanup container._typewriterCleanup = cleanup } // Enhanced setTimeout that tracks IDs function safeSetTimeout(callback, delay) { const id = setTimeout(() => { timeoutIds.delete(id) if (isActive) callback() }, delay) timeoutIds.add(id) return id } // Store original text and prepare divs function initializeDivs() { // Set container height to prevent layout shifts const containerHeight = container.offsetHeight container.style.minHeight = `${containerHeight}px` textDivs.forEach(div => { if (!div.dataset.originalText) { div.dataset.originalText = div.textContent.trim() } // Use visibility instead of blanking content to maintain layout div.style.visibility = 'hidden' div.innerHTML = '' div.style.opacity = '1' }) // Force reflow to ensure styles are applied container.offsetHeight } function createCursorSpan() { // No longer needed - cursor is now handled via CSS return null } function updateDivContent(div, text, withCursor = false) { // Use textContent for better performance div.textContent = text // Toggle cursor class instead of adding DOM element if (withCursor && showCursor) { div.classList.add('typewriter-cursor') } else { div.classList.remove('typewriter-cursor') } } function animateNextDiv() { if (currentDivIndex >= textDivs.length || !isActive) { isActive = false if (onAllComplete) { // Use setTimeout to avoid potential timing issues requestAnimationFrame(() => { safeSetTimeout(() => onAllComplete(container), 200) }) } return } const currentDiv = textDivs[currentDivIndex] const originalText = currentDiv.dataset.originalText const isLastDiv = currentDivIndex === textDivs.length - 1 // Make div visible and prepare for typing currentDiv.style.visibility = 'visible' updateDivContent(currentDiv, '', showCursor) currentDiv.classList.add('typing-active') let charIndex = 0 let nextCharTime = performance.now() + 100 // Initial delay function typeFrame(currentTime) { if (!isActive) { if (animationId) { cancelAnimationFrame(animationId) animationId = null } return } if (currentTime >= nextCharTime && charIndex < originalText.length) { charIndex++ const currentText = originalText.substring(0, charIndex) updateDivContent(currentDiv, currentText, true) // Calculate delay for next character let delay = baseSpeed + Math.random() * 15 const char = originalText.charAt(charIndex - 1) if (char === '.' || char === '!' || char === '?') { delay += 250 } else if (char === ',' || char === '') { delay += 120 } else if (char === ' ') { delay += 60 } nextCharTime = currentTime + delay } // Check if current div is complete if (charIndex >= originalText.length) { currentDiv.classList.remove('typing-active') // Final content update updateDivContent(currentDiv, originalText, showCursor) if (onDivComplete) { onDivComplete(currentDiv, currentDivIndex, container) } currentDivIndex++ if (isActive && currentDivIndex < textDivs.length) { // Move to next div after delay safeSetTimeout(() => { if (isActive) { // Remove cursor from previous div (except last one) if (!isLastDiv) { updateDivContent(currentDiv, originalText, false) } animateNextDiv() } }, divDelay) } else if (isActive) { // This was the last div safeSetTimeout(() => { if (onAllComplete) { onAllComplete(container) } }, 200) } return } // Continue animation if (isActive) { animationId = requestAnimationFrame(typeFrame) } } // Start the animation animationId = requestAnimationFrame(typeFrame) } // Initialize Turbo listeners setupTurboListeners() // Add required CSS for cursor pseudo-element function addCursorStyles() { const styleId = 'typewriter-cursor-styles' if (!document.getElementById(styleId)) { const style = document.createElement('style') style.id = styleId style.textContent = ` .typewriter-cursor::after { content: '|' animation: typewriter-blink 1s infinite color: currentColor } @keyframes typewriter-blink { 0%, 50% { opacity: 1 } 51%, 100% { opacity: 0 } } ` document.head.appendChild(style) } } // Initialize styles addCursorStyles() return { start: () => { if (isActive) return // Prevent multiple starts initializeDivs() isActive = true currentDivIndex = 0 // Use requestAnimationFrame for smoother start requestAnimationFrame(() => { if (isActive) { animateNextDiv() } }) }, stop: () => { cleanup() }, reset: () => { cleanup() textDivs.forEach(div => { if (div.dataset.originalText) { div.style.visibility = 'visible' updateDivContent(div, div.dataset.originalText, false) div.classList.remove('typing-active') div.classList.remove('typewriter-cursor') } }) currentDivIndex = 0 }, // Manual cleanup method for explicit cleanup destroy: () => { cleanup() // Remove Turbo event listeners const turboEvents = ['turbo:before-visit', 'turbo:before-cache', 'beforeunload'] turboEvents.forEach(eventName => { document.removeEventListener(eventName, cleanup) }) // Remove cleanup reference delete container._typewriterCleanup }, get isActive() { return isActive } } }