Skip to content

Commit

Permalink
extract turbo-view-transitions
Browse files Browse the repository at this point in the history
  • Loading branch information
palkan committed Oct 10, 2023
1 parent a9cdcc9 commit 0b326af
Show file tree
Hide file tree
Showing 3 changed files with 113 additions and 88 deletions.
104 changes: 16 additions & 88 deletions app/javascript/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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();
});
1 change: 1 addition & 0 deletions config/importmap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
96 changes: 96 additions & 0 deletions vendor/javascript/turbo-view-transitions.js
Original file line number Diff line number Diff line change
@@ -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);
});
}

0 comments on commit 0b326af

Please sign in to comment.