BottomSheet: first attempt for animation
This commit is contained in:
		
							parent
							
								
									7c0fac95a0
								
							
						
					
					
						commit
						db29d5dcc0
					
				
					 13 changed files with 196 additions and 20 deletions
				
			
		| 
						 | 
				
			
			@ -22,6 +22,7 @@ const AccountMastodonOAuth2Callback = lazy(
 | 
			
		|||
);
 | 
			
		||||
const TimelineHome = lazy(() => import("./timelines/Home.js"));
 | 
			
		||||
const Settings = lazy(() => import("./settings/Settings.js"));
 | 
			
		||||
const TootBottomSheet = lazy(() => import("./timelines/TootBottomSheet.js"));
 | 
			
		||||
 | 
			
		||||
const Routing: Component = () => {
 | 
			
		||||
  return (
 | 
			
		||||
| 
						 | 
				
			
			@ -29,6 +30,7 @@ const Routing: Component = () => {
 | 
			
		|||
      <Route path="/" component={TimelineHome}>
 | 
			
		||||
        <Route path=""></Route>
 | 
			
		||||
        <Route path="/settings" component={Settings}></Route>
 | 
			
		||||
        <Route path="/:acct/:id" component={TootBottomSheet}></Route>
 | 
			
		||||
      </Route>
 | 
			
		||||
      <Route path={"/accounts"}>
 | 
			
		||||
        <Route path={"/sign-in"} component={AccountSignIn} />
 | 
			
		||||
| 
						 | 
				
			
			@ -53,7 +55,7 @@ const App: Component = () => {
 | 
			
		|||
    );
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
const UnexpectedError = lazy(() => import("./UnexpectedError.js"))
 | 
			
		||||
  const UnexpectedError = lazy(() => import("./UnexpectedError.js"));
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <ErrorBoundary
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -30,4 +30,9 @@
 | 
			
		|||
      max-height: 100%;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &.animated {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    transform: none;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,25 +1,88 @@
 | 
			
		|||
import { createEffect, type ParentComponent } from "solid-js";
 | 
			
		||||
import {
 | 
			
		||||
  createEffect,
 | 
			
		||||
  createRenderEffect,
 | 
			
		||||
  onCleanup,
 | 
			
		||||
  onMount,
 | 
			
		||||
  type ParentComponent,
 | 
			
		||||
} from "solid-js";
 | 
			
		||||
import styles from "./BottomSheet.module.css";
 | 
			
		||||
import { useHeroSignal } from "../platform/anim";
 | 
			
		||||
 | 
			
		||||
export type BottomSheetProps = {
 | 
			
		||||
  open?: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const HERO = Symbol("BottomSheet Hero Symbol");
 | 
			
		||||
 | 
			
		||||
function composeAnimationFrame({
 | 
			
		||||
  top,
 | 
			
		||||
  left,
 | 
			
		||||
  height,
 | 
			
		||||
  width,
 | 
			
		||||
}: Record<"top" | "left" | "height" | "width", number>) {
 | 
			
		||||
  return {
 | 
			
		||||
    top: `${top}px`,
 | 
			
		||||
    left: `${left}px`,
 | 
			
		||||
    height: `${height}px`,
 | 
			
		||||
    width: `${width}px`,
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const MOVE_SPEED = 1400; // 1400px/s, bottom sheet is big and a bit heavier than small papers
 | 
			
		||||
 | 
			
		||||
const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
 | 
			
		||||
  let element: HTMLDialogElement;
 | 
			
		||||
  let animation: Animation | undefined;
 | 
			
		||||
  const hero = useHeroSignal(HERO);
 | 
			
		||||
 | 
			
		||||
  createEffect(() => {
 | 
			
		||||
    if (props.open) {
 | 
			
		||||
      if (!element.open) {
 | 
			
		||||
        element.showModal();
 | 
			
		||||
        animateOpen();
 | 
			
		||||
      }
 | 
			
		||||
    } else {
 | 
			
		||||
      if (element.open) {
 | 
			
		||||
        if (animation) {
 | 
			
		||||
          animation.cancel();
 | 
			
		||||
        }
 | 
			
		||||
        element.close();
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const animateOpen = () => {
 | 
			
		||||
    // Do hero animation
 | 
			
		||||
    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);
 | 
			
		||||
    element.classList.add(styles.animated);
 | 
			
		||||
    const distance = Math.sqrt(
 | 
			
		||||
      Math.pow(Math.abs(startRect.top - endRect.top), 2) +
 | 
			
		||||
        Math.pow(Math.abs(startRect.left - startRect.top), 2),
 | 
			
		||||
    );
 | 
			
		||||
    const duration = (distance / MOVE_SPEED) * 1000;
 | 
			
		||||
    animation = element.animate(
 | 
			
		||||
      [composeAnimationFrame(startRect), composeAnimationFrame(endRect)],
 | 
			
		||||
      { easing, duration },
 | 
			
		||||
    );
 | 
			
		||||
    const onAnimationEnd = () => {
 | 
			
		||||
      element.classList.remove(styles.animated);
 | 
			
		||||
      animation = undefined;
 | 
			
		||||
    };
 | 
			
		||||
    animation.addEventListener("finish", onAnimationEnd);
 | 
			
		||||
    animation.addEventListener("cancel", onAnimationEnd);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  onCleanup(() => {
 | 
			
		||||
    if (animation) {
 | 
			
		||||
      animation.cancel();
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <dialog class={styles.bottomSheet} ref={element!}>
 | 
			
		||||
      {props.children}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,13 +1,47 @@
 | 
			
		|||
import { createContext, useContext, type Accessor } from "solid-js";
 | 
			
		||||
import {
 | 
			
		||||
  createContext,
 | 
			
		||||
  createRenderEffect,
 | 
			
		||||
  createSignal,
 | 
			
		||||
  untrack,
 | 
			
		||||
  useContext,
 | 
			
		||||
  type Accessor,
 | 
			
		||||
  type Signal,
 | 
			
		||||
} from "solid-js";
 | 
			
		||||
 | 
			
		||||
export type HeroSource = {
 | 
			
		||||
  [key: string | symbol | number]: HTMLElement | undefined;
 | 
			
		||||
  [key: string | symbol | number]: DOMRect | undefined;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const HeroSourceContext = createContext<Accessor<HeroSource>>(() => ({}));
 | 
			
		||||
const HeroSourceContext = createContext<Signal<HeroSource>>(undefined);
 | 
			
		||||
 | 
			
		||||
export const HeroSourceProvider = HeroSourceContext.Provider;
 | 
			
		||||
 | 
			
		||||
export function useHeroSource() {
 | 
			
		||||
function useHeroSource() {
 | 
			
		||||
  return useContext(HeroSourceContext);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function useHeroSignal(
 | 
			
		||||
  key: string | symbol | number,
 | 
			
		||||
): Accessor<DOMRect | undefined> {
 | 
			
		||||
  const source = useHeroSource();
 | 
			
		||||
  if (source) {
 | 
			
		||||
    const [get, set] = createSignal<DOMRect>();
 | 
			
		||||
 | 
			
		||||
    createRenderEffect(() => {
 | 
			
		||||
      const value = source[0]();
 | 
			
		||||
      console.debug("value", value);
 | 
			
		||||
      if (value[key]) {
 | 
			
		||||
        set(value[key]);
 | 
			
		||||
        source[1]((x) => {
 | 
			
		||||
          const cpy = Object.assign({}, x);
 | 
			
		||||
          delete cpy[key];
 | 
			
		||||
          return cpy;
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    return get;
 | 
			
		||||
  } else {
 | 
			
		||||
    return () => undefined;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										12
									
								
								src/platform/host.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/platform/host.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,12 @@
 | 
			
		|||
export function isiOS() {
 | 
			
		||||
  return [
 | 
			
		||||
    'iPad Simulator',
 | 
			
		||||
    'iPhone Simulator',
 | 
			
		||||
    'iPod Simulator',
 | 
			
		||||
    'iPad',
 | 
			
		||||
    'iPhone',
 | 
			
		||||
    'iPod'
 | 
			
		||||
  ].includes(navigator.platform)
 | 
			
		||||
  // iPad on iOS 13 detection
 | 
			
		||||
  || (navigator.userAgent.includes("Mac") && "ontouchend" in document)
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										8
									
								
								src/platform/polyfills.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								src/platform/polyfills.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,8 @@
 | 
			
		|||
//! This module has side effect.
 | 
			
		||||
//! It recommended to include the module by <script> tag.
 | 
			
		||||
if (!document.body.animate) {
 | 
			
		||||
  // @ts-ignore: this file is polyfill, no exposed decls
 | 
			
		||||
  import("web-animations-js").then(() => {
 | 
			
		||||
    console.warn("web animation polyfill is included");
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -34,16 +34,25 @@ import Tab from "../material/Tab";
 | 
			
		|||
import { Create as CreateTootIcon } from "@suid/icons-material";
 | 
			
		||||
import { useTimeline } from "../masto/timelines";
 | 
			
		||||
import { makeEventListener } from "@solid-primitives/event-listener";
 | 
			
		||||
import BottomSheet from "../material/BottomSheet";
 | 
			
		||||
import BottomSheet, {
 | 
			
		||||
  HERO as BOTTOM_SHEET_HERO,
 | 
			
		||||
} from "../material/BottomSheet";
 | 
			
		||||
import { $settings } from "../settings/stores";
 | 
			
		||||
import { useStore } from "@nanostores/solid";
 | 
			
		||||
import { vibrate } from "../platform/hardware";
 | 
			
		||||
import PullDownToRefresh from "./PullDownToRefresh";
 | 
			
		||||
import { HeroSourceProvider, type HeroSource } from "../platform/anim";
 | 
			
		||||
import { useNavigate } from "@solidjs/router";
 | 
			
		||||
 | 
			
		||||
const TimelinePanel: Component<{
 | 
			
		||||
  client: mastodon.rest.Client;
 | 
			
		||||
  name: "home" | "public" | "trends";
 | 
			
		||||
  prefetch?: boolean;
 | 
			
		||||
 | 
			
		||||
  openFullScreenToot: (
 | 
			
		||||
    toot: mastodon.v1.Status,
 | 
			
		||||
    srcElement?: HTMLElement,
 | 
			
		||||
  ) => void;
 | 
			
		||||
}> = (props) => {
 | 
			
		||||
  const [scrollLinked, setScrollLinked] = createSignal<HTMLElement>();
 | 
			
		||||
  const [
 | 
			
		||||
| 
						 | 
				
			
			@ -125,18 +134,22 @@ const TimelinePanel: Component<{
 | 
			
		|||
      >
 | 
			
		||||
        <For each={timeline}>
 | 
			
		||||
          {(item, index) => {
 | 
			
		||||
            let element: HTMLElement | undefined;
 | 
			
		||||
            return (
 | 
			
		||||
              <TootThread
 | 
			
		||||
                ref={element}
 | 
			
		||||
                status={item}
 | 
			
		||||
                onBoost={(...args) => onBoost(index(), ...args)}
 | 
			
		||||
                onBookmark={(...args) => onBookmark(index(), ...args)}
 | 
			
		||||
                client={props.client}
 | 
			
		||||
                expanded={item.id === expandedThreadId() ? 1 : 0}
 | 
			
		||||
                onExpandChange={() =>
 | 
			
		||||
                  setExpandedThreadId(
 | 
			
		||||
                    item.id !== expandedThreadId() ? item.id : undefined,
 | 
			
		||||
                  )
 | 
			
		||||
                }
 | 
			
		||||
                onExpandChange={(x) => {
 | 
			
		||||
                  if (item.id !== expandedThreadId()) {
 | 
			
		||||
                    setExpandedThreadId(item.id);
 | 
			
		||||
                  } else if (x === 2){
 | 
			
		||||
                    props.openFullScreenToot(item, element);
 | 
			
		||||
                  }
 | 
			
		||||
                }}
 | 
			
		||||
              />
 | 
			
		||||
            );
 | 
			
		||||
          }}
 | 
			
		||||
| 
						 | 
				
			
			@ -185,7 +198,9 @@ const Home: ParentComponent = (props) => {
 | 
			
		|||
  const sessions = useSessions();
 | 
			
		||||
  const client = () => sessions()[0].client;
 | 
			
		||||
  const [profile] = useAcctProfile(client);
 | 
			
		||||
  const navigate = useNavigate();
 | 
			
		||||
 | 
			
		||||
  const [heroSrc, setHeroSrc] = createSignal<HeroSource>({});
 | 
			
		||||
  const [panelOffset, setPanelOffset] = createSignal(0);
 | 
			
		||||
  const prefetching = () => !settings$().prefetchTootsDisabled;
 | 
			
		||||
  const [currentFocusOn, setCurrentFocusOn] = createSignal<HTMLElement[]>([]);
 | 
			
		||||
| 
						 | 
				
			
			@ -259,6 +274,22 @@ const Home: ParentComponent = (props) => {
 | 
			
		|||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const openFullScreenToot = (
 | 
			
		||||
    toot: mastodon.v1.Status,
 | 
			
		||||
    srcElement?: HTMLElement,
 | 
			
		||||
  ) => {
 | 
			
		||||
    const p = sessions()[0];
 | 
			
		||||
    const inf = p.account.inf ?? profile();
 | 
			
		||||
    if (!inf) {
 | 
			
		||||
      console.warn('no account info?')
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    const rect = srcElement?.getBoundingClientRect();
 | 
			
		||||
    setHeroSrc((x) => Object.assign({}, x, { [BOTTOM_SHEET_HERO]: rect }));
 | 
			
		||||
    const acct = `${inf.username}@${p.account.site}`;
 | 
			
		||||
    navigate(`/${encodeURIComponent(acct)}/${toot.id}`);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  css`
 | 
			
		||||
    .tab-panel {
 | 
			
		||||
      overflow: visible auto;
 | 
			
		||||
| 
						 | 
				
			
			@ -344,6 +375,7 @@ const Home: ParentComponent = (props) => {
 | 
			
		|||
                  client={client()}
 | 
			
		||||
                  name="home"
 | 
			
		||||
                  prefetch={prefetching()}
 | 
			
		||||
                  openFullScreenToot={openFullScreenToot}
 | 
			
		||||
                />
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
| 
						 | 
				
			
			@ -353,6 +385,7 @@ const Home: ParentComponent = (props) => {
 | 
			
		|||
                  client={client()}
 | 
			
		||||
                  name="trends"
 | 
			
		||||
                  prefetch={prefetching()}
 | 
			
		||||
                  openFullScreenToot={openFullScreenToot}
 | 
			
		||||
                />
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
| 
						 | 
				
			
			@ -362,13 +395,16 @@ const Home: ParentComponent = (props) => {
 | 
			
		|||
                  client={client()}
 | 
			
		||||
                  name="public"
 | 
			
		||||
                  prefetch={prefetching()}
 | 
			
		||||
                  openFullScreenToot={openFullScreenToot}
 | 
			
		||||
                />
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div></div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </TimeSourceProvider>
 | 
			
		||||
        <BottomSheet open={!!child()}>{child()}</BottomSheet>
 | 
			
		||||
        <HeroSourceProvider value={[heroSrc, setHeroSrc]}>
 | 
			
		||||
          <BottomSheet open={!!child()}>{child()}</BottomSheet>
 | 
			
		||||
        </HeroSourceProvider>
 | 
			
		||||
      </Scaffold>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -15,8 +15,6 @@ import {
 | 
			
		|||
  untrack,
 | 
			
		||||
} from "solid-js";
 | 
			
		||||
import { css } from "solid-styled";
 | 
			
		||||
import { useHeroSource } from "../platform/anim";
 | 
			
		||||
import { Portal } from "solid-js/web";
 | 
			
		||||
import { createStore } from "solid-js/store";
 | 
			
		||||
import { IconButton, Toolbar } from "@suid/material";
 | 
			
		||||
import { ArrowLeft, ArrowRight, Close } from "@suid/icons-material";
 | 
			
		||||
| 
						 | 
				
			
			@ -42,8 +40,6 @@ function clamp(input: number, min: number, max: number) {
 | 
			
		|||
const MediaViewer: ParentComponent<MediaViewerProps> = (props) => {
 | 
			
		||||
  let rootRef: HTMLDialogElement;
 | 
			
		||||
 | 
			
		||||
  const heroSource = useHeroSource();
 | 
			
		||||
  const heroSourceEl = () => heroSource()[MEDIA_VIEWER_HEROSRC];
 | 
			
		||||
  type State = {
 | 
			
		||||
    ref?: HTMLElement;
 | 
			
		||||
    media: mastodon.v1.MediaAttachment;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,7 +1,23 @@
 | 
			
		|||
import { useParams } from "@solidjs/router";
 | 
			
		||||
import type { Component } from "solid-js";
 | 
			
		||||
import Scaffold from "../material/Scaffold";
 | 
			
		||||
import TootThread from "./TootThread";
 | 
			
		||||
import { AppBar, Toolbar } from "@suid/material";
 | 
			
		||||
import { Title } from "../material/typography";
 | 
			
		||||
 | 
			
		||||
const TootBottomSheet: Component = (props) => {
 | 
			
		||||
  return <></>;
 | 
			
		||||
  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>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default TootBottomSheet;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,5 @@
 | 
			
		|||
import type { mastodon } from "masto";
 | 
			
		||||
import { Show, createResource, createSignal, type Component } from "solid-js";
 | 
			
		||||
import { Show, createResource, createSignal, type Component, type Ref } from "solid-js";
 | 
			
		||||
import CompactToot from "./CompactToot";
 | 
			
		||||
import { useTimeSource } from "../platform/timesrc";
 | 
			
		||||
import RegularToot from "./RegularToot";
 | 
			
		||||
| 
						 | 
				
			
			@ -7,6 +7,7 @@ import cardStyle from "../material/cards.module.css";
 | 
			
		|||
import { css } from "solid-styled";
 | 
			
		||||
 | 
			
		||||
type TootThreadProps = {
 | 
			
		||||
  ref?: Ref<HTMLElement>,
 | 
			
		||||
  status: mastodon.v1.Status;
 | 
			
		||||
  client: mastodon.rest.Client;
 | 
			
		||||
  expanded?: 0 | 1 | 2;
 | 
			
		||||
| 
						 | 
				
			
			@ -70,6 +71,7 @@ const TootThread: Component<TootThreadProps> = (props) => {
 | 
			
		|||
 | 
			
		||||
  return (
 | 
			
		||||
    <article
 | 
			
		||||
      ref={props.ref}
 | 
			
		||||
      classList={{ "thread-line": !!inReplyTo(), expanded: expanded() > 0 }}
 | 
			
		||||
      onClick={() => props.onExpandChange?.(nextExpandLevel[expanded()])}
 | 
			
		||||
    >
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue