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)
|
|
document.addEventListener("turbo:load", validateFormInput)
|
|
|
|
|
|
function validateFormInput(event) {
|
|
const form = document.getElementById('questionForm');
|
|
const submitBtn = document.getElementById('questionSubmitButton');
|
|
|
|
if (form) {
|
|
form.addEventListener('submit', e => {
|
|
// If no radio in the group is checked…
|
|
if (!form.querySelector('input[type=radio]:checked')) {
|
|
e.preventDefault(); // stop the form from submitting
|
|
submitBtn.classList.remove('wiggle'); // reset so the animation can retrigger
|
|
void submitBtn.offsetWidth; // force reflow (quick trick)
|
|
submitBtn.classList.add('wiggle'); // start the wiggle
|
|
}
|
|
});
|
|
|
|
// Tidy up the class once the animation ends
|
|
submitBtn.addEventListener('animationend', () => {
|
|
submitBtn.classList.remove('wiggle');
|
|
});
|
|
}
|
|
}
|
|
|
|
|
|
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
|
|
})
|
|
|
|
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 + ((Math.random() - 0.5) * 0.2))
|
|
} else if (i === lowestBar.index) {
|
|
fakeHeight = containerHeight * (0.8 + ((Math.random() - 0.5) * 0.2))
|
|
} else {
|
|
fakeHeight = containerHeight * (0.5 + ((Math.random() - 0.5) * 0.2))
|
|
}
|
|
|
|
finalHeight = targetHeights[i]
|
|
|
|
gsap.to(bar, {
|
|
keyframes: [
|
|
{ height: fakeHeight, duration: 0.3, ease: "power2.inOut" },
|
|
{ height: finalHeight, duration: 0.4, ease: "power2.in" }
|
|
],
|
|
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 = 40,
|
|
divDelay = 200,
|
|
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
|
|
const isFirstDiv = currentDivIndex === 0
|
|
|
|
// Make div visible and prepare for typing
|
|
currentDiv.style.visibility = 'visible'
|
|
updateDivContent(currentDiv, '', showCursor)
|
|
currentDiv.classList.add('typing-active')
|
|
|
|
let charIndex = 0
|
|
// Add 1 second delay for first div to let cursor blink twice
|
|
let nextCharTime = performance.now() + (isFirstDiv ? 20 : 20)
|
|
|
|
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
|
|
let delay = baseSpeed
|
|
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 }
|
|
}
|
|
}
|