BottomSheet: backward animation
This commit is contained in:
		
							parent
							
								
									0d856c61c7
								
							
						
					
					
						commit
						c461ae72f8
					
				
					 9 changed files with 150 additions and 75 deletions
				
			
		| 
						 | 
				
			
			@ -3,18 +3,6 @@ import type { mastodon } from "masto";
 | 
			
		|||
import { useSessions } from "./clients";
 | 
			
		||||
import { updateAcctInf } from "../accounts/stores";
 | 
			
		||||
 | 
			
		||||
export function useAcctProfile(client: Accessor<mastodon.rest.Client>) {
 | 
			
		||||
  return createResource(
 | 
			
		||||
    client,
 | 
			
		||||
    (client) => {
 | 
			
		||||
      return client.v1.accounts.verifyCredentials();
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      name: "MastodonAccountProfile",
 | 
			
		||||
    },
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function useSignedInProfiles() {
 | 
			
		||||
  const sessions = useSessions();
 | 
			
		||||
  const [accessor, tools] = createResource(sessions, async (all) => {
 | 
			
		||||
| 
						 | 
				
			
			@ -24,11 +12,11 @@ export function useSignedInProfiles() {
 | 
			
		|||
  });
 | 
			
		||||
  return [
 | 
			
		||||
    () => {
 | 
			
		||||
      if (accessor.loading) {
 | 
			
		||||
        accessor();
 | 
			
		||||
      const value = accessor();
 | 
			
		||||
      if (!value) {
 | 
			
		||||
        return sessions().map((x) => ({ ...x, inf: x.account.inf }));
 | 
			
		||||
      }
 | 
			
		||||
      return accessor();
 | 
			
		||||
      return value;
 | 
			
		||||
    },
 | 
			
		||||
    tools,
 | 
			
		||||
  ] as const;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,3 +1,4 @@
 | 
			
		|||
import { cache } from "@solidjs/router";
 | 
			
		||||
import type { mastodon } from "masto";
 | 
			
		||||
import { createRenderEffect, createResource, type Accessor } from "solid-js";
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -11,10 +11,14 @@
 | 
			
		|||
  border-radius: 2px;
 | 
			
		||||
  overscroll-behavior: contain;
 | 
			
		||||
 | 
			
		||||
  &::backdrop {
 | 
			
		||||
    background-color: black;
 | 
			
		||||
    opacity: 0.5;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  box-shadow: var(--tutu-shadow-e16);
 | 
			
		||||
 | 
			
		||||
  :global(.MuiToolbar-root) > :global(.MuiButtonBase-root):first-child {
 | 
			
		||||
    color: white;
 | 
			
		||||
    margin-left: -0.5em;
 | 
			
		||||
    margin-right: 24px;
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,6 +3,8 @@ import {
 | 
			
		|||
  createRenderEffect,
 | 
			
		||||
  onCleanup,
 | 
			
		||||
  onMount,
 | 
			
		||||
  startTransition,
 | 
			
		||||
  useTransition,
 | 
			
		||||
  type ParentComponent,
 | 
			
		||||
} from "solid-js";
 | 
			
		||||
import styles from "./BottomSheet.module.css";
 | 
			
		||||
| 
						 | 
				
			
			@ -14,17 +16,21 @@ export type BottomSheetProps = {
 | 
			
		|||
 | 
			
		||||
export const HERO = Symbol("BottomSheet Hero Symbol");
 | 
			
		||||
 | 
			
		||||
function composeAnimationFrame({
 | 
			
		||||
  top,
 | 
			
		||||
  left,
 | 
			
		||||
  height,
 | 
			
		||||
  width,
 | 
			
		||||
}: Record<"top" | "left" | "height" | "width", number>) {
 | 
			
		||||
function composeAnimationFrame(
 | 
			
		||||
  {
 | 
			
		||||
    top,
 | 
			
		||||
    left,
 | 
			
		||||
    height,
 | 
			
		||||
    width,
 | 
			
		||||
  }: Record<"top" | "left" | "height" | "width", number>,
 | 
			
		||||
  x: Record<string, unknown>,
 | 
			
		||||
) {
 | 
			
		||||
  return {
 | 
			
		||||
    top: `${top}px`,
 | 
			
		||||
    left: `${left}px`,
 | 
			
		||||
    height: `${height}px`,
 | 
			
		||||
    width: `${width}px`,
 | 
			
		||||
    ...x,
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -35,30 +41,50 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
 | 
			
		|||
  let animation: Animation | undefined;
 | 
			
		||||
  const hero = useHeroSignal(HERO);
 | 
			
		||||
 | 
			
		||||
  const [pending] = useTransition()
 | 
			
		||||
 | 
			
		||||
  createEffect(() => {
 | 
			
		||||
    if (props.open) {
 | 
			
		||||
      if (!element.open) {
 | 
			
		||||
        element.showModal();
 | 
			
		||||
        animateOpen();
 | 
			
		||||
      if (!element.open && !pending()) {
 | 
			
		||||
        animatedOpen();
 | 
			
		||||
      }
 | 
			
		||||
    } else {
 | 
			
		||||
      if (element.open) {
 | 
			
		||||
        if (animation) {
 | 
			
		||||
          animation.cancel();
 | 
			
		||||
        }
 | 
			
		||||
        element.close();
 | 
			
		||||
        animatedClose();
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const animateOpen = () => {
 | 
			
		||||
    // Do hero animation
 | 
			
		||||
  const animatedClose = () => {
 | 
			
		||||
    const endRect = hero();
 | 
			
		||||
    if (endRect) {
 | 
			
		||||
      const startRect = element.getBoundingClientRect();
 | 
			
		||||
      const animation = animateHero(startRect, endRect, element, true);
 | 
			
		||||
      const onClose = () => {
 | 
			
		||||
        element.close();
 | 
			
		||||
      };
 | 
			
		||||
      animation.addEventListener("finish", onClose);
 | 
			
		||||
      animation.addEventListener("cancel", onClose);
 | 
			
		||||
    } else {
 | 
			
		||||
      element.close();
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const animatedOpen = () => {
 | 
			
		||||
    element.showModal();
 | 
			
		||||
    const startRect = hero();
 | 
			
		||||
    console.debug("capture hero source", startRect);
 | 
			
		||||
    if (!startRect) return;
 | 
			
		||||
    const endRect = element.getBoundingClientRect();
 | 
			
		||||
    const easing = "ease-in-out";
 | 
			
		||||
    console.debug("easing", easing);
 | 
			
		||||
    animateHero(startRect, endRect, element);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const animateHero = (
 | 
			
		||||
    startRect: DOMRect,
 | 
			
		||||
    endRect: DOMRect,
 | 
			
		||||
    element: HTMLElement,
 | 
			
		||||
    reserve?: boolean,
 | 
			
		||||
  ) => {
 | 
			
		||||
    const easing = "cubic-bezier(0.4, 0, 0.2, 1)";
 | 
			
		||||
    element.classList.add(styles.animated);
 | 
			
		||||
    const distance = Math.sqrt(
 | 
			
		||||
      Math.pow(Math.abs(startRect.top - endRect.top), 2) +
 | 
			
		||||
| 
						 | 
				
			
			@ -66,7 +92,10 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
 | 
			
		|||
    );
 | 
			
		||||
    const duration = (distance / MOVE_SPEED) * 1000;
 | 
			
		||||
    animation = element.animate(
 | 
			
		||||
      [composeAnimationFrame(startRect), composeAnimationFrame(endRect)],
 | 
			
		||||
      [
 | 
			
		||||
        composeAnimationFrame(startRect, { opacity: reserve ? 1 : 0.5 }),
 | 
			
		||||
        composeAnimationFrame(endRect, { opacity: reserve ? 0.5 : 1 }),
 | 
			
		||||
      ],
 | 
			
		||||
      { easing, duration },
 | 
			
		||||
    );
 | 
			
		||||
    const onAnimationEnd = () => {
 | 
			
		||||
| 
						 | 
				
			
			@ -75,6 +104,7 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
 | 
			
		|||
    };
 | 
			
		||||
    animation.addEventListener("finish", onAnimationEnd);
 | 
			
		||||
    animation.addEventListener("cancel", onAnimationEnd);
 | 
			
		||||
    return animation;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  onCleanup(() => {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -12,7 +12,7 @@ export type HeroSource = {
 | 
			
		|||
  [key: string | symbol | number]: DOMRect | undefined;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const HeroSourceContext = createContext<Signal<HeroSource>>(undefined);
 | 
			
		||||
const HeroSourceContext = createContext<Signal<HeroSource>>(/* __@PURE__ */undefined);
 | 
			
		||||
 | 
			
		||||
export const HeroSourceProvider = HeroSourceContext.Provider;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -20,6 +20,9 @@ function useHeroSource() {
 | 
			
		|||
  return useContext(HeroSourceContext);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Use hero value for the {@link key}.
 | 
			
		||||
 */
 | 
			
		||||
export function useHeroSignal(
 | 
			
		||||
  key: string | symbol | number,
 | 
			
		||||
): Accessor<DOMRect | undefined> {
 | 
			
		||||
| 
						 | 
				
			
			@ -29,7 +32,6 @@ export function useHeroSignal(
 | 
			
		|||
 | 
			
		||||
    createRenderEffect(() => {
 | 
			
		||||
      const value = source[0]();
 | 
			
		||||
      console.debug("value", value);
 | 
			
		||||
      if (value[key]) {
 | 
			
		||||
        set(value[key]);
 | 
			
		||||
        source[1]((x) => {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -50,7 +50,7 @@ const Settings: ParentComponent = () => {
 | 
			
		|||
      topbar={
 | 
			
		||||
        <AppBar position="static">
 | 
			
		||||
          <Toolbar variant="dense" sx={{paddingTop: "var(--safe-area-inset-top, 0px)"}}>
 | 
			
		||||
            <IconButton onClick={[navigate, -1]}>
 | 
			
		||||
            <IconButton color="inherit" onClick={[navigate, -1]}>
 | 
			
		||||
              <CloseIcon />
 | 
			
		||||
            </IconButton>
 | 
			
		||||
            <Title>Settings</Title>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -8,9 +8,9 @@ import {
 | 
			
		|||
  onMount,
 | 
			
		||||
  type ParentComponent,
 | 
			
		||||
  children,
 | 
			
		||||
  Suspense,
 | 
			
		||||
} from "solid-js";
 | 
			
		||||
import { useDocumentTitle } from "../utils";
 | 
			
		||||
import { useSessions } from "../masto/clients";
 | 
			
		||||
import { type mastodon } from "masto";
 | 
			
		||||
import Scaffold from "../material/Scaffold";
 | 
			
		||||
import {
 | 
			
		||||
| 
						 | 
				
			
			@ -27,7 +27,6 @@ import {
 | 
			
		|||
import { css } from "solid-styled";
 | 
			
		||||
import { TimeSourceProvider, createTimeSource } from "../platform/timesrc";
 | 
			
		||||
import TootThread from "./TootThread.js";
 | 
			
		||||
import { useAcctProfile } from "../masto/acct";
 | 
			
		||||
import ProfileMenuButton from "./ProfileMenuButton";
 | 
			
		||||
import Tabs from "../material/Tabs";
 | 
			
		||||
import Tab from "../material/Tab";
 | 
			
		||||
| 
						 | 
				
			
			@ -43,6 +42,7 @@ import { vibrate } from "../platform/hardware";
 | 
			
		|||
import PullDownToRefresh from "./PullDownToRefresh";
 | 
			
		||||
import { HeroSourceProvider, type HeroSource } from "../platform/anim";
 | 
			
		||||
import { useNavigate } from "@solidjs/router";
 | 
			
		||||
import { useSignedInProfiles } from "../masto/acct";
 | 
			
		||||
 | 
			
		||||
const TimelinePanel: Component<{
 | 
			
		||||
  client: mastodon.rest.Client;
 | 
			
		||||
| 
						 | 
				
			
			@ -146,7 +146,7 @@ const TimelinePanel: Component<{
 | 
			
		|||
                onExpandChange={(x) => {
 | 
			
		||||
                  if (item.id !== expandedThreadId()) {
 | 
			
		||||
                    setExpandedThreadId(item.id);
 | 
			
		||||
                  } else if (x === 2){
 | 
			
		||||
                  } else if (x === 2) {
 | 
			
		||||
                    props.openFullScreenToot(item, element);
 | 
			
		||||
                  }
 | 
			
		||||
                }}
 | 
			
		||||
| 
						 | 
				
			
			@ -195,9 +195,10 @@ const Home: ParentComponent = (props) => {
 | 
			
		|||
  const now = createTimeSource();
 | 
			
		||||
 | 
			
		||||
  const settings$ = useStore($settings);
 | 
			
		||||
  const sessions = useSessions();
 | 
			
		||||
  const client = () => sessions()[0].client;
 | 
			
		||||
  const [profile] = useAcctProfile(client);
 | 
			
		||||
 | 
			
		||||
  const [profiles] = useSignedInProfiles();
 | 
			
		||||
  const profile = () => profiles()[0].inf;
 | 
			
		||||
  const client = () => profiles()[0].client;
 | 
			
		||||
  const navigate = useNavigate();
 | 
			
		||||
 | 
			
		||||
  const [heroSrc, setHeroSrc] = createSignal<HeroSource>({});
 | 
			
		||||
| 
						 | 
				
			
			@ -278,10 +279,10 @@ const Home: ParentComponent = (props) => {
 | 
			
		|||
    toot: mastodon.v1.Status,
 | 
			
		||||
    srcElement?: HTMLElement,
 | 
			
		||||
  ) => {
 | 
			
		||||
    const p = sessions()[0];
 | 
			
		||||
    const p = profiles()[0];
 | 
			
		||||
    const inf = p.account.inf ?? profile();
 | 
			
		||||
    if (!inf) {
 | 
			
		||||
      console.warn('no account info?')
 | 
			
		||||
      console.warn("no account info?");
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    const rect = srcElement?.getBoundingClientRect();
 | 
			
		||||
| 
						 | 
				
			
			@ -402,9 +403,11 @@ const Home: ParentComponent = (props) => {
 | 
			
		|||
            <div></div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </TimeSourceProvider>
 | 
			
		||||
        <HeroSourceProvider value={[heroSrc, setHeroSrc]}>
 | 
			
		||||
          <BottomSheet open={!!child()}>{child()}</BottomSheet>
 | 
			
		||||
        </HeroSourceProvider>
 | 
			
		||||
        <Suspense>
 | 
			
		||||
          <HeroSourceProvider value={[heroSrc, setHeroSrc]}>
 | 
			
		||||
            <BottomSheet open={!!child()}>{child()}</BottomSheet>
 | 
			
		||||
          </HeroSourceProvider>
 | 
			
		||||
        </Suspense>
 | 
			
		||||
      </Scaffold>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,9 +2,7 @@ import type { mastodon } from "masto";
 | 
			
		|||
import { type Component, For, createSignal } from "solid-js";
 | 
			
		||||
import { css } from "solid-styled";
 | 
			
		||||
import tootStyle from "./toot.module.css";
 | 
			
		||||
import { Portal } from "solid-js/web";
 | 
			
		||||
import MediaViewer, { MEDIA_VIEWER_HEROSRC } from "./MediaViewer";
 | 
			
		||||
import { HeroSourceProvider } from "../platform/anim";
 | 
			
		||||
import MediaViewer from "./MediaViewer";
 | 
			
		||||
 | 
			
		||||
const MediaAttachmentGrid: Component<{
 | 
			
		||||
  attachments: mastodon.v1.MediaAttachment[];
 | 
			
		||||
| 
						 | 
				
			
			@ -58,13 +56,6 @@ const MediaAttachmentGrid: Component<{
 | 
			
		|||
          }
 | 
			
		||||
        }}
 | 
			
		||||
      </For>
 | 
			
		||||
      <HeroSourceProvider
 | 
			
		||||
        value={() => ({
 | 
			
		||||
          [MEDIA_VIEWER_HEROSRC]: rootRef.children.item(
 | 
			
		||||
            viewerIndex() || 0,
 | 
			
		||||
          ) as HTMLElement,
 | 
			
		||||
        })}
 | 
			
		||||
      >
 | 
			
		||||
        <MediaViewer
 | 
			
		||||
          show={viewerOpened()}
 | 
			
		||||
          index={viewerIndex() || 0}
 | 
			
		||||
| 
						 | 
				
			
			@ -72,7 +63,6 @@ const MediaAttachmentGrid: Component<{
 | 
			
		|||
          media={props.attachments}
 | 
			
		||||
          onClose={() => setViewerIndex(undefined)}
 | 
			
		||||
        />
 | 
			
		||||
      </HeroSourceProvider>
 | 
			
		||||
    </section>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,23 +1,80 @@
 | 
			
		|||
import { useParams } from "@solidjs/router";
 | 
			
		||||
import type { Component } from "solid-js";
 | 
			
		||||
import { useNavigate, useParams } from "@solidjs/router";
 | 
			
		||||
import { createResource, Show, type Component } from "solid-js";
 | 
			
		||||
import Scaffold from "../material/Scaffold";
 | 
			
		||||
import TootThread from "./TootThread";
 | 
			
		||||
import { AppBar, Toolbar } from "@suid/material";
 | 
			
		||||
import { AppBar, IconButton, Toolbar } from "@suid/material";
 | 
			
		||||
import { Title } from "../material/typography";
 | 
			
		||||
import { Close as CloseIcon } from "@suid/icons-material";
 | 
			
		||||
import { isiOS } from "../platform/host";
 | 
			
		||||
import { createUnauthorizedClient, useSessions } from "../masto/clients";
 | 
			
		||||
import { resolveCustomEmoji } from "../masto/toot";
 | 
			
		||||
import RegularToot from "./RegularToot";
 | 
			
		||||
 | 
			
		||||
const TootBottomSheet: Component = (props) => {
 | 
			
		||||
  const params = useParams()
 | 
			
		||||
  return <Scaffold
 | 
			
		||||
  topbar={
 | 
			
		||||
    <AppBar position="static">
 | 
			
		||||
      <Toolbar variant="dense" sx={{paddingTop: "var(--safe-area-inset-top, 0px)"}}>
 | 
			
		||||
        <Title>A Toot</Title>
 | 
			
		||||
      </Toolbar>
 | 
			
		||||
    </AppBar>
 | 
			
		||||
  }
 | 
			
		||||
  >
 | 
			
		||||
    <p>{params.acct}/{params.id}</p>
 | 
			
		||||
  </Scaffold>;
 | 
			
		||||
  const params = useParams<{ acct: string; id: string }>();
 | 
			
		||||
  const navigate = useNavigate();
 | 
			
		||||
  const allSession = useSessions();
 | 
			
		||||
  const session = () => {
 | 
			
		||||
    const [inputUsername, inputSite] = decodeURIComponent(params.acct).split(
 | 
			
		||||
      "@",
 | 
			
		||||
      2,
 | 
			
		||||
    );
 | 
			
		||||
    const authedSession = allSession().find(
 | 
			
		||||
      (x) =>
 | 
			
		||||
        x.account.site === inputSite &&
 | 
			
		||||
        x.account.inf?.username === inputUsername,
 | 
			
		||||
    );
 | 
			
		||||
    return authedSession ?? { client: createUnauthorizedClient(inputSite) };
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const [remoteToot] = createResource(
 | 
			
		||||
    () => [session().client, params.id] as const,
 | 
			
		||||
    async ([client, id]) => {
 | 
			
		||||
      return await client.v1.statuses.$select(id).fetch();
 | 
			
		||||
    },
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const toot = remoteToot;
 | 
			
		||||
 | 
			
		||||
  const tootTitle = () => {
 | 
			
		||||
    const t = toot();
 | 
			
		||||
    if (t) {
 | 
			
		||||
      const name = resolveCustomEmoji(t.account.displayName, t.account.emojis);
 | 
			
		||||
      return `${name}'s toot`;
 | 
			
		||||
    }
 | 
			
		||||
    return "A toot";
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Scaffold
 | 
			
		||||
      topbar={
 | 
			
		||||
        <AppBar
 | 
			
		||||
          sx={{
 | 
			
		||||
            backgroundColor: "var(--tutu-color-surface)",
 | 
			
		||||
            color: "var(--tutu-color-on-surface)",
 | 
			
		||||
          }}
 | 
			
		||||
          elevation={1}
 | 
			
		||||
          position="static"
 | 
			
		||||
        >
 | 
			
		||||
          <Toolbar
 | 
			
		||||
            variant="dense"
 | 
			
		||||
            sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}
 | 
			
		||||
          >
 | 
			
		||||
            <IconButton color="inherit" onClick={[navigate, -1]} disableRipple>
 | 
			
		||||
              <CloseIcon />
 | 
			
		||||
            </IconButton>
 | 
			
		||||
            <Title>{tootTitle}</Title>
 | 
			
		||||
          </Toolbar>
 | 
			
		||||
        </AppBar>
 | 
			
		||||
      }
 | 
			
		||||
    >
 | 
			
		||||
      <div>
 | 
			
		||||
        <Show when={toot()}>
 | 
			
		||||
          <RegularToot status={toot()!}></RegularToot>
 | 
			
		||||
        </Show>
 | 
			
		||||
      </div>
 | 
			
		||||
    </Scaffold>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default TootBottomSheet;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue