1 2 3function onLoad() { 4 5 // mobile menu toggle 6 const $menuToggle = document.querySelector('.menu__toggle'); 7 if ($menuToggle != null) { 8 $menuToggle.addEventListener('click', function(e) { 9 $menuToggle.parentNode.classList.toggle('menu__toggle--enabled'); 10 }); 11 } 12 13 const $reviews = document.querySelector('.reviews'); 14 if ($reviews != null) { 15 $more = $reviews.querySelector('.read-more'); 16 $more.addEventListener('click', function(e) { 17 e.preventDefault(); 18 $reviews.classList.toggle('reviews--expanded'); 19 $more.classList.toggle('read-more--expand'); 20 $more.classList.toggle('read-more--collapse'); 21 }) 22 } 23 24 25 // fox speech bubbles 26 const $getAppElement = document.querySelector('.get-app'); 27 if ($getAppElement != null) { 28 29 const foxStatements = [ 30 {text: 'sup?', link: '/fox?vid=lick'}, 31 {text: 'me all day', link: '/fox?vid=jump'}, 32 {text: 'fraka-kaka', link: '/fox?vid=say'}, 33 {text: 'wiggle', link: '/fox?vid=wiggle'}, 34 {text: 'starring me', link: '/fox?vid=mr'}, 35 {text: 'not me', link: '/fox?vid=stuck'} 36 ]; 37 38 const $foxElement = $getAppElement.querySelector('.get-app__fox-statement'); 39 const $iconElement = $getAppElement.querySelector('.get-app__icon'); 40 41 let currentStatement = -1; 42 43 $iconElement.addEventListener('mouseenter', function() { 44 45 currentStatement = (currentStatement + 1) % foxStatements.length; 46 const statement = foxStatements[ currentStatement ]; 47 $foxElement.setAttribute('href', statement.link); 48 $foxElement.textContent = statement.text; 49 50 }); 51 } 52 53 const $starField = document.querySelector('#dark-mode-stars'); 54 if ($starField != null) { 55 const sf = StarField($starField); 56 } 57 58 const $overviewScreen = document.querySelector('.focused__grid'); 59 let overviewScreenTranslation = 0 60 if ($overviewScreen != null) { 61 scroller.watch($overviewScreen, pos => { 62 if (!pos.isVisible) { 63 return; 64 } 65 66 const translate = Math.max(0, pos.progress - 0.035) * 40 * -1; 67 68 // only update if necessary 69 if (overviewScreenTranslation === translate) { 70 return; 71 } 72 73 overviewScreenTranslation = translate 74 75 nextFrame(() => { 76 $overviewScreen.style.transform = `translateY(${translate}%)`; 77 }) 78 }) 79 } 80 81 const $overviewListScreen = document.querySelector('.focused__list'); 82 let overviewListScreenTranslation = 0 83 if ($overviewListScreen != null) { 84 scroller.watch($overviewListScreen, pos => { 85 if (!pos.isVisible) { 86 return; 87 } 88 // start a bit later, not immediately at bottom 89 const startOffset = 0.035 90 // this is a percentage 0%...-38%, depends on image/container height ratio 91 const maxTranslate = 38 92 const translate = Math.max(0, pos.progress - startOffset) * maxTranslate * -1; 93 94 // only update if necessary 95 if (overviewListScreenTranslation === translate) { 96 return; 97 } 98 99 overviewListScreenTranslation = translate 100 101 nextFrame(() => { 102 $overviewListScreen.style.transform = `translateY(${translate}%)`; 103 }) 104 }) 105 } 106 107 const $duplicatesScreen = document.querySelector('.duplicates__image'); 108 let duplicatesScreenTranslation = 0 109 if ($duplicatesScreen != null) { 110 scroller.watch($duplicatesScreen, pos => { 111 if (!pos.isVisible) { 112 return; 113 } 114 115 const translate = Math.round(pos.progress * -60 - 20); 116 117 // only update if necessary 118 if (duplicatesScreenTranslation === translate) { 119 return; 120 } 121 122 duplicatesScreenTranslation = translate 123 124 nextFrame(() => { 125 $duplicatesScreen.style.transform = `translateY(${translate}px)`; 126 $duplicatesScreen.querySelectorAll('.duplicates__check').forEach((el, idx) => { 127 el.style.display = pos.progress < idx * 0.1 + 0.2 ? "none" : "block" 128 }) 129 }) 130 }) 131 } 132 133 const $pairingScreen = document.querySelector('.pairing__image'); 134 let pairingScreenTranslation = 0 135 if ($pairingScreen != null) { 136 scroller.watch($pairingScreen, pos => { 137 if (!pos.isVisible) { 138 return; 139 } 140 141 const translate = Math.round(pos.progress * -50); 142 143 // only update if necessary 144 if (pairingScreenTranslation === translate) { 145 return; 146 } 147 148 pairingScreenTranslation = translate 149 150 nextFrame(() => { 151 $pairingScreen.style.transform = `translateY(${translate}px)`; 152 $pairingScreen.querySelectorAll('.pairing__candidate').forEach((el, idx) => { 153 el.style.display = pos.progress < idx * 0.14 + 0.25 ? "none" : "block" 154 }) 155 }) 156 }) 157 } 158 159 const $headers = document.querySelectorAll('h1:not(.title)');
160 $headers.forEach($el => { 161 162 scroller.watch($el, pos => { 163 if (!pos.isVisible) { 164 return; 165 } 166 167 const start = Math.min(Math.max(0, pos.progress * 2.8 - 0.07), 1) * -120 + 20; 168 if (start <= -100) { 169 return; // perf 170 } 171 const center = start + 100; 172 const end = start + 200; 173 174 nextFrame(() => { 175 $el.style.backgroundImage = `linear-gradient(90deg, #7C10E1 ${start}%, #0093C8 ${center}%, #C95BD1 ${end}%)` // #0251C9 176 }) 177 178 }) 179 180 }); 181 182 // Check if videos can be auto played, show controls if autoplay is disabled 183 const $videos = document.querySelectorAll('video[autoplay]'); 184 $videos.forEach($el => { 185 // wait until enough data is available such that video can be played 186 // playing before this event may throw an error 187 // repro: 188 // 1. focus Safari (16.1) location input (no inspector) 189 // 2. type site (http://app.typeface.test:8888) 190 // 3. select item from dropdown suggestions 191 // => previously called .play() => would error 192 $el.addEventListener('canplaythrough', (event) => { 193 194 // should typically autoplay by browser, wait a bit for that 195 setTimeout(() => { 196 197 if ($el.paused) { 198 // video is not playing... 199 // try to force play 200 $el.play() 201 .catch(err => { 202 // playing failed, user has probably disabled 203 console.error("Could not play media", err); 204 // show controls to let user manualy play 205 $el.setAttribute("controls", ""); 206 }) 207 } 208 209 }, 20) 210 }); 211 212 }); 213 214} 215 216 217function detectCheckout() { 218 if (window.fastspringIsReturning) { return; } 219 220 const session = getParameterByName('checkout') || getParameterByName('session') 221 if (session != null ) { 222 fastspring.builder.checkout(session); 223 return; 224 } 225 226 const purchase = getParameterByName('purchase') 227 if ( purchase != null) { 228 229 const coupon = getParameterByName('coupon'); 230 231 setTimeout(function() { 232 checkout(parseInt(purchase, 10) || 1, coupon) 233 }, 500) 234 } 235} 236 237function checkout(quantity, coupon) { 238 239 const s = { 240 'coupon': coupon || "", 241 'reset': true, // reset the cart and session (will remove everything added to the cart or passed to the session prior to this) 242 'products' : [ 243 { 244 'path': fastspringProductID, 245 'quantity': quantity 246 } 247 ], 248 'checkout': true, // once all of the above is done redirect to checkout 249 }; 250 251 fastspring.builder.push(s); 252 253} 254 255function getParameterByName(name, url) { 256 if (!url) url = window.location.href; 257 name = name.replace(/[\[\]]/g, "\\$&"); 258 var regex = new RegExp("[?&]" + name + "(=([^&#]*)|&|#|$)"), 259 results = regex.exec(url); 260 if (!results) return null; 261 if (!results[2]) return ''; 262 return decodeURIComponent(results[2].replace(/\+/g, " ")); 263} 264 265function onFSPopupClosed(order) { 266 if (order) { 267 track('Order', 'Completed'); 268 } else { 269 track('Order', 'Canceled'); 270 document.body.classList.add('has-canceled'); 271 } 272} 273 274function onFSError(code, reason) { 275 track('Error', 'FastSpring', 'FS ' + code + ' ' + reason) 276 document.body.classList.add('has-canceled'); 277} 278 279function track(category, message, details) { 280 console.log(category, message, details); 281 // ga('send', 'event', category, message, details) 282} 283 284 285const ProductQuantity = { 286 287 template: ` 288 <button class="purchase__users-decrease" @click="decrease">-</button> 289 <div class="purchase__users-count"><span class="purchase__users-quantity">{{ counter }}</span> {{ unit }}</div> 290 <button class="purchase__users-increase" @click="increase">+</button> 291 `, 292 293 data() { 294 return { 295 counter: 1, 296 } 297 }, 298 299 computed: { 300 unit() { 301 return this.counter > 1 ? 'Users' : 'User' 302 }, 303 }, 304 305 watch: { 306 counter: { 307 handler(newValue) { 308 fastspring.builder.update(fastspringProductID, newValue) 309 }, 310 // resets initial qty to 1 on launch 311 // Note that data-continuous must be true on FastSpring script tag, otherwise qty here might get out of sync with session qty 312 // e.g. open popup -> close popup -> session qty back to 0, but qty here still 1+ 313 immediate: !window.fastspringIsReturning, 314 } 315 }, 316 317 methods: { 318 decrease() { 319 this.setQuantity(this.counter - 1) 320 }, 321 increase() { 322 this.setQuantity(this.counter + 1) 323 }, 324 setQuantity(quantity) { 325 this.counter = Math.max(1, Math.min(quantity, 999)) 326 }, 327 } 328 329} 330 331 332const Search = { 333 334 template: ` 335 <form class="search__form" @submit.prevent="search" v-if="isEnabled"> 336 <div class="search__field"> 337 <input type="search" class="search__input" placeholder="Search" v-model="searchString" @keydown.esc="reset" @input="search"/> 338 <button type="reset" class="search__reset" alt="Reset search" @click="reset"><img src="/assets/img/icon-close.svg" height="14" v-if="searchString.length"/></button> 339 </div> 340 <div class="search__results">
341 <span class="search__no-results" v-if="hasNoResults">No results found for "{{ searchedString }}"</span> 342 <a :href="r.url" class="search__result article" v-for="r in results"> 343 <span class="article__title">{{ r.meta.title }}</span> 344 <span class="article__tags search__excerpt" v-html="r.excerpt"></span> 345 </a> 346 <a href="#" class="read-more" @click.prevent="loadResults" v-if="hasMoreResults">Load more results</a> 347 <hr v-if="results.length || hasNoResults"/> 348 </div> 349 </form> 350 `, 351 352 data() { 353 return { 354 searchString: "", 355 searchedString: null, 356 response: null, 357 results: [], 358 isEnabled: false, 359 } 360 }, 361 362 computed: { 363 hasMoreResults() { 364 return this.response != null && this.response.results.length > this.results.length 365 }, 366 hasNoResults() { 367 return this.response && this.response.results.length == 0 368 } 369 }, 370 371 async mounted() { 372 try { 373 this.pagefind = await import("/assets/pagefind/pagefind.js"); 374 await this.pagefind.options({ 375 baseUrl: "/", 376 bundlePath: "/assets/pagefind/", 377 }) 378 this.pagefind.init() 379 this.isEnabled = true 380 } catch { 381 // dummy 382 this.pagefind = { 383 async search() { return { results: [] } }, 384 async debouncedSearch() { return { results: [] } }, 385 } 386 } 387 }, 388 389 methods: { 390 async search() { 391 const searchTerm = this.searchString 392 if (searchTerm.length == 0) { 393 return await this.reset() 394 } 395 396 const raw = await this.pagefind.debouncedSearch(searchTerm); 397 // may be null if debounced 398 if (raw != null) { 399 this.response = raw 400 this.results = [] 401 this.searchedString = searchTerm 402 await this.loadResults() 403 } 404 }, 405 406 async loadResults() { 407 const start = this.results.length 408 const end = start + 5 409 const results = await Promise.all(this.response.results.slice(start, end).map(r => r.data())) 410 for (const r of results) { 411 r.url = r.url.replace(/(\/|\.html)$/, "") 412 // #:~: text fragments only highlight first occurence 413 } 414 this.results = this.results.concat(results) 415 }, 416 417 async reset() { 418 this.searchString = "" 419 this.searchedString = "" 420 this.response = null 421 this.results = [] 422 // cancel previous search 423 await this.pagefind.debouncedSearch("", {}, 0); 424 } 425 } 426 427} 428 429 430detectCheckout() 431 432Vue.createApp(ProductQuantity).mount('#vue-product-quantity') 433Vue.createApp(Search).mount('#vue-search') 434 435if (document.readyState === "complete" || document.readyState === "interactive") { 436 onLoad(); 437} else { 438 window.addEventListener('DOMContentLoaded', onLoad); 439} 440 441const nextFrame = window.requestAnimationFrame || 442 window.webkitRequestAnimationFrame || 443 window.mozRequestAnimationFrame || 444 window.msRequestAnimationFrame || 445 window.oRequestAnimationFrame || 446 function(cb) { window.setTimeout(cb, 1000/60) } 447 448/** 449 * Scroller 450 * @constructor 451 */ 452function Scroller() { 453 454 let subjects = [] 455 let isStarted = false 456 457 let viewportTop = 0 458 let viewportHeight = 0 459 let viewportBottom = 0 460 461 // calculate y offset of element to top of document 462 const getYOffset = function (el) { 463 let y = 0 464 while( el && !isNaN(el.offsetTop) ) { 465 y += el.offsetTop 466 el = el.offsetParent 467 } 468 return y 469 } 470 471 this.watch = function(el, callback) { 472 473 if (el == null) { 474 return 475 } 476 477 const subject = { el: el, callback: callback } 478 subjects.push(subject) 479 480 if (!isStarted) { 481 window.addEventListener('scroll', () => this.didScroll()) 482 this.updateViewport() 483 isStarted = true 484 } 485 486 // fire at least once 487 this.update(subject) 488 } 489 490 /// Remove all watchers for a particular element 491 this.removeWatchers = function(el) { 492 subjects = subjects.filter(s => s.el != el) 493 } 494 495 this.didScroll = function() { 496 // scrolling is synced with v-sync, so no need to requestAnimationFrame 497 // let callback decide if it should use requestAnimationFrame 498 // https://stackoverflow.com/questions/41740082/scroll-events-requestanimationframe-vs-requestidlecallback-vs-passive-event-lis 499 this.updateViewport()
500 subjects.forEach(subject => this.update(subject)) 501 } 502 503 this.updateViewport = function() { 504 viewportTop = window.pageYOffset 505 viewportHeight = window.innerHeight 506 viewportBottom = viewportTop + viewportHeight 507 } 508 509 this.update = function(subject) { 510 511 newPos = this.calcPosition(subject.el) 512 if (!subject.pos || newPos.progress != subject.pos.progress) { 513 subject.callback(newPos) 514 } 515 subject.pos = newPos 516 517 } 518 // calculate the position on screen of the given dom element 519 this.calcPosition = function (el) { 520 521 const elTop = getYOffset(el), 522 elHeight = el.offsetHeight, 523 elBottom = elTop + elHeight; 524 525 // 0 if element top is on viewport bottom 526 // 1 if element bottom is on viewport top 527 const positioning = ( viewportBottom - elTop ) / ( viewportHeight + elHeight ) 528 529 const fromTop = elTop - viewportTop 530 const fromBottom = viewportBottom - elBottom 531 532 return { progress: Math.max(0, Math.min( positioning, 1) ), fromTop: fromTop, fromBottom: fromBottom, isVisible: positioning >= 0 && positioning <= 1 } 533 } 534} 535 536const scroller = new Scroller() 537 538 539/** 540 * StarField 541 * @param {[type]} canvas [description] 542 * @constructor 543 */ 544function StarField(canvas) { 545 546 // https://codepen.io/iblamefish/pen/xgefG 547 const STAR_COLORS = ["#ffffff", "#ffe9c4", "#d4fbff"]; 548 const STAR_COUNT = 1000; 549 550 // generate a random integer between min..<max 551 function random (min, max) { 552 // Math.random() = 0..<1 553 return Math.floor((Math.random() * (max - min)) + min); 554 } 555 556 function Star(fieldWidth, fieldHeight, render) { 557 // distribute, also outside of field to allow translating 558 this.x = Math.random() * fieldWidth * 1.1 - 0.05 * fieldWidth; 559 this.y = Math.random() * fieldHeight * 1.1; 560 // more small stars than big, with minimum size 561 this.radius = Math.random() * Math.random() * 12 + 2; 562 this.brightness = random(75, 100) / 100; 563 this.render = render; 564 } 565 566 function createStars(w, h) { 567 568 // offscreen render a few stars 569 // which are used to draw on the main canvas 570 // this improves performance 571 // https://codepen.io/giana/pen/qbWNYy 572 let prerenders = []; 573 for (let i = 0; i < STAR_COLORS.length; i++) { 574 const pcanvas = document.createElement('canvas'); 575 const pctx = pcanvas.getContext('2d'); 576 pcanvas.width = pcanvas.height = 30; 577 const center = pcanvas.width/2; 578 579 const color = STAR_COLORS[i % STAR_COLORS.length]; 580 const gradient = pctx.createRadialGradient(center, center, 0, center, center, center); 581 // inner 582 gradient.addColorStop(0.025, '#fff'); 583 gradient.addColorStop(0.1, color + 'bb'); 584 // halo 585 gradient.addColorStop(0.2, color + '20'); 586 gradient.addColorStop(1, 'transparent'); 587 588 pctx.globalAlpha = 1; 589 pctx.fillStyle = gradient; 590 591 pctx.beginPath(); 592 pctx.arc(center, center, center, 0, Math.PI * 2); 593 pctx.fill(); 594 595 prerenders[i] = pcanvas; 596 } 597 598 // generate stars 599 let stars = []; 600 for (let i = 0; i < STAR_COUNT; i++) { 601 stars[i] = new Star(w, h, prerenders[i % prerenders.length]); 602 } 603 604 return stars 605 606 } 607 608 // render the star field 609 this.render = function() { 610 611 if (!this.pos.isVisible) { 612 // not in view, skip drawing 613 // and stop animation (wait until in view again) 614 return; 615 } 616 617 // reset transformations 618 ctx.setTransform(1, 0, 0, 1, 0, 0); 619 ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height); 620 ctx.globalCompositeOperation = 'lighter'; 621 622 // rotate around point 623 ctx.translate(300, 300); 624 ctx.rotate(- this.pos.progress / 5); 625 ctx.translate(-300, -300); 626 627 for (const star of this.stars) { 628 629 const twinkle = Math.max(0, random(0, 600) - 500) / 200; // 1 in 7 times decrease by 0...0.5 630 const alpha = star.brightness - twinkle; 631 ctx.globalAlpha = alpha; 632 633 ctx.drawImage(star.render, star.x - star.radius, star.y - star.radius, star.radius * 2, star.radius * 2); 634 } 635 636 nextFrame(() => this.render()); 637 } 638 639 // initialize 640 this.pos = null; 641 this.ctx = canvas.getContext('2d'); 642 // make canvas size same as css size 643 canvas.width = canvas.offsetWidth; 644 canvas.height = canvas.offsetHeight; 645 646 this.stars = createStars(canvas.width, canvas.height); 647 648 // wait until into view 649 scroller.watch(canvas, pos => { 650 const wasVisible = this.pos && this.pos.isVisible; 651 this.pos = pos; 652 if (pos.isVisible && !wasVisible) { 653 // canvas visible, start rendering 654 nextFrame(() => this.render()); 655 } 656 }) 657}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.