diff --git a/app/javascript/application.js b/app/javascript/application.js index f18ed8a..0529d46 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -3,6 +3,10 @@ import "@hotwired/turbo-rails"; import "controllers"; import Idiomorph from "idiomorph"; +import { + shouldPerformTransition, + performTransition, +} from "turbo-view-transitions"; let prevPath = window.location.pathname; @@ -26,111 +30,35 @@ const morphRender = (prevEl, newEl) => { }); }; -const withViewTransitions = async (callback) => { - await document.startViewTransition(callback).finished.then(() => { - resetTransitions(); - }); -}; - -const transitionAttr = "data-turbo-transition"; -const activeTransitionAttr = "data-turbo-transition-active"; - -const resetTransitions = (scope) => { - scope = scope || document; - - scope.querySelectorAll(`[${activeTransitionAttr}]`).forEach((el) => { - el.style.viewTransitionName = ""; - el.removeAttribute(activeTransitionAttr); - }); -}; - -// This function is responsible for picking the transition elements for the current navigation. -// The selection criteria are as follows: -// - IDs must be present in both old and new views -// - There must be unique within for the corresponding transition name (if present) -const activateTransitions = (prevEl, newEl) => { - let transitions = Array.from( - prevEl.querySelectorAll(`[${transitionAttr}]`) - ).reduce((acc, el) => { - const id = el.id || "0"; - const name = el.getAttribute(transitionAttr) || `$${id}`; - - if (!acc[name]) { - acc[name] = { ids: {}, active: false, discarded: false }; - } - - acc[name].ids[id] = el; - return acc; - }, {}); - - Array.from(newEl.querySelectorAll(`[${transitionAttr}]`)).forEach((el) => { - const id = el.id || "0"; - const name = el.getAttribute(transitionAttr) || `$${id}`; - - // If prev state has a matching element - if (transitions[name] && transitions[name].ids[id]) { - // If we already found one, we discard everything - // (since there is no way to decide which one to choose) - if (transitions[name].active) { - transitions[name].discarded = true; - return; - } - - // Otherwise, we register the new element and mark transition - // as active - transitions[name].newEl = el; - transitions[name].oldEl = transitions[name].ids[id]; - transitions[name].active = true; - } - }); - - console.log("transitions: %o", transitions); - - for (let name in transitions) { - let { newEl, oldEl, active, discarded } = transitions[name]; - - if (discarded || !active) continue; - - oldEl.style.viewTransitionName = name; - newEl.style.viewTransitionName = name; - oldEl.setAttribute(activeTransitionAttr, ""); - newEl.setAttribute(activeTransitionAttr, ""); - } -}; - document.addEventListener("turbo:before-render", (event) => { Turbo.navigator.currentVisit.scrolled = prevPath === window.location.pathname; prevPath = window.location.pathname; - if (document.startViewTransition) { + event.detail.render = async (prevEl, newEl) => { + await new Promise((resolve) => setTimeout(() => resolve(), 0)); + await morphRender(prevEl, newEl); + }; + + if (shouldPerformTransition()) { + // Make sure rendering is synchronous in this case event.detail.render = (prevEl, newEl) => { morphRender(prevEl, newEl); }; event.preventDefault(); - resetTransitions(document.body); - - activateTransitions(document.body, event.detail.newBody); - - withViewTransitions(async () => { + performTransition(document.body, event.detail.newBody, async () => { await event.detail.resume(); }); - } else { - event.detail.render = async (prevEl, newEl) => { - await new Promise((resolve) => setTimeout(() => resolve(), 0)); - await morphRender(prevEl, newEl); - }; } }); -document.addEventListener("turbo:load", () => { - if (document.head.querySelector('meta[name="view-transition"]')) - Turbo.cache.exemptPageFromCache(); -}); - document.addEventListener("turbo:before-frame-render", (event) => { event.detail.render = (prevEl, newEl) => { Idiomorph.morph(prevEl, newEl.children, { morphStyle: "innerHTML" }); }; }); + +document.addEventListener("turbo:load", () => { + if (shouldPerformTransition()) Turbo.cache.exemptPageFromCache(); +}); diff --git a/config/importmap.rb b/config/importmap.rb index 8d7e2ab..0688baf 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -10,3 +10,4 @@ pin_all_from "app/javascript/controllers", under: "controllers" pin "utils/fake_audio", to: "utils/fake_audio.js" pin "stimulus-animated-number" # @4.1.0 +pin "turbo-view-transitions" # @0.1.0 diff --git a/vendor/javascript/turbo-view-transitions.js b/vendor/javascript/turbo-view-transitions.js new file mode 100644 index 0000000..46de179 --- /dev/null +++ b/vendor/javascript/turbo-view-transitions.js @@ -0,0 +1,96 @@ +// This method checks if View Transitions API is supported and +// page has the view-transition meta tag. +// TODO: Should we check for the value of the tag (same-origin, what else?) +export function shouldPerformTransition() { + if ( + typeof document !== "undefined" && + document.head && + document.startViewTransition && + document.head.querySelector('meta[name="view-transition"]') + ) { + return true; + } + + return false; +} + +const defaultTransitionAttr = "data-turbo-transition"; +const defaultActiveTransitionAttr = "data-turbo-transition-active"; + +// This function removes view-transition-name from all previously activated elements +const resetTransitions = (scope, opts) => { + scope = scope || document; + + let { activeAttr } = opts; + + scope.querySelectorAll(`[${activeAttr}]`).forEach((el) => { + el.style.viewTransitionName = ""; + el.removeAttribute(activeAttr); + }); +}; + +// This function is responsible for picking the transition elements for the current navigation. +// The selection criteria are as follows: +// - IDs must be present in both old and new views +// - There must be unique within for the corresponding transition name (if present) +const activateTransitions = (prevEl, nextEl, opts) => { + let { transitionAttr, activeAttr } = opts; + + let transitions = Array.from( + prevEl.querySelectorAll(`[${transitionAttr}]`) + ).reduce((acc, el) => { + let id = el.id || "0"; + let name = el.getAttribute(transitionAttr) || `$${id}`; + + if (!acc[name]) { + acc[name] = { ids: {}, active: false, discarded: false }; + } + + acc[name].ids[id] = el; + return acc; + }, {}); + + Array.from(nextEl.querySelectorAll(`[${transitionAttr}]`)).forEach((el) => { + let id = el.id || "0"; + let name = el.getAttribute(transitionAttr) || `$${id}`; + + // If prev state has a matching element + if (transitions[name] && transitions[name].ids[id]) { + // If we already found one, we discard everything + // (since there is no way to decide which one to choose) + if (transitions[name].active) { + transitions[name].discarded = true; + return; + } + + // Otherwise, we register the new element and mark transition + // as active + transitions[name].newEl = el; + transitions[name].oldEl = transitions[name].ids[id]; + transitions[name].active = true; + } + }); + + for (let name in transitions) { + let { newEl, oldEl, active, discarded } = transitions[name]; + + if (discarded || !active) continue; + + oldEl.style.viewTransitionName = name; + newEl.style.viewTransitionName = name; + oldEl.setAttribute(activeAttr, ""); + newEl.setAttribute(activeAttr, ""); + } +}; + +export async function performTransition(fromEl, toEl, callback, opts = {}) { + opts.activeAttr = opts.activeAttr || defaultActiveTransitionAttr; + opts.transitionAttr = opts.transitionAttr || defaultTransitionAttr; + + resetTransitions(fromEl, opts); + activateTransitions(fromEl, toEl, opts); + + await document.startViewTransition(callback).finished.then(() => { + resetTransitions(fromEl, opts); + }); +}