|
|
import { Controller } from "@hotwired/stimulus"
|
|
|
|
|
|
// Neither layout animates for someone who asked for less motion; the two that
|
|
|
// do part ways on the video, which the narrow screen grows into place and the
|
|
|
// wide one carries in on the screen's own slide.
|
|
|
const MOTION = "(prefers-reduced-motion: no-preference)"
|
|
|
const NARROW = "(max-width: 1023.98px)"
|
|
|
|
|
|
export default class extends Controller {
|
|
|
static targets = ["video"]
|
|
|
|
|
|
// Tapping through to the next stage hands over to the stage's own video: the
|
|
|
// copy leaves, the hero grows into the space it frees, and the video fades up
|
|
|
// behind the result. The next screen then arrives over the video's tail.
|
|
|
exit(event) {
|
|
|
if (this.leaving) return
|
|
|
if (!this.hasVideoTarget) return
|
|
|
if (!window.matchMedia(MOTION).matches) return
|
|
|
|
|
|
this.narrow = window.matchMedia(NARROW).matches
|
|
|
|
|
|
event.preventDefault()
|
|
|
this.link = event.currentTarget
|
|
|
|
|
|
if (this.narrow) {
|
|
|
const answers = this.element.querySelector(".answers-container")
|
|
|
if (answers) this.element.style.setProperty("--exit-shift", `${answers.offsetHeight}px`)
|
|
|
|
|
|
// The last video hands over to the results rather than to another stage, so
|
|
|
// the header leaves with the copy and the video takes the whole screen.
|
|
|
if (event.params.full) {
|
|
|
const header = this.element.querySelector(".stage-header-container")
|
|
|
if (header) this.element.style.setProperty("--header-shift", `${header.offsetHeight}px`)
|
|
|
this.element.classList.add("is-finishing")
|
|
|
}
|
|
|
}
|
|
|
|
|
|
this.element.classList.add("is-exiting")
|
|
|
if (!this.narrow) this.element.addEventListener("animationend", this.land)
|
|
|
|
|
|
this.videoTarget.addEventListener("timeupdate", this.checkTail)
|
|
|
this.videoTarget.addEventListener("ended", this.follow, { once: true })
|
|
|
this.videoTarget.addEventListener("error", this.follow, { once: true })
|
|
|
this.videoTarget.play().catch(this.follow)
|
|
|
}
|
|
|
|
|
|
// The screen is pushed off to the left with the video riding in beside it, so
|
|
|
// once that lands the push is undone: the video ends up square in the viewport
|
|
|
// and the screen back under it, which is the frame the next transition
|
|
|
// captures and holds still while the stage slides in over it.
|
|
|
land = (event) => {
|
|
|
if (event && event.target !== this.element) return
|
|
|
|
|
|
this.element.removeEventListener("animationend", this.land)
|
|
|
this.element.classList.replace("is-exiting", "is-playing")
|
|
|
}
|
|
|
|
|
|
// The next stage assembles itself over the tail of the video rather than
|
|
|
// after it, so the last frame is still moving while the new screen arrives.
|
|
|
checkTail = () => {
|
|
|
const { duration, currentTime } = this.videoTarget
|
|
|
if (!duration || duration === Infinity) return
|
|
|
|
|
|
if ((duration - currentTime) * 1000 <= this.#lead()) this.follow()
|
|
|
}
|
|
|
|
|
|
follow = () => {
|
|
|
if (this.leaving) return
|
|
|
|
|
|
this.leaving = true
|
|
|
this.videoTarget.removeEventListener("timeupdate", this.checkTail)
|
|
|
|
|
|
// A video shorter than the slide would otherwise be captured mid-push.
|
|
|
if (!this.narrow) this.land()
|
|
|
|
|
|
this.link?.click()
|
|
|
}
|
|
|
|
|
|
// How much of the video the incoming screen overlaps -- the same length the
|
|
|
// transition itself runs for.
|
|
|
#lead() {
|
|
|
const declared = getComputedStyle(document.documentElement).getPropertyValue("--dur-base")
|
|
|
return parseFloat(declared) * (declared.includes("ms") ? 1 : 1000) || 600
|
|
|
}
|
|
|
}
|