Compare commits

..

2 commits

Author SHA1 Message Date
thislight
6879eb5292
TootList: add hero animation 2024-11-17 17:38:38 +08:00
thislight
1641f3e75b
StackedRouter: hero animation support 2024-11-17 17:37:42 +08:00
3 changed files with 119 additions and 26 deletions

View file

@ -24,12 +24,8 @@ dialog.StackedPage {
background: var(--tutu-color-surface); background: var(--tutu-color-surface);
box-shadow: var(--tutu-shadow-e16); box-shadow: var(--tutu-shadow-e16);
@media (min-width: 560px) { margin-left: auto;
& { margin-right: auto;
left: 50%;
transform: translateX(-50%);
}
}
@media (max-width: 560px) { @media (max-width: 560px) {
& { & {
@ -50,4 +46,12 @@ dialog.StackedPage {
&::backdrop { &::backdrop {
background: none; background: none;
} }
&.animating {
overflow: hidden;
* {
overflow: hidden;
}
}
} }

View file

@ -6,6 +6,7 @@ import {
createRenderEffect, createRenderEffect,
createUniqueId, createUniqueId,
Index, Index,
onMount,
Show, Show,
untrack, untrack,
useContext, useContext,
@ -15,9 +16,7 @@ import { createStore, unwrap } from "solid-js/store";
import "./StackedRouter.css"; import "./StackedRouter.css";
import { animateSlideInFromRight, animateSlideOutToRight } from "./anim"; import { animateSlideInFromRight, animateSlideOutToRight } from "./anim";
import { ANIM_CURVE_DECELERATION, ANIM_CURVE_STD } from "../material/theme"; import { ANIM_CURVE_DECELERATION, ANIM_CURVE_STD } from "../material/theme";
import { import { makeEventListener } from "@solid-primitives/event-listener";
makeEventListener,
} from "@solid-primitives/event-listener";
export type StackedRouterProps = Omit<RouterProps, "url">; export type StackedRouterProps = Omit<RouterProps, "url">;
@ -25,6 +24,9 @@ export type StackFrame = {
path: string; path: string;
rootId: string; rootId: string;
state: unknown; state: unknown;
animateOpen?: (element: HTMLElement) => Animation;
animateClose?: (element: HTMLElement) => Animation;
}; };
export type NewFrameOptions<T> = (T extends undefined export type NewFrameOptions<T> = (T extends undefined
@ -33,6 +35,8 @@ export type NewFrameOptions<T> = (T extends undefined
} }
: { state: T }) & { : { state: T }) & {
replace?: boolean; replace?: boolean;
animateOpen?: StackFrame["animateOpen"];
animateClose?: StackFrame["animateClose"];
}; };
export type FramePusher<T, K extends keyof T = keyof T> = T[K] extends export type FramePusher<T, K extends keyof T = keyof T> = T[K] extends
@ -117,9 +121,11 @@ function animateClose(element: HTMLElement) {
function animateOpen(element: HTMLElement) { function animateOpen(element: HTMLElement) {
if (window.innerWidth <= 560) { if (window.innerWidth <= 560) {
animateSlideInFromRight(element, { easing: ANIM_CURVE_DECELERATION }); return animateSlideInFromRight(element, {
easing: ANIM_CURVE_DECELERATION,
});
} else { } else {
element.animate( return element.animate(
{ {
opacity: [0.5, 1], opacity: [0.5, 1],
}, },
@ -128,6 +134,17 @@ function animateOpen(element: HTMLElement) {
} }
} }
function serializableStack(stack: readonly StackFrame[]) {
const frames = unwrap(stack);
return frames.map((fr) => {
return {
path: fr.path,
rootId: fr.rootId,
state: fr.state,
};
});
}
/** /**
* The router that stacks the pages. * The router that stacks the pages.
*/ */
@ -140,13 +157,15 @@ const StackedRouter: Component<StackedRouterProps> = (oprops) => {
path, path,
state: opts?.state, state: opts?.state,
rootId: createUniqueId(), rootId: createUniqueId(),
animateOpen: opts?.animateOpen,
animateClose: opts?.animateClose,
}; };
mutStack(opts?.replace ? stack.length - 1 : stack.length, frame); mutStack(opts?.replace ? stack.length - 1 : stack.length, frame);
if (opts?.replace) { if (opts?.replace) {
window.history.replaceState(unwrap(stack), "", path); window.history.replaceState(serializableStack(stack), "", path);
} else { } else {
window.history.pushState(unwrap(stack), "", path); window.history.pushState(serializableStack(stack), "", path);
} }
return frame; return frame;
}); });
@ -165,10 +184,17 @@ const StackedRouter: Component<StackedRouterProps> = (oprops) => {
} }
if (stack.length > 1) { if (stack.length > 1) {
const lastFrame = stack[stack.length - 1]; const lastFrame = stack[stack.length - 1];
const element = document.getElementById(lastFrame.rootId)!; const element = document.getElementById(
lastFrame.rootId,
)! as HTMLDialogElement;
const createAnimation = lastFrame.animateClose ?? animateClose;
requestAnimationFrame(() => { requestAnimationFrame(() => {
const animation = animateClose(element); element.classList.add("animating")
animation.addEventListener("finish", () => onlyPopFrame(depth)); const animation = createAnimation(element);
animation.addEventListener("finish", () => {
element.classList.remove("animating")
onlyPopFrame(depth);
});
}); });
} else { } else {
onlyPopFrame(depth); onlyPopFrame(depth);
@ -195,10 +221,16 @@ const StackedRouter: Component<StackedRouterProps> = (oprops) => {
}); });
const onBeforeDialogMount = (element: HTMLDialogElement) => { const onBeforeDialogMount = (element: HTMLDialogElement) => {
createEffect(() => { onMount(() => {
const lastFr = untrack(() => stack[stack.length - 1]);
const createAnimation = lastFr.animateOpen ?? animateOpen;
requestAnimationFrame(() => { requestAnimationFrame(() => {
element.showModal(); element.showModal();
animateOpen(element); element.classList.add("animating");
const animation = createAnimation(element);
animation.addEventListener("finish", () =>
element.classList.remove("animating"),
);
}); });
}); });
}; };

