Files
home/Projects/pivoine.art/_js/src/push-state.js
2025-10-08 10:35:48 +02:00

213 lines
6.3 KiB
JavaScript

// Copyright (c) 2019 Florian Klampfer <https://qwtel.com/>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { fromEvent, merge, timer, zip } from 'rxjs';
import {
tap,
exhaustMap,
map,
mapTo,
mergeMap,
pairwise,
share,
startWith,
switchMap,
takeUntil,
} from 'rxjs/operators';
import { animate, empty, importTemplate, webComponentsReady } from './common';
import { CrossFader } from './cross-fader';
import { setupFLIP } from './flip';
(async () => {
await Promise.all([
...('fetch' in window ? [] : [import(/* webpackChunkName: "fetch" */ './polyfills/fetch')]),
...('customElements' in window
? []
: [import(/* webpackChunkName: "webcomponents" */ './polyfills/webcomponents')]),
...('animate' in Element.prototype ? [] : [import(/* webpackChunkName: "webanimations" */ 'web-animations-js')]),
...('IntersectionObserver' in window
? []
: [import(/* webpackChunkName: "intersection-observer" */ 'intersection-observer')]),
]);
await webComponentsReady;
const NAVBAR_SEL = '#_navbar > .content > .nav-btn-bar';
const CROSS_FADE_DURATION = 2000;
const FADE_OUT = [{ opacity: 1 }, { opacity: 0 }];
const FADE_IN = [
{ opacity: 0, transform: 'translateY(-3rem)' },
{ opacity: 1, transform: 'translateY(0)' },
];
function setupAnimationMain(pushStateEl) {
pushStateEl?.parentNode?.insertBefore(importTemplate('_animation-template'), pushStateEl);
return pushStateEl?.previousElementSibling;
}
function setupLoading(navbarEl) {
navbarEl?.appendChild(importTemplate('_loading-template'));
return navbarEl?.lastElementChild;
}
function setupErrorPage(main, url) {
const { pathname } = url;
const error = importTemplate('_error-template');
const anchor = error?.querySelector('.this-link');
if (anchor) {
anchor.href = pathname;
anchor.textContent = pathname;
}
main?.appendChild(error);
return main?.lastElementChild;
}
function getFlipType(el) {
if (el?.classList.contains('flip-title')) return 'title';
if (el?.classList.contains('flip-project')) return 'project';
return el?.getAttribute?.('data-flip');
}
const pushStateEl = document.querySelector('hy-push-state');
if (!pushStateEl) return;
const duration = Number(pushStateEl.getAttribute('duration')) || 350;
const settings = {
duration: duration,
easing: 'ease',
};
const animateFadeOut = ({ main }) => animate(main, FADE_OUT, { ...settings, fill: 'forwards' }).pipe(mapTo({ main }));
const animateFadeIn = ({ replaceEls: [main], flipType }) =>
animate(main, FADE_IN, settings).pipe(mapTo({ main, flipType }));
const drawerEl = document.querySelector('hy-drawer');
const navbarEl = document.querySelector(NAVBAR_SEL);
const animationMain = setupAnimationMain(pushStateEl);
const loadingEl = setupLoading(navbarEl);
const fromEventX = (eventName, mapFn) =>
fromEvent(pushStateEl, eventName).pipe(
map(({ detail }) => detail),
mapFn ? map(mapFn) : (_) => _,
share(),
);
const start$ = fromEventX('hy-push-state-start', (detail) =>
Object.assign(detail, { flipType: getFlipType(detail.anchor) }),
);
const ready$ = fromEventX('hy-push-state-ready');
const after$ = fromEventX('hy-push-state-after');
const progress$ = fromEventX('hy-push-state-progress');
const error$ = fromEventX('hy-push-state-networkerror');
const fadeOut$ = start$.pipe(
map((context) => Object.assign(context, { main: document.getElementById('_main') })),
tap(({ main }) => {
main.style.pointerEvents = 'none';
}),
window._noDrawer && drawerEl?.classList.contains('cover')
? tap(() => {
drawerEl.classList.remove('cover');
drawerEl.parentNode?.appendChild(drawerEl);
})
: (_) => _,
exhaustMap(animateFadeOut),
tap(({ main }) => empty.call(main)),
share(),
);
if (loadingEl) {
progress$.subscribe(() => {
loadingEl.style.display = 'flex';
});
ready$.subscribe(() => {
loadingEl.style.display = 'none';
});
}
const fadeIn$ = after$.pipe(
switchMap((context) => {
const p = animateFadeIn(context).toPromise();
context.transitionUntil(p);
return p;
}),
share(),
);
const flip$ = setupFLIP(start$, ready$, merge(fadeIn$, error$), {
animationMain,
settings: settings,
}).pipe(share());
start$
.pipe(
switchMap((context) => {
const promise = zip(timer(duration), fadeOut$, flip$).toPromise();
context.transitionUntil(promise);
return promise;
}),
)
.subscribe();
// FIXME: Keeping permanent subscription? turn into hot observable?
fadeOut$.subscribe();
flip$.subscribe();
const sidebarBg = document.querySelector('.sidebar-bg');
if (sidebarBg) {
const crossFader = new CrossFader(CROSS_FADE_DURATION);
after$
.pipe(
switchMap(({ document }) =>
zip(crossFader.fetchImage(document), fadeIn$).pipe(
map(([x]) => x),
takeUntil(start$),
),
),
startWith([sidebarBg]),
pairwise(),
mergeMap(([prev, curr]) => crossFader.fade(prev, curr)),
)
.subscribe();
}
error$
.pipe(
switchMap(({ url }) => {
if (loadingEl) loadingEl.style.display = 'none';
const main = document.getElementById('_main');
if (main) main.style.pointerEvents = '';
empty.call(animationMain?.querySelector('.page'));
empty.call(main);
setupErrorPage(main, url);
return animate(main, FADE_IN, { ...settings, fill: 'forwards' });
}),
)
.subscribe();
import(/* webpackMode: "eager" */ '@hydecorp/push-state');
window._pushState = pushStateEl;
})();