Mattias Bodlund 6 months ago
parent
commit
49072574ec
6 changed files with 189 additions and 148 deletions
  1. +2
    -1
      app/assets/stylesheets/application.css
  2. +1
    -1
      app/helpers/questions_helper.rb
  3. +182
    -143
      app/javascript/application.js
  4. +1
    -1
      app/views/players/new.html.erb
  5. +1
    -1
      app/views/questions/result.html.erb
  6. +2
    -1
      config/locales/en.yml

+ 2
- 1
app/assets/stylesheets/application.css View File

@ -429,7 +429,7 @@ main {
line-height: 1.3; line-height: 1.3;
align-self: end; align-self: end;
min-height: 160px;
/*min-height: 160px;*/
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -524,6 +524,7 @@ label {
content: '|'; content: '|';
animation: typewriter-blink 1s infinite; animation: typewriter-blink 1s infinite;
color: currentColor; color: currentColor;
font-weight: 400;
position: absolute; position: absolute;
} }


+ 1
- 1
app/helpers/questions_helper.rb View File

@ -3,7 +3,7 @@ module QuestionsHelper
def decorate_divs_for_typewriting_effect(html) def decorate_divs_for_typewriting_effect(html)
doc = Nokogiri::HTML.fragment(html, 'utf-8') doc = Nokogiri::HTML.fragment(html, 'utf-8')
doc.xpath('./*').each do |div|
doc.xpath('./*[position() > last()-2]').each do |div|
div['class'] = 'typewriter-text' div['class'] = 'typewriter-text'
end end


+ 182
- 143
app/javascript/application.js View File

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

+ 1
- 1
app/views/players/new.html.erb View File

@ -25,7 +25,7 @@
<div class="animation-element"> <div class="animation-element">
<%= form.label :name, class: "question-answer" do %> <%= form.label :name, class: "question-answer" do %>
<%= form.text_field :name, placeholder: t('what_is_your_name'), autofocus: true %>
<%= form.text_field :name, placeholder: t('write_your_name'), autofocus: true, autocomplete: 'off' %>
<% end %> <% end %>
</div> </div>


+ 1
- 1
app/views/questions/result.html.erb View File

@ -26,7 +26,7 @@
<div class="animation-element"> <div class="animation-element">
<div class="result-actions"> <div class="result-actions">
<%= link_to tag.span(t('read_more')), "#", class: 'button__base' %>
<%= link_to tag.span(t('read_more')), t('read_more_link'), class: 'button__base' %>
<%= button_tag tag.span(t('share_on_story')), <%= button_tag tag.span(t('share_on_story')),
data: { data: {
share_title: t('share.title'), share_title: t('share.title'),


+ 2
- 1
config/locales/en.yml View File

@ -5,7 +5,7 @@ en:
get_started: Let’s go! get_started: Let’s go!
what_is_your_name: What’s your name? what_is_your_name: What’s your name?
please_type_your_name_here: Please type your name here…
write_your_name: Write your name.
submit: Submit submit: Submit
next_question: Next question next_question: Next question
of_people_worldwide_think_just_lik_you: of people worldwide think like you. of_people_worldwide_think_just_lik_you: of people worldwide think like you.
@ -13,6 +13,7 @@ en:
results: Results results: Results
read_more: Read more read_more: Read more
share_on_story: Share share_on_story: Share
read_more_link: https://ikeafoundation.org/25
please_type_your_name_to_continue: Please type your name to continue please_type_your_name_to_continue: Please type your name to continue
share: share:


Loading…
Cancel
Save