View file

@ -13,7 +13,6 @@ import { useDefaultSession } from "../masto/clients";
import { useHeroSource } from "../platform/anim"; import { useHeroSource } from "../platform/anim";
import { HERO as BOTTOM_SHEET_HERO } from "../material/BottomSheet"; import { HERO as BOTTOM_SHEET_HERO } from "../material/BottomSheet";
import { setCache as setTootBottomSheetCache } from "./TootBottomSheet"; import { setCache as setTootBottomSheetCache } from "./TootBottomSheet";
import { useNavigate } from "@solidjs/router";
import RegularToot, { import RegularToot, {
findElementActionable, findElementActionable,
findRootToot, findRootToot,
@ -21,6 +20,21 @@ import RegularToot, {
import cardStyle from "../material/cards.module.css"; import cardStyle from "../material/cards.module.css";
import type { ThreadNode } from "../masto/timelines"; import type { ThreadNode } from "../masto/timelines";
import { useNavigator } from "../platform/StackedRouter"; import { useNavigator } from "../platform/StackedRouter";
import { ANIM_CURVE_STD } from "../material/theme";
function durationOf(rect0: DOMRect, rect1: DOMRect) {
const distancelt = Math.sqrt(
Math.pow(Math.abs(rect0.top - rect1.top), 2) +
Math.pow(Math.abs(rect0.left - rect1.left), 2),
);
const distancerb = Math.sqrt(
Math.pow(Math.abs(rect0.bottom - rect1.bottom), 2) +
Math.pow(Math.abs(rect0.right - rect1.right), 2),
);
const distance = distancelt + distancerb;
const duration = distance / 1.6;
return duration;
}
function positionTootInThread(index: number, threadLength: number) { function positionTootInThread(index: number, threadLength: number) {
if (index === 0) { if (index === 0) {
@ -41,7 +55,7 @@ const TootList: Component<{
const session = useDefaultSession(); const session = useDefaultSession();
const heroSrc = useHeroSource(); const heroSrc = useHeroSource();
const [expandedThreadId, setExpandedThreadId] = createSignal<string>(); const [expandedThreadId, setExpandedThreadId] = createSignal<string>();
const {push} = useNavigator(); const { push } = useNavigator();
const onBookmark = async (status: mastodon.v1.Status) => { const onBookmark = async (status: mastodon.v1.Status) => {
const client = session()?.client; const client = session()?.client;
@ -100,7 +114,7 @@ const TootList: Component<{
const openFullScreenToot = ( const openFullScreenToot = (
toot: mastodon.v1.Status, toot: mastodon.v1.Status,
srcElement?: HTMLElement, srcElement: HTMLElement,
reply?: boolean, reply?: boolean,
) => { ) => {
const p = session()?.account; const p = session()?.account;
@ -116,12 +130,55 @@ const TootList: Component<{
const acct = `${inf.username}@${p.site}`; const acct = `${inf.username}@${p.site}`;
setTootBottomSheetCache(acct, toot); setTootBottomSheetCache(acct, toot);
push(`/${encodeURIComponent(acct)}/toot/${toot.id}`, { push(`/${encodeURIComponent(acct)}/toot/${toot.id}`, {
state: reply animateOpen(element) {
? { const rect0 = srcElement.getBoundingClientRect(); // the start rect
tootReply: true, const rect1 = element.getBoundingClientRect(); // the end rect
}
: undefined, const duration = durationOf(rect0, rect1);
const keyframes = {
top: [`${rect0.top}px`, `${rect1.top}px`],
bottom: [`${rect0.bottom}px`, `${rect1.bottom}px`],
left: [`${rect0.left}px`, `${rect1.left}px`],
right: [`${rect0.right}px`, `${rect1.right}px`],
height: [`${rect0.height}px`, `${rect1.height}px`],
margin: 0,
};
srcElement.style.visibility = "hidden";
const animation = element.animate(keyframes, {
duration,
easing: ANIM_CURVE_STD,
});
return animation;
},
animateClose(element) {
const rect0 = element.getBoundingClientRect(); // the start rect
const rect1 = srcElement.getBoundingClientRect(); // the end rect
const duration = durationOf(rect0, rect1);
const keyframes = {
top: [`${rect0.top}px`, `${rect1.top}px`],
bottom: [`${rect0.bottom}px`, `${rect1.bottom}px`],
left: [`${rect0.left}px`, `${rect1.left}px`],
right: [`${rect0.right}px`, `${rect1.right}px`],
height: [`${rect0.height}px`, `${rect1.height}px`],
margin: 0,
};
srcElement.style.visibility = "";
const animation = element.animate(keyframes, {
duration,
easing: ANIM_CURVE_STD,
});
return animation;
},
}); });
}; };