Created
June 12, 2019 12:37
-
-
Save MarioSo/59cb8f25dbe2cc0927e34911d77eeb1d to your computer and use it in GitHub Desktop.
Wall of Text vue component by URSA MAJOR SUPERCLUSTER
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
| <script> | |
| import { TimelineMax, Power0, Power3 } from 'gsap' | |
| import { find, hasClass } from '../scripts/elements' | |
| import { addListener } from '../scripts/events' | |
| import SpanOrBreak from './SpanOrBreak.vue' | |
| /// UTILS | |
| const shuffle = arr => { | |
| const a = arr.slice(0) | |
| let j, x, i | |
| for (i = a.length - 1; i > 0; i--) { | |
| j = Math.floor(Math.random() * (i + 1)) | |
| x = a[i] | |
| a[i] = a[j] | |
| a[j] = x | |
| } | |
| return a | |
| } | |
| const getRandomNumber = (from, to) => { | |
| return Math.floor(Math.random() * to) + from | |
| } | |
| const pickRandom = arr => { | |
| return arr[getRandomNumber(0, arr.length)] | |
| } | |
| const pickFillWords = (arr, arrSizes, length, num) => { | |
| const words = [] | |
| while (words.length < num) { | |
| const word = pickRandom(arr) | |
| if (arrSizes[word.id].width > length) { | |
| words.push(word) | |
| } | |
| } | |
| return words | |
| } | |
| /// UTILS END | |
| export default { | |
| name: 'WallOfText', | |
| components: { | |
| SpanOrBreak, | |
| }, | |
| data() { | |
| const baseWords = [ | |
| 'We', | |
| 'are', | |
| 'Ursa', | |
| 'Major', | |
| 'Supercluster', | |
| 'and', | |
| 'this', | |
| 'is', | |
| 'a', | |
| 'wall', | |
| 'of', | |
| 'text', | |
| ] | |
| return { | |
| // words, | |
| sentences: [ | |
| ['We', 'are', 'Ursa', 'Major', 'Supercluster'], | |
| ['and', 'this', 'is', 'a', 'wall', 'of', 'text'], | |
| ], | |
| spacing: 20, | |
| text: baseWords.map((t, i) => ({ id: i, word: t })), | |
| words: [], | |
| allWordsRefs: [], | |
| fontsLoaded: false, | |
| mobile: null, | |
| // animation | |
| revealAnimationIntro: null, | |
| currentSentence: 0, | |
| loop: true, | |
| } | |
| }, | |
| mounted() { | |
| // wait for the font to be loaded in order to get the correct | |
| // reference sizes of each word | |
| this.onFontLoaded(() => { | |
| this.$nextTick(function() { | |
| this.words = this.setupWords() | |
| this.mobile = this.isMobile() | |
| this.$nextTick(function() { | |
| this.revealAnimationIntro = this.createRevealAnimationIntro() | |
| this.revealAnimationIntro.play(0) | |
| this.$refs.textContainer.style.opacity = 1 | |
| }) | |
| }) | |
| }) | |
| addListener(window, 'resize', this.onResize) | |
| }, | |
| methods: { | |
| getMaxLines(width) { | |
| if (width < 1024) { | |
| return 22 | |
| } | |
| if (width > 1367) { | |
| return 14 | |
| } | |
| return 18 | |
| }, | |
| setupWords() { | |
| // talking about fullscreen word overlay here | |
| const height = | |
| Math.max( | |
| document.documentElement.clientHeight, | |
| window.innerHeight || 0 | |
| ) - | |
| 2 * this.spacing | |
| const width = | |
| Math.max(document.documentElement.clientWidth, window.innerWidth || 0) - | |
| 2 * this.spacing | |
| // get size of each word, tried a lot getBoundinClientRect came closest to the actual size | |
| const sizes = this.$refs.refWords.map(e => { | |
| const rect = e.getBoundingClientRect() | |
| return { | |
| width: rect.width, | |
| height: rect.height, | |
| } | |
| }) | |
| return this.updateIdx(this.text, sizes, width, height) | |
| }, | |
| updateIdx(text, textSizes, width, height) { | |
| const returnText = [] // holding all text objects (spans & breaks) | |
| const shuffleText = !this.mobile // do not shuffle on mobile b/c of limited screen space | |
| const maxLines = this.getMaxLines(width) // differnet lines for different screens | |
| const currentSize = { | |
| // track the current w & h of the wall of text | |
| width: 0, | |
| height: 0, | |
| } | |
| let idx = 0 | |
| let i = 0 | |
| let lines = 0 | |
| let elementCount = 0 | |
| let randomWords = shuffleText ? shuffle(text) : text.slice(0) | |
| this.allWordsRefs = [] // reset references | |
| // insert words till the screen is packedk | |
| while ( | |
| currentSize.width < width && | |
| currentSize.height < height && | |
| i < 400 // safety net to prevent infinite loop | |
| ) { | |
| const el = randomWords.shift() // get a word | |
| const space = width - currentSize.width // space left on the right | |
| let lineClass = `js-line--${lines % 2 === 0 ? 'left' : 'right'}` // used for alternating animation | |
| currentSize.width += textSizes[el.id].width | |
| if (currentSize.width > width) { | |
| const fillWords = pickFillWords(text, textSizes, space / 2, 2) // get a random fill word | |
| // random word needs a fakeObject in order to be added to retrunText | |
| const fakeObj = { | |
| id: -1, | |
| word: el.word, | |
| key: 'ignore1-' + getRandomNumber(10000, 20000), | |
| // text: fillWords[0].word, | |
| text: `${fillWords[0].word} ${fillWords[0].word}`, // use the word twice in case it is to short | |
| className: 'ignore-me ' + lineClass, | |
| } | |
| // create a second one for left/right side | |
| const fakeObj2 = { | |
| id: -2, | |
| word: el.word, | |
| key: 'ignore2-' + getRandomNumber(1, 10000), | |
| text: `${fillWords[0].word} ${fillWords[0].word}`, // use the word twice in case it is to short | |
| className: 'ignore-me ' + lineClass, | |
| } | |
| // line is over add a <br /> | |
| const breakObj = { | |
| id: -99, | |
| word: 'break', | |
| key: 'break-' + getRandomNumber(1, 10000), | |
| } | |
| // add all the objects to the retured array | |
| // first one needs to be at the beginning of the line | |
| const indexToAdd = idx === 0 ? 0 : idx - elementCount + lines * 3 | |
| returnText.splice(indexToAdd, 0, fakeObj2) | |
| returnText.push(fakeObj) | |
| returnText.push(breakObj) | |
| // some housekeeping | |
| currentSize.width = textSizes[el.id].width | |
| currentSize.height += textSizes[el.id].height | |
| lines++ | |
| elementCount = 0 // will be increased in this loop | |
| lineClass = `js-line--${lines % 2 === 0 ? 'left' : 'right'}` | |
| } | |
| if (Math.ceil(currentSize.height) >= height || lines >= maxLines) break | |
| const id = idx // i can't be used since it will be increased on every loop | |
| const skip = lines === 0 || lines === maxLines - 1 // do not animate first & last row | |
| // this is the actual word which will be animated | |
| const textObj = { | |
| id, | |
| word: el.word, | |
| key: id, | |
| ref: 'word-' + id, | |
| className: `${el.word.toLowerCase().replace(' ', '-')} ${lineClass}`, | |
| text: el.word, | |
| skip, | |
| } | |
| // build up a reference array for each word | |
| // needed to animate the sentence in order | |
| if (!skip) { | |
| if (this.allWordsRefs[el.word]) { | |
| this.allWordsRefs[el.word] = [ | |
| ...this.allWordsRefs[el.word], | |
| textObj, | |
| ] | |
| } else { | |
| this.allWordsRefs[el.word] = [textObj] | |
| } | |
| } | |
| returnText.push(textObj) | |
| // if all words from the sentence are placed on the screen start over | |
| if (randomWords.length === 0) { | |
| randomWords = shuffleText ? shuffle(text) : text.slice(0) | |
| } | |
| // housekeeping | |
| i++ | |
| idx++ | |
| elementCount++ | |
| } | |
| return returnText | |
| }, | |
| // ANIMATION | |
| createRevealAnimationIntro() { | |
| const allWords = find('.js-font-overlay__text') | |
| const leftWords = find('.js-line--left') | |
| const rightWords = find('.js-line--right') | |
| const links = find('.js-link') | |
| const quarter = Math.floor(allWords.length / 6) | |
| const part1 = shuffle(allWords.slice(0, quarter)) | |
| const part2 = shuffle(allWords.slice(quarter, quarter * 2)) | |
| const part3 = shuffle(allWords.slice(quarter * 2, quarter * 3)) | |
| const part4 = shuffle(allWords.slice(quarter * 3, quarter * 4)) | |
| const part5 = shuffle(allWords.slice(quarter * 4, quarter * 5)) | |
| const part6 = shuffle(allWords.slice(quarter * 5)) | |
| // const stagger1 = 0.04 | |
| // const stagger2 = 0.04 | |
| const stagger3 = 0.04 | |
| const dur = 0.3 | |
| const dur1 = 1.5 | |
| const dur2 = 1.5 | |
| const dur3 = 0.5 | |
| const from = { | |
| opacity: 0, | |
| color: '#fbf7f3', | |
| webkitTextStrokeColor: 'transparent', | |
| ease: Power0.easeNone, | |
| } | |
| const from1 = { x: -100 } | |
| const from2 = { x: 100 } | |
| const to = { opacity: 1, ease: Power0.easeNone } | |
| const to1 = { x: 0, ease: Power3.easeOut } | |
| const to2 = { x: 0, ease: Power3.easeOut } | |
| const to3 = { | |
| opacity: 0.15, | |
| color: 'transparent', | |
| webkitTextStrokeColor: '#fbf7f3', | |
| ease: Power0.easeNone, | |
| } | |
| return new TimelineMax({ | |
| paused: true, | |
| onComplete: () => { | |
| this.loop = true | |
| this.introAnimation = false | |
| this.createLoopAnimation() | |
| }, | |
| }) | |
| .fromTo(allWords, dur, from, to, 0.3) | |
| .fromTo(leftWords, dur1, from1, to1, 0.3) | |
| .fromTo(rightWords, dur2, from2, to2, 0.3) | |
| .staggerTo(part1, dur3, to3, stagger3, 2) | |
| .staggerTo(part2, dur3, to3, stagger3, 2) | |
| .staggerTo(part3, dur3, to3, stagger3, 2) | |
| .staggerTo(part4, dur3, to3, stagger3, 2) | |
| .staggerTo(part5, dur3, to3, stagger3, 2) | |
| .staggerTo(part6, dur3, to3, stagger3, 2) | |
| .staggerFromTo(links, 0.2, from, to, 0.05) | |
| }, | |
| pickSentence() { | |
| const sentence = this.sentences[this.currentSentence] | |
| const maxTries = 10 | |
| let isValidSentence = false | |
| let elements = [] | |
| let setToZero = false | |
| let trys = 0 | |
| let wordTrys = 0 | |
| const fromRandom = this.mobile ? 0 : 2 // pick a random start index | |
| const toRandom = this.mobile ? 2 : 3 // max word index | |
| while (!isValidSentence && trys <= maxTries) { | |
| let currentIdx = 0 | |
| let nextRandom = setToZero ? 0 : getRandomNumber(fromRandom, toRandom) | |
| isValidSentence = true | |
| trys++ | |
| elements = sentence.map(word => { | |
| let w = this.allWordsRefs[word][nextRandom] | |
| // if there are no more words in the array | |
| if (!w) { | |
| isValidSentence = false | |
| setToZero = true | |
| return null | |
| } | |
| // compare id to make sure the order is correct | |
| while (w && w.id <= currentIdx && wordTrys <= maxTries) { | |
| w = this.allWordsRefs[word][++nextRandom] | |
| wordTrys++ | |
| } | |
| if (!w) { | |
| isValidSentence = false | |
| setToZero = true | |
| return null | |
| } | |
| currentIdx = w.id | |
| // add dom element to elements array | |
| return find('.js-font-overlay__text--' + w.id)[0] | |
| }) | |
| } | |
| // if still not valid | |
| if (!isValidSentence) return [] | |
| this.currentSentence = | |
| this.currentSentence + 1 === this.sentences.length | |
| ? 0 | |
| : this.currentSentence + 1 | |
| return elements | |
| }, | |
| createLoopAnimation() { | |
| if (!this.loop) return | |
| if (this.loopAnimation) { | |
| this.loopAnimation.progress(0).kill() | |
| } | |
| const dur = 0.2 // transition duration | |
| const dur2 = 0.4 // transition duration | |
| const delay = 2.4 // duration of text beeing fully white | |
| const sentence = this.pickSentence() | |
| if (sentence.length === 0) { | |
| console.log('no sentence for you today') | |
| return | |
| } | |
| this.loopAnimation = new TimelineMax({ | |
| delay: 1, | |
| onComplete: () => { | |
| if (!this.loop) return | |
| this.createLoopAnimation() | |
| }, | |
| }) | |
| .staggerTo( | |
| sentence, | |
| dur, | |
| { | |
| css: { | |
| opacity: '1', | |
| }, | |
| ease: Power0.easeNone, | |
| }, | |
| 0.1 | |
| ) | |
| .staggerTo( | |
| sentence, | |
| dur2, | |
| { | |
| css: { | |
| color: '#fbf7f3', | |
| webkitTextStrokeColor: 'transparent', | |
| }, | |
| ease: Power0.easeNone, | |
| }, | |
| 0.1, | |
| 0.4 | |
| ) | |
| .staggerTo( | |
| sentence, | |
| dur, | |
| { | |
| css: { | |
| color: 'transparent', | |
| opacity: '0.15', | |
| webkitTextStrokeColor: '#fbf7f3', | |
| }, | |
| ease: Power0.easeNone, | |
| }, | |
| 0.1, | |
| delay | |
| ) | |
| }, | |
| // ANIMATION END | |
| // haven't found a better solution to wait for fonts to be loaded | |
| // using nuxt - if you have one I am happy to chat: @mario_sommer (on twitter) | |
| onFontLoaded(cb) { | |
| if (this.fontsLoaded) return | |
| if (hasClass(document.documentElement, 'wf-active')) { | |
| this.fontsLoaded = true | |
| cb() | |
| return | |
| } | |
| // try again in 100ms | |
| setTimeout(() => { | |
| this.onFontLoaded(cb) | |
| }, 100) | |
| }, | |
| isMobile() { | |
| // TODO: put it in the store | |
| return ( | |
| Math.max(document.documentElement.clientWidth, window.innerWidth || 0) < | |
| 1024 | |
| ) | |
| }, | |
| onResize() { | |
| clearTimeout(this.resizeTimer) | |
| this.resizeTimer = setTimeout(() => { | |
| if (this.loopAnimation) { | |
| this.loopAnimation.progress(0).kill() | |
| } | |
| this.words = this.setupWords() | |
| this.loop = false | |
| this.mobile = this.isMobile() | |
| this.$nextTick(function() { | |
| this.revealAnimationIntro = this.createRevealAnimationIntro() | |
| // this.revealAnimationOut = this.createDisappearAnimation() | |
| // this.revealAnimation = this.createRevealAnimation() | |
| this.revealAnimationIntro.play(0) | |
| }) | |
| }, 300) | |
| }, | |
| }, | |
| } | |
| </script> | |
| <template> | |
| <div class="wall-of-words"> | |
| <div ref="textContainer" class="overlay-words"> | |
| <SpanOrBreak | |
| v-for="word in words" | |
| ref="words" | |
| :key="word.key" | |
| :word="word" | |
| /> | |
| </div> | |
| <div class="words-for-reference"> | |
| <span | |
| v-for="refWord in text" | |
| ref="refWords" | |
| :key="refWord.id" | |
| class="overlay-type-box" | |
| ><span class="js-font-overlay__text overlay-type" | |
| >{{ refWord.word }} </span | |
| > | |
| </span> | |
| </div> | |
| </div> | |
| </template> | |
| <style lang="scss"> | |
| @import '../styles/tools.scss'; | |
| .wall-of-words { | |
| position: absolute; | |
| width: 100%; | |
| height: 100%; | |
| overflow: hidden; | |
| } | |
| // needed to calculate sizes | |
| .words-for-reference { | |
| position: absolute; | |
| top: -9999px; | |
| left: -9999px; | |
| opacity: 0; | |
| font-size: 0; | |
| } | |
| .overlay-words { | |
| position: absolute; | |
| letter-spacing: 0px; | |
| top: 50%; | |
| left: 50%; | |
| height: calc((#{vh(100)} - (#{$base-spacing / 2} * 2))); | |
| width: 400%; | |
| box-sizing: border-box; | |
| text-align: center; | |
| margin-bottom: 0; | |
| transform: translate(-50%, -50%); | |
| font-size: 0; | |
| line-height: 0; | |
| @include media('>=tablet-l') { | |
| letter-spacing: -1px; | |
| height: calc((#{vh(100)} - (#{$base-spacing} * 2))); | |
| } | |
| } | |
| .overlay-type-box { | |
| display: inline-block; | |
| overflow: hidden; | |
| height: calc((#{vh(100)} - (#{$base-spacing / 2} * 2)) / var(--lines-mobile)); | |
| line-height: calc( | |
| (#{vh(100)} - (#{$base-spacing / 2} * 2)) / var(--lines-mobile) | |
| ); | |
| @include media('<tablet-l', 'landscape') { | |
| height: calc( | |
| (#{vh(100)} - (#{$base-spacing / 2} * 2)) / var(--lines-desktop) | |
| ); | |
| line-height: calc( | |
| (#{vh(100)} - (#{$base-spacing / 2} * 2)) / var(--lines-desktop) | |
| ); | |
| } | |
| @include media('>=tablet-l') { | |
| height: calc((#{vh(100)} - (#{$base-spacing} * 2)) / var(--lines)); | |
| line-height: calc((#{vh(100)} - (#{$base-spacing} * 2)) / var(--lines)); | |
| } | |
| @include media('>=tablet-xxl') { | |
| height: calc((#{vh(100)} - (#{$base-spacing} * 2)) / var(--lines-desktop)); | |
| line-height: calc( | |
| (#{vh(100)} - (#{$base-spacing} * 2)) / var(--lines-desktop) | |
| ); | |
| } | |
| &.has-break:after { | |
| content: '\A'; | |
| white-space: pre; | |
| } | |
| } | |
| .overlay-type { | |
| // @include sec-font(); | |
| font-size: 100px; | |
| font-family: 'Montserrat', sans-serif; | |
| font-weight: 800; | |
| display: inline-block; | |
| font-size: calc( | |
| (#{vh(100)} - (#{$base-spacing / 2} * 2)) / var(--lines-mobile) | |
| ); | |
| -webkit-text-stroke-width: 1px; | |
| -webkit-text-stroke-color: #fff; | |
| color: transparent; | |
| text-transform: uppercase; | |
| opacity: var(--word-opacity, 0.12); | |
| @include media('<tablet-l', 'landscape') { | |
| font-size: calc( | |
| (#{vh(100)} - (#{$base-spacing / 2} * 2)) / var(--lines-desktop) | |
| ); | |
| } | |
| @include media('>=tablet-l') { | |
| font-size: calc((#{vh(100)} - (#{$base-spacing} * 2)) / var(--lines)); | |
| -webkit-text-stroke-width: 1.3px; | |
| } | |
| @include media('>=tablet-xxl') { | |
| font-size: calc( | |
| (#{vh(100)} - (#{$base-spacing} * 2)) / var(--lines-desktop) | |
| ); | |
| -webkit-text-stroke-width: 1.3px; | |
| } | |
| } | |
| </style> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment