From 0710aaf4f30a6e95ac63a814fae73d8867bd39d6 Mon Sep 17 00:00:00 2001 From: thislight Date: Sat, 16 Nov 2024 20:04:55 +0800 Subject: [PATCH 01/18] first prototype of StackedRouter --- src/App.tsx | 30 ++-- src/accounts/MastodonOAuth2Callback.tsx | 5 +- src/masto/clients.ts | 7 +- src/material/BottomSheet.css | 16 -- src/material/Scaffold.css | 28 ++- src/material/Scaffold.tsx | 48 ++--- src/platform/A.tsx | 19 ++ src/platform/BackButton.tsx | 24 +++ src/platform/StackedRouter.css | 52 ++++++ src/platform/StackedRouter.tsx | 228 ++++++++++++++++++++++++ src/profiles/Profile.css | 2 + src/profiles/Profile.tsx | 11 +- src/settings/Language.tsx | 19 +- src/settings/Motions.tsx | 6 +- src/settings/Region.tsx | 6 +- src/settings/Settings.tsx | 9 +- src/timelines/Home.tsx | 8 +- src/timelines/ProfileMenuButton.tsx | 2 +- src/timelines/TootBottomSheet.css | 3 +- src/timelines/TootBottomSheet.tsx | 21 +-- src/timelines/TootList.tsx | 7 +- 21 files changed, 442 insertions(+), 109 deletions(-) create mode 100644 src/platform/A.tsx create mode 100644 src/platform/BackButton.tsx create mode 100644 src/platform/StackedRouter.css create mode 100644 src/platform/StackedRouter.tsx diff --git a/src/App.tsx b/src/App.tsx index abdff31..5fdb0c8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,6 +27,7 @@ import { import { Service } from "./serviceworker/services.js"; import { makeEventListener } from "@solid-primitives/event-listener"; import { ServiceWorkerProvider } from "./platform/host.js"; +import StackedRouter from "./platform/StackedRouter.js"; const AccountSignIn = lazy(() => import("./accounts/SignIn.js")); const AccountMastodonOAuth2Callback = lazy( @@ -37,24 +38,21 @@ const Settings = lazy(() => import("./settings/Settings.js")); const TootBottomSheet = lazy(() => import("./timelines/TootBottomSheet.js")); const MotionSettings = lazy(() => import("./settings/Motions.js")); const LanguageSettings = lazy(() => import("./settings/Language.js")); -const RegionSettings = lazy(() => import("./settings/Region.jsx")); +const RegionSettings = lazy(() => import("./settings/Region.js")); const UnexpectedError = lazy(() => import("./UnexpectedError.js")); const Profile = lazy(() => import("./profiles/Profile.js")); const Routing: Component = () => { return ( - - - - - - - - - - - - + + + + + + + + + { component={AccountMastodonOAuth2Callback} /> - + ); }; @@ -70,7 +68,9 @@ const App: Component = () => { const theme = useRootTheme(); const accts = useStore($accounts); const lang = useLanguage(); - const [serviceWorker, setServiceWorker] = createSignal(); + const [serviceWorker, setServiceWorker] = createSignal< + ServiceWorker | undefined + >(undefined, { name: "serviceWorker" }); const dispatcher = new ResultDispatcher(); let checkAge = 0; diff --git a/src/accounts/MastodonOAuth2Callback.tsx b/src/accounts/MastodonOAuth2Callback.tsx index 249c58f..0714984 100644 --- a/src/accounts/MastodonOAuth2Callback.tsx +++ b/src/accounts/MastodonOAuth2Callback.tsx @@ -1,4 +1,4 @@ -import { useNavigate, useSearchParams } from "@solidjs/router"; +import { useSearchParams } from "@solidjs/router"; import { Component, Show, @@ -14,6 +14,7 @@ import { LinearProgress } from "@suid/material"; import Img from "../material/Img"; import { createRestAPIClient } from "masto"; import { Title } from "../material/typography"; +import { useNavigator } from "../platform/StackedRouter"; type OAuth2CallbackParams = { code?: string; @@ -25,7 +26,7 @@ const MastodonOAuth2Callback: Component = () => { const progressId = createUniqueId(); const titleId = createUniqueId(); const [params] = useSearchParams(); - const navigate = useNavigate(); + const { push: navigate } = useNavigator(); const setDocumentTitle = useDocumentTitle("Back from Mastodon..."); const [siteImg, setSiteImg] = createSignal<{ src: string; diff --git a/src/masto/clients.ts b/src/masto/clients.ts index 95c9b0f..d11c7c9 100644 --- a/src/masto/clients.ts +++ b/src/masto/clients.ts @@ -8,7 +8,8 @@ import { } from "solid-js"; import { Account } from "../accounts/stores"; import { createRestAPIClient, mastodon } from "masto"; -import { useLocation, useNavigate } from "@solidjs/router"; +import { useLocation } from "@solidjs/router"; +import { useNavigator } from "../platform/StackedRouter"; const restfulCache: Record = {}; @@ -56,12 +57,12 @@ export const Provider = Context.Provider; export function useSessions() { const sessions = useSessionsRaw(); - const navigate = useNavigate(); + const {push} = useNavigator(); const location = useLocation(); createRenderEffect(() => { if (sessions().length > 0) return; - navigate( + push( "/accounts/sign-in?back=" + encodeURIComponent(location.pathname), { replace: true }, ); diff --git a/src/material/BottomSheet.css b/src/material/BottomSheet.css index c0f2803..049ca32 100644 --- a/src/material/BottomSheet.css +++ b/src/material/BottomSheet.css @@ -25,22 +25,6 @@ box-shadow: var(--tutu-shadow-e16); - .MuiToolbar-root { - >.MuiButtonBase-root { - - &:first-child { - margin-left: -0.5em; - margin-right: 24px; - } - - &:last-child { - margin-right: -0.5em; - margin-left: 24px; - } - - } - } - @media (max-width: 560px) { & { left: 0; diff --git a/src/material/Scaffold.css b/src/material/Scaffold.css index fdcf2bd..56b2cd0 100644 --- a/src/material/Scaffold.css +++ b/src/material/Scaffold.css @@ -1,22 +1,42 @@ - -.Scaffold__topbar { +.Scaffold>.topbar { position: sticky; top: 0px; z-index: var(--tutu-zidx-nav, auto); + + .MuiToolbar-root { + >.MuiButtonBase-root { + &:first-child { + margin-left: -0.5em; + margin-right: 24px; + } + + &:last-child { + margin-right: -0.5em; + margin-left: 24px; + } + + } + } } -.Scaffold__fab-dock { +.Scaffold>.fab-dock { position: fixed; bottom: 40px; right: 40px; z-index: var(--tutu-zidx-nav, auto); } -.Scaffold__bottom-dock { +.Scaffold>.bottom-dock { position: sticky; bottom: 0; left: 0; right: 0; z-index: var(--tutu-zidx-nav, auto); padding-bottom: var(--safe-area-inset-bottom, 0); +} + +.Scaffold { + height: 100%; + width: 100%; + background-color: var(--tutu-color-surface); } \ No newline at end of file diff --git a/src/material/Scaffold.tsx b/src/material/Scaffold.tsx index 693793f..4e905c3 100644 --- a/src/material/Scaffold.tsx +++ b/src/material/Scaffold.tsx @@ -28,42 +28,48 @@ const Scaffold: Component = (props) => { "bottom", "children", "ref", + "class", ]); const [topbarElement, setTopbarElement] = createSignal(); const topbarSize = createElementSize(topbarElement); return ( - <> +
{ + createRenderEffect(() => { + e.style.setProperty( + "--scaffold-topbar-height", + (topbarSize.height?.toString() ?? 0) + "px", + ); + }); + + if (managed.ref) { + (managed.ref as (val: typeof e) => void)(e); + } + }} + {...rest} + > - ); }; diff --git a/src/platform/A.tsx b/src/platform/A.tsx new file mode 100644 index 0000000..b655dc9 --- /dev/null +++ b/src/platform/A.tsx @@ -0,0 +1,19 @@ +import { type JSX } from "solid-js"; +import { useNavigator } from "./StackedRouter"; + +function handleClick( + push: (name: string, state: unknown) => void, + event: MouseEvent & { currentTarget: HTMLAnchorElement }, +) { + const target = event.currentTarget; + event.preventDefault(); + event.stopPropagation(); + push(target.href, { state: target.getAttribute("state") || undefined }); +} + +const A = (oprops: JSX.HTMLElementTags["a"]) => { + const { push } = useNavigator(); + return ; +}; + +export default A; diff --git a/src/platform/BackButton.tsx b/src/platform/BackButton.tsx new file mode 100644 index 0000000..0c59876 --- /dev/null +++ b/src/platform/BackButton.tsx @@ -0,0 +1,24 @@ +import type { IconButtonProps } from "@suid/material/IconButton"; +import IconButton from "@suid/material/IconButton"; +import { Show, type Component } from "solid-js"; +import { useCurrentFrame, useNavigator } from "./StackedRouter"; +import { ArrowBack, Close } from "@suid/icons-material"; + +export type BackButtonProps = Omit; + +const BackButton: Component = (props) => { + const currentFrame = useCurrentFrame(); + const { pop } = useNavigator(); + + const hasPrevSubPage = () => currentFrame().index > 1; + + return ( + + }> + + + + ); +}; + +export default BackButton; diff --git a/src/platform/StackedRouter.css b/src/platform/StackedRouter.css new file mode 100644 index 0000000..714893c --- /dev/null +++ b/src/platform/StackedRouter.css @@ -0,0 +1,52 @@ +.StackedPage { + container: StackedPage / size; + display: contents; + max-width: 100vw; + max-width: 100dvw; +} + +dialog.StackedPage { + border: none; + position: fixed; + padding: 0; + overscroll-behavior: none; + width: 560px; + max-height: 100vh; + max-height: 100dvh; + background: none; + display: none; + + contain: strict; + contain-intrinsic-size: auto 560px auto 100vh; + contain-intrinsic-size: auto 560px auto 100dvh; + content-visibility: auto; + + box-shadow: var(--tutu-shadow-e16); + + @media (min-width: 560px) { + & { + left: 50%; + transform: translateX(-50%); + } + } + + @media (max-width: 560px) { + & { + width: 100vw; + width: 100dvw; + height: 100vh; + height: 100dvh; + contain-intrinsic-size: 100vw 100vh; + contain-intrinsic-size: 100dvw 100dvh; + } + + } + + &[open] { + display: contents; + } + + &::backdrop { + background: none; + } +} \ No newline at end of file diff --git a/src/platform/StackedRouter.tsx b/src/platform/StackedRouter.tsx new file mode 100644 index 0000000..8d489ae --- /dev/null +++ b/src/platform/StackedRouter.tsx @@ -0,0 +1,228 @@ +import { StaticRouter, type RouterProps } from "@solidjs/router"; +import { + Component, + createContext, + createEffect, + createRenderEffect, + createUniqueId, + Index, + onMount, + Show, + untrack, + useContext, + type Accessor, +} from "solid-js"; +import { createStore, unwrap } from "solid-js/store"; +import { insert, render } from "solid-js/web"; +import "./StackedRouter.css"; +import { animateSlideInFromRight } from "./anim"; +import { ANIM_CURVE_DECELERATION, ANIM_CURVE_STD } from "../material/theme"; + +export type StackedRouterProps = Omit; + +export type StackFrame = { + path: string; + rootId: string; + state: unknown; + beforeShow?: (element: HTMLElement) => void; +}; + +export type NewFrameOptions = (T extends undefined + ? { + state?: T; + } + : { state: T }) & { + replace?: boolean; +}; + +export type FramePusher = T[K] extends + | undefined + | any + ? (path: K, state?: Readonly>) => Readonly + : (path: K, state: Readonly>) => Readonly; + +export type Navigator> = { + frames: readonly StackFrame[]; + push: FramePusher; + pop: (depth?: number) => void; +}; + +const NavigatorContext = /* @__PURE__ */ createContext(); + +/** + * Get the navigator of the {@link StackedRouter}. + * + * This function returns a {@link Navigator} without available + * push guide. Push guide is a record type contains available + * path and its state. If you need push guide, you may want to + * define your own function (like `useAppNavigator`) and cast the + * navigator to the type you need. + */ +export function useNavigator() { + const navigator = useContext(NavigatorContext); + + if (!navigator) { + throw new TypeError("not in available scope of StackedRouter"); + } + + return navigator; +} + +export type CurrentFrame = { + index: number; + frame: Readonly; +}; + +const CurrentFrameContext = + /* @__PURE__ */ createContext>>(); + +export function useCurrentFrame() { + const frame = useContext(CurrentFrameContext); + + if (!frame) { + throw new TypeError("not in available scope of StackedRouter"); + } + + return frame; +} + +function onDialogClick( + onClose: () => void, + event: MouseEvent & { currentTarget: HTMLDialogElement }, +) { + if (event.target !== event.currentTarget) return; + const rect = event.currentTarget.getBoundingClientRect(); + const isNotInDialog = + event.clientY < rect.top || + event.clientY > rect.bottom || + event.clientX < rect.left || + event.clientX > rect.right; + if (isNotInDialog) { + onClose(); + } +} + +/** + * The router that stacks the pages. + */ +const StackedRouter: Component = (oprops) => { + const [stack, mutStack] = createStore([] as StackFrame[], { name: "stack" }); + + const pushFrame = (path: string, opts?: Readonly>) => + untrack(() => { + const frame = { + path, + state: opts?.state, + rootId: createUniqueId(), + }; + mutStack(opts?.replace ? stack.length - 1 : stack.length, frame); + return frame; + }); + + const popFrame = (depth: number = 1) => + untrack(() => { + if (import.meta.env.DEV) { + if (depth < 0) { + console.warn("the depth to pop should not < 0, now is", depth); + } + } + if (stack.length > 1) { + const lastFrame = stack[stack.length - 1]; + const element = document.getElementById(lastFrame.rootId)!; + requestAnimationFrame(() => { + const animation = element.animate( + { + opacity: [0.5, 0], + }, + { easing: ANIM_CURVE_STD, duration: 220 }, + ); + animation.addEventListener("finish", () => + mutStack((o) => o.toSpliced(o.length - depth, depth)), + ); + }); + } else { + mutStack((o) => { + return o.toSpliced(o.length - depth, depth); + }); + } + }); + + /* createEffect(() => { + const length = stack.length; + console.debug("stack is changed", length, unwrap(stack)); + }); */ + + createRenderEffect(() => { + if (stack.length === 0) { + pushFrame("/", undefined); + } + }); + + const onBeforeDialogMount = (element: HTMLDialogElement) => { + createEffect(() => { + requestAnimationFrame(() => { + element.showModal(); + if (window.innerWidth <= 560) { + animateSlideInFromRight(element, { easing: ANIM_CURVE_DECELERATION }); + } else { + element.animate( + { + opacity: [0.5, 1], + }, + { easing: ANIM_CURVE_STD, duration: 220 }, + ); + } + }); + }); + }; + + return ( + + + {(frame, index) => { + const currentFrame = () => { + return { + index, + frame: frame(), + }; + }; + + return ( + + + +
+ } + > + + + + + + ); + }} + + + ); +}; + +export default StackedRouter; diff --git a/src/profiles/Profile.css b/src/profiles/Profile.css index cce8364..3f0bce8 100644 --- a/src/profiles/Profile.css +++ b/src/profiles/Profile.css @@ -1,4 +1,6 @@ .Profile { + height: 100%; + .intro { background-color: var(--tutu-color-surface-d); color: var(--tutu-color-on-surface); diff --git a/src/profiles/Profile.tsx b/src/profiles/Profile.tsx index caf3cd0..21ce929 100644 --- a/src/profiles/Profile.tsx +++ b/src/profiles/Profile.tsx @@ -45,7 +45,7 @@ import { Verified, } from "@suid/icons-material"; import { Body2, Title } from "../material/typography"; -import { useNavigate, useParams } from "@solidjs/router"; +import { useParams } from "@solidjs/router"; import { useSessionForAcctStr } from "../masto/clients"; import { resolveCustomEmoji } from "../masto/toot"; import { FastAverageColor } from "fast-average-color"; @@ -57,9 +57,10 @@ import TootFilterButton from "./TootFilterButton"; import Menu, { createManagedMenuState } from "../material/Menu"; import { share } from "../platform/share"; import "./Profile.css"; +import { useNavigator } from "../platform/StackedRouter"; const Profile: Component = () => { - const navigate = useNavigate(); + const { pop } = useNavigator(); const params = useParams<{ acct: string; id: string }>(); const acctText = () => decodeURIComponent(params.acct); const session = useSessionForAcctStr(acctText); @@ -209,11 +210,7 @@ const Profile: Component = () => { paddingTop: "var(--safe-area-inset-top)", }} > - + { - const navigate = useNavigate() + const { pop } = useNavigator(); const [t] = createTranslator( () => import("./i18n/lang-names.json"), (code) => @@ -37,9 +37,9 @@ const ChooseLang: Component = () => { }; }>, ); - const settings = useStore($settings) + const settings = useStore($settings); - const code = () => settings().language + const code = () => settings().language; const unsupportedLangCodes = createMemo(() => { return iso639_1.getAllCodes().filter((x) => !["zh", "en"].includes(x)); @@ -48,8 +48,8 @@ const ChooseLang: Component = () => { const matchedLangCode = createMemo(() => autoMatchLangTag()); const onCodeChange = (code?: string) => { - $settings.setKey("language", code) - } + $settings.setKey("language", code); + }; return ( <Scaffold @@ -59,7 +59,7 @@ const ChooseLang: Component = () => { variant="dense" sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }} > - <IconButton color="inherit" onClick={[navigate, -1]} disableRipple> + <IconButton color="inherit" onClick={[pop, 1]} disableRipple> <ArrowBack /> </IconButton> <Title>{t("Choose Language")} @@ -96,7 +96,10 @@ const ChooseLang: Component = () => { {t(`lang.${c}`)} diff --git a/src/settings/Motions.tsx b/src/settings/Motions.tsx index a90bd44..5b8cabb 100644 --- a/src/settings/Motions.tsx +++ b/src/settings/Motions.tsx @@ -13,14 +13,14 @@ import { Toolbar, } from "@suid/material"; import { Title } from "../material/typography"; -import { useNavigate } from "@solidjs/router"; import { ArrowBack } from "@suid/icons-material"; import { createTranslator } from "../platform/i18n"; import { useStore } from "@nanostores/solid"; import { $settings } from "./stores"; +import { useNavigator } from "../platform/StackedRouter"; const Motions: Component = () => { - const navigate = useNavigate(); + const {pop} = useNavigator(); const [t] = createTranslator( (code) => import(`./i18n/${code}.json`) as Promise<{ @@ -36,7 +36,7 @@ const Motions: Component = () => { variant="dense" sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }} > - + {t("motions")} diff --git a/src/settings/Region.tsx b/src/settings/Region.tsx index 130ef1f..c4b7a50 100644 --- a/src/settings/Region.tsx +++ b/src/settings/Region.tsx @@ -20,12 +20,12 @@ import { } from "../platform/i18n"; import { Title } from "../material/typography"; import type { Template } from "@solid-primitives/i18n"; -import { useNavigate } from "@solidjs/router"; import { $settings } from "./stores"; import { useStore } from "@nanostores/solid"; +import { useNavigator } from "../platform/StackedRouter"; const ChooseRegion: Component = () => { - const navigate = useNavigate(); + const {pop} = useNavigator(); const [t] = createTranslator( () => import("./i18n/lang-names.json"), (code) => @@ -54,7 +54,7 @@ const ChooseRegion: Component = () => { variant="dense" sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }} > - + {t("Choose Region")} diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 34bb4cc..0aec49c 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -30,7 +30,7 @@ import { Refresh as RefreshIcon, Translate as TranslateIcon, } from "@suid/icons-material"; -import { A, useNavigate } from "@solidjs/router"; +import A from "../platform/A.js"; import { Title } from "../material/typography.jsx"; import { css } from "solid-styled"; import { signOut, type Account } from "../accounts/stores.js"; @@ -47,6 +47,7 @@ import { type Template } from "@solid-primitives/i18n"; import BottomSheet from "../material/BottomSheet.jsx"; import { useServiceWorker } from "../platform/host.js"; import { useSessions } from "../masto/clients.js"; +import { useNavigator } from "../platform/StackedRouter.jsx"; type Inset = { top?: number; @@ -170,7 +171,7 @@ const Settings: ParentComponent = (props) => { }>, () => import(`./i18n/lang-names.json`), ); - const navigate = useNavigate(); + const {pop} = useNavigator(); const settings$ = useStore($settings); const { needRefresh, offlineReady } = useServiceWorker(); const dateFnLocale = useDateFnLocale(); @@ -200,7 +201,7 @@ const Settings: ParentComponent = (props) => { variant="dense" sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }} > - + {t("Settings")} @@ -208,7 +209,7 @@ const Settings: ParentComponent = (props) => { } > - navigate(-1)}> + pop(1)}> {subpage()} diff --git a/src/timelines/Home.tsx b/src/timelines/Home.tsx index 99045bf..339e157 100644 --- a/src/timelines/Home.tsx +++ b/src/timelines/Home.tsx @@ -29,11 +29,11 @@ import BottomSheet, { import { $settings } from "../settings/stores"; import { useStore } from "@nanostores/solid"; import { HeroSourceProvider, type HeroSource } from "../platform/anim"; -import { useNavigate } from "@solidjs/router"; import { setCache as setTootBottomSheetCache } from "./TootBottomSheet"; import TrendTimelinePanel from "./TrendTimelinePanel"; import TimelinePanel from "./TimelinePanel"; import { useSessions } from "../masto/clients"; +import { useNavigator } from "../platform/StackedRouter"; const Home: ParentComponent = (props) => { let panelList: HTMLDivElement; @@ -53,7 +53,7 @@ const Home: ParentComponent = (props) => { const all = profiles(); return all?.[0]?.client; }; - const navigate = useNavigate(); + const {pop, push} = useNavigator(); const [heroSrc, setHeroSrc] = createSignal({}); const [panelOffset, setPanelOffset] = createSignal(0); @@ -151,7 +151,7 @@ const Home: ParentComponent = (props) => { ); const acct = `${inf.username}@${p.account.site}`; setTootBottomSheetCache(acct, toot); - navigate(`/${encodeURIComponent(acct)}/toot/${toot.id}`, { + push(`/${encodeURIComponent(acct)}/toot/${toot.id}`, { state: reply ? { tootReply: true, @@ -276,7 +276,7 @@ const Home: ParentComponent = (props) => { - navigate(-1)}> + pop(1)}> {child()} diff --git a/src/timelines/ProfileMenuButton.tsx b/src/timelines/ProfileMenuButton.tsx index 0aa088c..c33ba8e 100644 --- a/src/timelines/ProfileMenuButton.tsx +++ b/src/timelines/ProfileMenuButton.tsx @@ -21,7 +21,7 @@ import { Star as LikeIcon, FeaturedPlayList as ListIcon, } from "@suid/icons-material"; -import { A } from "@solidjs/router"; +import A from "../platform/A"; const ProfileMenuButton: ParentComponent<{ profile?: { diff --git a/src/timelines/TootBottomSheet.css b/src/timelines/TootBottomSheet.css index ceb7c17..e6babb0 100644 --- a/src/timelines/TootBottomSheet.css +++ b/src/timelines/TootBottomSheet.css @@ -1,12 +1,11 @@ .TootBottomSheet { overflow: hidden; - height: calc(100% - var(--scaffold-topbar-height, 0px)); .Scrollable { padding-bottom: var(--safe-area-inset-bottom, 0); overflow-y: auto; overscroll-behavior-y: contain; - height: 100%; + height: calc(100% - var(--scaffold-topbar-height, 0px)); } .progress-line { diff --git a/src/timelines/TootBottomSheet.tsx b/src/timelines/TootBottomSheet.tsx index 1c6b111..e82ce00 100644 --- a/src/timelines/TootBottomSheet.tsx +++ b/src/timelines/TootBottomSheet.tsx @@ -1,10 +1,9 @@ -import { useLocation, useNavigate, useParams } from "@solidjs/router"; +import { useLocation, useParams } from "@solidjs/router"; import { catchError, createEffect, createRenderEffect, createResource, - createSignal, Show, type Component, } from "solid-js"; @@ -25,6 +24,8 @@ import { useDocumentTitle } from "../utils"; import { createTimelineControlsForArray } from "../masto/timelines"; import TootList from "./TootList"; import "./TootBottomSheet.css"; +import { useNavigator } from "../platform/StackedRouter"; +import BackButton from "../platform/BackButton"; let cachedEntry: [string, mastodon.v1.Status] | undefined; @@ -43,7 +44,7 @@ const TootBottomSheet: Component = (props) => { const location = useLocation<{ tootReply?: boolean; }>(); - const navigate = useNavigate(); + const { pop, push } = useNavigator(); const time = createTimeSource(); const acctText = () => decodeURIComponent(params.acct); const session = useSessionForAcctStr(acctText); @@ -186,7 +187,7 @@ const TootBottomSheet: Component = (props) => { target.dataset.client || `@${new URL(target.href).origin}`, ); - navigate(`/${acct}/profile/${target.dataset.acctId}`); + push(`/${acct}/profile/${target.dataset.acctId}`); return; } else { @@ -228,9 +229,7 @@ const TootBottomSheet: Component = (props) => { variant="dense" sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }} > - - - + <span ref={(e: HTMLElement) => @@ -246,9 +245,7 @@ const TootBottomSheet: Component = (props) => { } class="TootBottomSheet" > - <div - class="Scrollable" - > + <div class="Scrollable"> <TimeSourceProvider value={time}> <TootList threads={ancestors.list} @@ -288,9 +285,7 @@ const TootBottomSheet: Component = (props) => { </Show> <Show when={tootContextErrorUncaught.loading}> - <div - class="progress-line" - > + <div class="progress-line"> <CircularProgress style="width: 1.5em; height: 1.5em;" /> </div> </Show> diff --git a/src/timelines/TootList.tsx b/src/timelines/TootList.tsx index 7ef86da..caaf4c3 100644 --- a/src/timelines/TootList.tsx +++ b/src/timelines/TootList.tsx @@ -20,6 +20,7 @@ import RegularToot, { } from "./RegularToot"; import cardStyle from "../material/cards.module.css"; import type { ThreadNode } from "../masto/timelines"; +import { useNavigator } from "../platform/StackedRouter"; function positionTootInThread(index: number, threadLength: number) { if (index === 0) { @@ -40,7 +41,7 @@ const TootList: Component<{ const session = useDefaultSession(); const heroSrc = useHeroSource(); const [expandedThreadId, setExpandedThreadId] = createSignal<string>(); - const navigate = useNavigate(); + const {push} = useNavigator(); const onBookmark = async (status: mastodon.v1.Status) => { const client = session()?.client; @@ -115,7 +116,7 @@ const TootList: Component<{ const acct = `${inf.username}@${p.site}`; setTootBottomSheetCache(acct, toot); - navigate(`/${encodeURIComponent(acct)}/toot/${toot.id}`, { + push(`/${encodeURIComponent(acct)}/toot/${toot.id}`, { state: reply ? { tootReply: true, @@ -146,7 +147,7 @@ const TootList: Component<{ target.dataset.client || `@${new URL(target.href).origin}`, ); - navigate(`/${acct}/profile/${target.dataset.acctId}`); + push(`/${acct}/profile/${target.dataset.acctId}`); return; } else { From 9dfcfa3868bebfa23c4766a69818761680711494 Mon Sep 17 00:00:00 2001 From: thislight <l1589002388@gmail.com> Date: Sat, 16 Nov 2024 21:50:18 +0800 Subject: [PATCH 02/18] StackedRouter: add default open animation --- src/platform/StackedRouter.tsx | 46 +++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/src/platform/StackedRouter.tsx b/src/platform/StackedRouter.tsx index 8d489ae..25dd6e6 100644 --- a/src/platform/StackedRouter.tsx +++ b/src/platform/StackedRouter.tsx @@ -15,7 +15,7 @@ import { import { createStore, unwrap } from "solid-js/store"; import { insert, render } from "solid-js/web"; import "./StackedRouter.css"; -import { animateSlideInFromRight } from "./anim"; +import { animateSlideInFromRight, animateSlideOutToRight } from "./anim"; import { ANIM_CURVE_DECELERATION, ANIM_CURVE_STD } from "../material/theme"; export type StackedRouterProps = Omit<RouterProps, "url">; @@ -102,6 +102,32 @@ function onDialogClick( } } +function animateClose(element: HTMLElement) { + if (window.innerWidth <= 560) { + return animateSlideOutToRight(element, { easing: ANIM_CURVE_DECELERATION }); + } else { + return element.animate( + { + opacity: [0.5, 0], + }, + { easing: ANIM_CURVE_STD, duration: 220 }, + ); + } +} + +function animateOpen(element: HTMLElement) { + if (window.innerWidth <= 560) { + animateSlideInFromRight(element, { easing: ANIM_CURVE_DECELERATION }); + } else { + element.animate( + { + opacity: [0.5, 1], + }, + { easing: ANIM_CURVE_STD, duration: 220 }, + ); + } +} + /** * The router that stacks the pages. */ @@ -130,12 +156,7 @@ const StackedRouter: Component<StackedRouterProps> = (oprops) => { const lastFrame = stack[stack.length - 1]; const element = document.getElementById(lastFrame.rootId)!; requestAnimationFrame(() => { - const animation = element.animate( - { - opacity: [0.5, 0], - }, - { easing: ANIM_CURVE_STD, duration: 220 }, - ); + const animation = animateClose(element); animation.addEventListener("finish", () => mutStack((o) => o.toSpliced(o.length - depth, depth)), ); @@ -162,16 +183,7 @@ const StackedRouter: Component<StackedRouterProps> = (oprops) => { createEffect(() => { requestAnimationFrame(() => { element.showModal(); - if (window.innerWidth <= 560) { - animateSlideInFromRight(element, { easing: ANIM_CURVE_DECELERATION }); - } else { - element.animate( - { - opacity: [0.5, 1], - }, - { easing: ANIM_CURVE_STD, duration: 220 }, - ); - } + animateOpen(element); }); }); }; From ab37a280e74519fc060712f7825d9f9a38a89f3d Mon Sep 17 00:00:00 2001 From: thislight <l1589002388@gmail.com> Date: Sat, 16 Nov 2024 22:26:16 +0800 Subject: [PATCH 03/18] ProfileMenuButton: fix open settings in new page --- src/timelines/ProfileMenuButton.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/timelines/ProfileMenuButton.tsx b/src/timelines/ProfileMenuButton.tsx index c33ba8e..b74386c 100644 --- a/src/timelines/ProfileMenuButton.tsx +++ b/src/timelines/ProfileMenuButton.tsx @@ -51,7 +51,7 @@ const ProfileMenuButton: ParentComponent<{ props.onClick?.(); }; - const inf = () => props.profile?.account.inf + const inf = () => props.profile?.account.inf; const onClose = () => { props.onClick?.(); @@ -130,7 +130,7 @@ const ProfileMenuButton: ParentComponent<{ {props.children} <Divider /> </Show> - <MenuItem component={A} href="/settings" onClick={onClose}> + <MenuItem component={A} href="/settings"> <ListItemIcon> <SettingsIcon /> </ListItemIcon> From 2da7bf134e91a5696e337a5d6ed82a5c08eb28dc Mon Sep 17 00:00:00 2001 From: thislight <l1589002388@gmail.com> Date: Sat, 16 Nov 2024 22:27:35 +0800 Subject: [PATCH 04/18] App: fix path for motion settings --- src/App.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 5fdb0c8..8f7ba44 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -46,10 +46,10 @@ const Routing: Component = () => { return ( <StackedRouter> <Route path="/" component={TimelineHome} /> - <Route path="/settings" component={Settings} /> <Route path="/settings/language" component={LanguageSettings} /> <Route path="/settings/region" component={RegionSettings} /> - <Route path="/motions" component={MotionSettings} /> + <Route path="/settings/motions" component={MotionSettings} /> + <Route path="/settings" component={Settings} /> <Route path="/:acct/toot/:id" component={TootBottomSheet} /> <Route path="/:acct/profile/:id" component={Profile} /> From e3d9d0c4ba52830ad98a8e7a65b919315bd16199 Mon Sep 17 00:00:00 2001 From: thislight <l1589002388@gmail.com> Date: Sat, 16 Nov 2024 22:37:05 +0800 Subject: [PATCH 05/18] StackedRouter: add default background --- src/platform/StackedRouter.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/platform/StackedRouter.css b/src/platform/StackedRouter.css index 714893c..5dae005 100644 --- a/src/platform/StackedRouter.css +++ b/src/platform/StackedRouter.css @@ -21,6 +21,7 @@ dialog.StackedPage { contain-intrinsic-size: auto 560px auto 100dvh; content-visibility: auto; + background: var(--tutu-color-surface); box-shadow: var(--tutu-shadow-e16); @media (min-width: 560px) { From 5be56bb80ec7cea2e5f0d7bc672375c082072ec1 Mon Sep 17 00:00:00 2001 From: thislight <l1589002388@gmail.com> Date: Sat, 16 Nov 2024 22:37:22 +0800 Subject: [PATCH 06/18] A: fix missolved path --- src/platform/A.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/platform/A.tsx b/src/platform/A.tsx index b655dc9..3d0b33d 100644 --- a/src/platform/A.tsx +++ b/src/platform/A.tsx @@ -1,5 +1,6 @@ -import { type JSX } from "solid-js"; +import { splitProps, type JSX } from "solid-js"; import { useNavigator } from "./StackedRouter"; +import { useResolvedPath } from "@solidjs/router"; function handleClick( push: (name: string, state: unknown) => void, @@ -7,13 +8,14 @@ function handleClick( ) { const target = event.currentTarget; event.preventDefault(); - event.stopPropagation(); push(target.href, { state: target.getAttribute("state") || undefined }); } -const A = (oprops: JSX.HTMLElementTags["a"]) => { +const A = (oprops: Omit<JSX.HTMLElementTags["a"], "onClick" | "onclick">) => { + const [props, rest] = splitProps(oprops, ["href"]); + const resolvedPath = useResolvedPath(() => props.href || "#"); const { push } = useNavigator(); - return <a onClick={[handleClick, push]} {...oprops}></a>; + return <a onClick={[handleClick, push]} href={resolvedPath()} {...rest}></a>; }; export default A; From 32dffaaa3df589b57dbde92ccc61ce89b64a5439 Mon Sep 17 00:00:00 2001 From: thislight <l1589002388@gmail.com> Date: Sat, 16 Nov 2024 22:38:00 +0800 Subject: [PATCH 07/18] StackedRouter: intergrated with web history API --- src/platform/StackedRouter.tsx | 35 ++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/src/platform/StackedRouter.tsx b/src/platform/StackedRouter.tsx index 25dd6e6..7325d7e 100644 --- a/src/platform/StackedRouter.tsx +++ b/src/platform/StackedRouter.tsx @@ -6,17 +6,18 @@ import { createRenderEffect, createUniqueId, Index, - onMount, Show, untrack, useContext, type Accessor, } from "solid-js"; import { createStore, unwrap } from "solid-js/store"; -import { insert, render } from "solid-js/web"; import "./StackedRouter.css"; import { animateSlideInFromRight, animateSlideOutToRight } from "./anim"; import { ANIM_CURVE_DECELERATION, ANIM_CURVE_STD } from "../material/theme"; +import { + makeEventListener, +} from "@solid-primitives/event-listener"; export type StackedRouterProps = Omit<RouterProps, "url">; @@ -24,7 +25,6 @@ export type StackFrame = { path: string; rootId: string; state: unknown; - beforeShow?: (element: HTMLElement) => void; }; export type NewFrameOptions<T> = (T extends undefined @@ -141,10 +141,21 @@ const StackedRouter: Component<StackedRouterProps> = (oprops) => { state: opts?.state, rootId: createUniqueId(), }; + mutStack(opts?.replace ? stack.length - 1 : stack.length, frame); + if (opts?.replace) { + window.history.replaceState(unwrap(stack), "", path); + } else { + window.history.pushState(unwrap(stack), "", path); + } return frame; }); + const onlyPopFrame = (depth: number) => { + mutStack((o) => o.toSpliced(o.length - depth, depth)); + window.history.go(-depth); + }; + const popFrame = (depth: number = 1) => untrack(() => { if (import.meta.env.DEV) { @@ -157,14 +168,10 @@ const StackedRouter: Component<StackedRouterProps> = (oprops) => { const element = document.getElementById(lastFrame.rootId)!; requestAnimationFrame(() => { const animation = animateClose(element); - animation.addEventListener("finish", () => - mutStack((o) => o.toSpliced(o.length - depth, depth)), - ); + animation.addEventListener("finish", () => onlyPopFrame(depth)); }); } else { - mutStack((o) => { - return o.toSpliced(o.length - depth, depth); - }); + onlyPopFrame(depth); } }); @@ -175,10 +182,18 @@ const StackedRouter: Component<StackedRouterProps> = (oprops) => { createRenderEffect(() => { if (stack.length === 0) { - pushFrame("/", undefined); + pushFrame(window.location.pathname); } }); + createRenderEffect(() => { + makeEventListener(window, "popstate", (event) => { + if (event.state && stack.length !== event.state.length) { + mutStack(event.state); + } + }); + }); + const onBeforeDialogMount = (element: HTMLDialogElement) => { createEffect(() => { requestAnimationFrame(() => { From 63fe4acc98502ba1c722a0d6b0209adf08dc6f5e Mon Sep 17 00:00:00 2001 From: thislight <l1589002388@gmail.com> Date: Sat, 16 Nov 2024 22:38:27 +0800 Subject: [PATCH 08/18] Settings: fix back button pop too much frames --- src/settings/Settings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 0aec49c..2351f8d 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -201,7 +201,7 @@ const Settings: ParentComponent = (props) => { variant="dense" sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }} > - <IconButton color="inherit" onClick={[pop, 11]} disableRipple> + <IconButton color="inherit" onClick={[pop, 1]} disableRipple> <CloseIcon /> </IconButton> <Title>{t("Settings")} From 1641f3e75b0cc687aaf230516d71e91d83cec52d Mon Sep 17 00:00:00 2001 From: thislight Date: Sun, 17 Nov 2024 17:37:42 +0800 Subject: [PATCH 09/18] StackedRouter: hero animation support --- src/platform/StackedRouter.css | 16 ++++++---- src/platform/StackedRouter.tsx | 56 ++++++++++++++++++++++++++-------- 2 files changed, 54 insertions(+), 18 deletions(-) diff --git a/src/platform/StackedRouter.css b/src/platform/StackedRouter.css index 5dae005..6b912c1 100644 --- a/src/platform/StackedRouter.css +++ b/src/platform/StackedRouter.css @@ -24,12 +24,8 @@ dialog.StackedPage { background: var(--tutu-color-surface); box-shadow: var(--tutu-shadow-e16); - @media (min-width: 560px) { - & { - left: 50%; - transform: translateX(-50%); - } - } + margin-left: auto; + margin-right: auto; @media (max-width: 560px) { & { @@ -50,4 +46,12 @@ dialog.StackedPage { &::backdrop { background: none; } + + &.animating { + overflow: hidden; + + * { + overflow: hidden; + } + } } \ No newline at end of file diff --git a/src/platform/StackedRouter.tsx b/src/platform/StackedRouter.tsx index 7325d7e..792ec97 100644 --- a/src/platform/StackedRouter.tsx +++ b/src/platform/StackedRouter.tsx @@ -6,6 +6,7 @@ import { createRenderEffect, createUniqueId, Index, + onMount, Show, untrack, useContext, @@ -15,9 +16,7 @@ import { createStore, unwrap } from "solid-js/store"; import "./StackedRouter.css"; import { animateSlideInFromRight, animateSlideOutToRight } from "./anim"; import { ANIM_CURVE_DECELERATION, ANIM_CURVE_STD } from "../material/theme"; -import { - makeEventListener, -} from "@solid-primitives/event-listener"; +import { makeEventListener } from "@solid-primitives/event-listener"; export type StackedRouterProps = Omit; @@ -25,6 +24,9 @@ export type StackFrame = { path: string; rootId: string; state: unknown; + + animateOpen?: (element: HTMLElement) => Animation; + animateClose?: (element: HTMLElement) => Animation; }; export type NewFrameOptions = (T extends undefined @@ -33,6 +35,8 @@ export type NewFrameOptions = (T extends undefined } : { state: T }) & { replace?: boolean; + animateOpen?: StackFrame["animateOpen"]; + animateClose?: StackFrame["animateClose"]; }; export type FramePusher = T[K] extends @@ -117,9 +121,11 @@ function animateClose(element: HTMLElement) { function animateOpen(element: HTMLElement) { if (window.innerWidth <= 560) { - animateSlideInFromRight(element, { easing: ANIM_CURVE_DECELERATION }); + return animateSlideInFromRight(element, { + easing: ANIM_CURVE_DECELERATION, + }); } else { - element.animate( + return element.animate( { 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. */ @@ -140,13 +157,15 @@ const StackedRouter: Component = (oprops) => { path, state: opts?.state, rootId: createUniqueId(), + animateOpen: opts?.animateOpen, + animateClose: opts?.animateClose, }; mutStack(opts?.replace ? stack.length - 1 : stack.length, frame); if (opts?.replace) { - window.history.replaceState(unwrap(stack), "", path); + window.history.replaceState(serializableStack(stack), "", path); } else { - window.history.pushState(unwrap(stack), "", path); + window.history.pushState(serializableStack(stack), "", path); } return frame; }); @@ -165,10 +184,17 @@ const StackedRouter: Component = (oprops) => { } if (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(() => { - const animation = animateClose(element); - animation.addEventListener("finish", () => onlyPopFrame(depth)); + element.classList.add("animating") + const animation = createAnimation(element); + animation.addEventListener("finish", () => { + element.classList.remove("animating") + onlyPopFrame(depth); + }); }); } else { onlyPopFrame(depth); @@ -195,10 +221,16 @@ const StackedRouter: Component = (oprops) => { }); const onBeforeDialogMount = (element: HTMLDialogElement) => { - createEffect(() => { + onMount(() => { + const lastFr = untrack(() => stack[stack.length - 1]); + const createAnimation = lastFr.animateOpen ?? animateOpen; requestAnimationFrame(() => { element.showModal(); - animateOpen(element); + element.classList.add("animating"); + const animation = createAnimation(element); + animation.addEventListener("finish", () => + element.classList.remove("animating"), + ); }); }); }; From 6879eb52928c07a945f4977a22cfd150780910b2 Mon Sep 17 00:00:00 2001 From: thislight Date: Sun, 17 Nov 2024 17:37:58 +0800 Subject: [PATCH 10/18] TootList: add hero animation --- src/timelines/TootList.tsx | 73 +++++++++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 8 deletions(-) diff --git a/src/timelines/TootList.tsx b/src/timelines/TootList.tsx index caaf4c3..38670e2 100644 --- a/src/timelines/TootList.tsx +++ b/src/timelines/TootList.tsx @@ -13,7 +13,6 @@ import { useDefaultSession } from "../masto/clients"; import { useHeroSource } from "../platform/anim"; import { HERO as BOTTOM_SHEET_HERO } from "../material/BottomSheet"; import { setCache as setTootBottomSheetCache } from "./TootBottomSheet"; -import { useNavigate } from "@solidjs/router"; import RegularToot, { findElementActionable, findRootToot, @@ -21,6 +20,21 @@ import RegularToot, { import cardStyle from "../material/cards.module.css"; import type { ThreadNode } from "../masto/timelines"; 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) { if (index === 0) { @@ -41,7 +55,7 @@ const TootList: Component<{ const session = useDefaultSession(); const heroSrc = useHeroSource(); const [expandedThreadId, setExpandedThreadId] = createSignal(); - const {push} = useNavigator(); + const { push } = useNavigator(); const onBookmark = async (status: mastodon.v1.Status) => { const client = session()?.client; @@ -100,7 +114,7 @@ const TootList: Component<{ const openFullScreenToot = ( toot: mastodon.v1.Status, - srcElement?: HTMLElement, + srcElement: HTMLElement, reply?: boolean, ) => { const p = session()?.account; @@ -116,12 +130,55 @@ const TootList: Component<{ const acct = `${inf.username}@${p.site}`; setTootBottomSheetCache(acct, toot); + push(`/${encodeURIComponent(acct)}/toot/${toot.id}`, { - state: reply - ? { - tootReply: true, - } - : undefined, + animateOpen(element) { + const rect0 = srcElement.getBoundingClientRect(); // the start rect + const rect1 = element.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 = "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; + }, }); }; From f0dadebfb61961d2077455ec4f5a5dd7cbb3ebca Mon Sep 17 00:00:00 2001 From: thislight Date: Sun, 17 Nov 2024 20:27:49 +0800 Subject: [PATCH 11/18] Home: minor cleanup - remove unused prop openFullScreenToot from TimelinePanel and TrendTimelinePanel --- src/timelines/Home.tsx | 140 +++++++++------------------ src/timelines/TimelinePanel.tsx | 6 -- src/timelines/TrendTimelinePanel.tsx | 6 -- 3 files changed, 44 insertions(+), 108 deletions(-) diff --git a/src/timelines/Home.tsx b/src/timelines/Home.tsx index 339e157..13b59d7 100644 --- a/src/timelines/Home.tsx +++ b/src/timelines/Home.tsx @@ -3,11 +3,9 @@ import { Show, onMount, type ParentComponent, - children, - Suspense, + createRenderEffect, } from "solid-js"; import { useDocumentTitle } from "../utils"; -import { type mastodon } from "masto"; import Scaffold from "../material/Scaffold"; import { AppBar, @@ -23,17 +21,11 @@ import ProfileMenuButton from "./ProfileMenuButton"; import Tabs from "../material/Tabs"; import Tab from "../material/Tab"; import { makeEventListener } from "@solid-primitives/event-listener"; -import BottomSheet, { - HERO as BOTTOM_SHEET_HERO, -} from "../material/BottomSheet"; import { $settings } from "../settings/stores"; import { useStore } from "@nanostores/solid"; -import { HeroSourceProvider, type HeroSource } from "../platform/anim"; -import { setCache as setTootBottomSheetCache } from "./TootBottomSheet"; import TrendTimelinePanel from "./TrendTimelinePanel"; import TimelinePanel from "./TimelinePanel"; import { useSessions } from "../masto/clients"; -import { useNavigator } from "../platform/StackedRouter"; const Home: ParentComponent = (props) => { let panelList: HTMLDivElement; @@ -43,29 +35,17 @@ const Home: ParentComponent = (props) => { const settings$ = useStore($settings); const profiles = useSessions(); - const profile = () => { - const all = profiles(); - if (all.length > 0) { - return all[0].account.inf; - } - }; const client = () => { const all = profiles(); return all?.[0]?.client; }; - const {pop, push} = useNavigator(); - const [heroSrc, setHeroSrc] = createSignal({}); - const [panelOffset, setPanelOffset] = createSignal(0); const prefetching = () => !settings$().prefetchTootsDisabled; - const [currentFocusOn, setCurrentFocusOn] = createSignal([]); const [focusRange, setFocusRange] = createSignal([0, 0] as readonly [ number, number, ]); - const child = children(() => props.children); - let scrollEventLockReleased = true; const recalculateTabIndicator = () => { @@ -102,17 +82,17 @@ const Home: ParentComponent = (props) => { } }; + const requestRecalculateTabIndicator = () => { + if (scrollEventLockReleased) { + requestAnimationFrame(recalculateTabIndicator); + } + }; + + createRenderEffect(() => { + makeEventListener(window, "resize", requestRecalculateTabIndicator); + }); + onMount(() => { - makeEventListener(panelList, "scroll", () => { - if (scrollEventLockReleased) { - requestAnimationFrame(recalculateTabIndicator); - } - }); - makeEventListener(window, "resize", () => { - if (scrollEventLockReleased) { - requestAnimationFrame(recalculateTabIndicator); - } - }); requestAnimationFrame(recalculateTabIndicator); }); @@ -135,30 +115,6 @@ const Home: ParentComponent = (props) => { } }; - const openFullScreenToot = ( - toot: mastodon.v1.Status, - srcElement?: HTMLElement, - reply?: boolean, - ) => { - const p = profiles()[0]; - const inf = p.account.inf ?? profile(); - if (!inf) { - console.warn("no account info?"); - return; - } - setHeroSrc((x) => - Object.assign({}, x, { [BOTTOM_SHEET_HERO]: srcElement }), - ); - const acct = `${inf.username}@${p.account.site}`; - setTootBottomSheetCache(acct, toot); - push(`/${encodeURIComponent(acct)}/toot/${toot.id}`, { - state: reply - ? { - tootReply: true, - } - : undefined, - }); - }; css` .tab-panel { @@ -209,7 +165,7 @@ const Home: ParentComponent = (props) => { class="responsive" sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }} > - + Home @@ -239,48 +195,40 @@ const Home: ParentComponent = (props) => { } > - - - -
-
-
- -
+ + +
+
+
+
-
-
- -
-
-
-
- -
-
-
- - - - pop(1)}> - {child()} - - - +
+
+ +
+
+
+
+ +
+
+
+
+
+
); diff --git a/src/timelines/TimelinePanel.tsx b/src/timelines/TimelinePanel.tsx index 150ca0e..7a4c4e1 100644 --- a/src/timelines/TimelinePanel.tsx +++ b/src/timelines/TimelinePanel.tsx @@ -20,12 +20,6 @@ const TimelinePanel: Component<{ client: mastodon.rest.Client; name: "home" | "public"; prefetch?: boolean; - - openFullScreenToot: ( - toot: mastodon.v1.Status, - srcElement?: HTMLElement, - reply?: boolean, - ) => void; }> = (props) => { const [scrollLinked, setScrollLinked] = createSignal(); diff --git a/src/timelines/TrendTimelinePanel.tsx b/src/timelines/TrendTimelinePanel.tsx index 1691ec8..9f0ba75 100644 --- a/src/timelines/TrendTimelinePanel.tsx +++ b/src/timelines/TrendTimelinePanel.tsx @@ -13,12 +13,6 @@ import TootList from "./TootList.jsx"; const TrendTimelinePanel: Component<{ client: mastodon.rest.Client; - - openFullScreenToot: ( - toot: mastodon.v1.Status, - srcElement?: HTMLElement, - reply?: boolean, - ) => void; }> = (props) => { const [scrollLinked, setScrollLinked] = createSignal(); const [tl, snapshot, { refetch: refetchTimeline }] = createTimelineSnapshot( From 169aa91e73c9fe6fb7d23070cb96582fa44a914d Mon Sep 17 00:00:00 2001 From: thislight Date: Sun, 17 Nov 2024 20:57:56 +0800 Subject: [PATCH 12/18] StackedRouter: add useMaybeIsFrameSuspended() --- src/platform/StackedRouter.css | 2 ++ src/platform/StackedRouter.tsx | 64 +++++++++++++++++++++++++++++++--- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/platform/StackedRouter.css b/src/platform/StackedRouter.css index 6b912c1..eac26ce 100644 --- a/src/platform/StackedRouter.css +++ b/src/platform/StackedRouter.css @@ -3,6 +3,8 @@ display: contents; max-width: 100vw; max-width: 100dvw; + + contain: layout; } dialog.StackedPage { diff --git a/src/platform/StackedRouter.tsx b/src/platform/StackedRouter.tsx index 792ec97..5bc6d8c 100644 --- a/src/platform/StackedRouter.tsx +++ b/src/platform/StackedRouter.tsx @@ -2,7 +2,6 @@ import { StaticRouter, type RouterProps } from "@solidjs/router"; import { Component, createContext, - createEffect, createRenderEffect, createUniqueId, Index, @@ -34,8 +33,33 @@ export type NewFrameOptions = (T extends undefined state?: T; } : { state: T }) & { + /** + * The new frame should replace the current frame. + */ replace?: boolean; + /** + * The animatedOpen phase of the life cycle. + * + * You can use this hook to animate the opening + * of the frame. In this phase, the frame content is created + * and is mounted to the document. + * + * You must return an {@link Animation}. This function must be + * without side effects. This phase is ended after the {@link Animation} + * finished. + */ animateOpen?: StackFrame["animateOpen"]; + /** + * The animatedClose phase of the life cycle. + * + * You can use this hook to animate the closing of the frame. + * In this phase, the frame content is still mounted in the + * document and will be unmounted after this phase. + * + * You must return an {@link Animation}. This function must be + * without side effects. This phase is ended after the + * {@link Animation} finished. + */ animateClose?: StackFrame["animateClose"]; }; @@ -53,6 +77,10 @@ export type Navigator> = { const NavigatorContext = /* @__PURE__ */ createContext(); +export function useMaybeNavigator() { + return useContext(NavigatorContext); +} + /** * Get the navigator of the {@link StackedRouter}. * @@ -63,7 +91,7 @@ const NavigatorContext = /* @__PURE__ */ createContext(); * navigator to the type you need. */ export function useNavigator() { - const navigator = useContext(NavigatorContext); + const navigator = useMaybeNavigator(); if (!navigator) { throw new TypeError("not in available scope of StackedRouter"); @@ -80,8 +108,12 @@ export type CurrentFrame = { const CurrentFrameContext = /* @__PURE__ */ createContext>>(); +export function useMaybeCurrentFrame() { + return useContext(CurrentFrameContext); +} + export function useCurrentFrame() { - const frame = useContext(CurrentFrameContext); + const frame = useMaybeCurrentFrame(); if (!frame) { throw new TypeError("not in available scope of StackedRouter"); @@ -90,6 +122,28 @@ export function useCurrentFrame() { return frame; } +/** + * Return an accessor of is current frame is suspended. + * + * A suspended frame is the one not on the top. "Suspended" + * is the description of a certain situtation, not in the life cycle + * of a frame. + */ +export function useMaybeIsFrameSuspended() { + const { frames } = useMaybeNavigator() || {}; + + if (typeof frames === "undefined") { + return () => false; + } + + const thisFrame = useCurrentFrame(); + + return () => { + const idx = thisFrame().index; + return frames.length - 1 > idx; + }; +} + function onDialogClick( onClose: () => void, event: MouseEvent & { currentTarget: HTMLDialogElement }, @@ -189,10 +243,10 @@ const StackedRouter: Component = (oprops) => { )! as HTMLDialogElement; const createAnimation = lastFrame.animateClose ?? animateClose; requestAnimationFrame(() => { - element.classList.add("animating") + element.classList.add("animating"); const animation = createAnimation(element); animation.addEventListener("finish", () => { - element.classList.remove("animating") + element.classList.remove("animating"); onlyPopFrame(depth); }); }); From 3c50f150dc9a7b4c4571d2af9bebea4a0b9baac2 Mon Sep 17 00:00:00 2001 From: thislight Date: Sun, 17 Nov 2024 20:58:23 +0800 Subject: [PATCH 13/18] PullDownToRefresh: adpats useMaybeIsFrameSuspended --- src/timelines/PullDownToRefresh.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/timelines/PullDownToRefresh.tsx b/src/timelines/PullDownToRefresh.tsx index 10df2bf..7cbacdb 100644 --- a/src/timelines/PullDownToRefresh.tsx +++ b/src/timelines/PullDownToRefresh.tsx @@ -10,6 +10,7 @@ import { Refresh as RefreshIcon } from "@suid/icons-material"; import { CircularProgress } from "@suid/material"; import { makeEventListener } from "@solid-primitives/event-listener"; import { createVisibilityObserver } from "@solid-primitives/intersection-observer"; +import { useMaybeIsFrameSuspended } from "../platform/StackedRouter"; const PullDownToRefresh: Component<{ loading?: boolean; @@ -33,6 +34,7 @@ const PullDownToRefresh: Component<{ }); const rootVisible = obvx(() => rootElement); + const isFrameSuspended = useMaybeIsFrameSuspended() createEffect(() => { if (!rootVisible()) setPullDown(0); @@ -109,6 +111,9 @@ const PullDownToRefresh: Component<{ if (!rootVisible()) { return; } + if (isFrameSuspended()) { + return; + } const element = props.linkedElement; if (!element) return; makeEventListener(element, "wheel", handleLinkedWheel); @@ -159,6 +164,9 @@ const PullDownToRefresh: Component<{ if (!rootVisible()) { return; } + if (isFrameSuspended()) { + return; + } const element = props.linkedElement; if (!element) return; makeEventListener(element, "touchmove", handleTouch); From 6fbd198021b637e7c207f0c5bc12c6b82fcad1ac Mon Sep 17 00:00:00 2001 From: thislight Date: Sun, 17 Nov 2024 21:00:36 +0800 Subject: [PATCH 14/18] UnexpectedError: restart the app instead of reload --- src/UnexpectedError.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/UnexpectedError.tsx b/src/UnexpectedError.tsx index 906d99d..4177758 100644 --- a/src/UnexpectedError.tsx +++ b/src/UnexpectedError.tsx @@ -49,11 +49,13 @@ const UnexpectedError: Component<{ error?: any }> = (props) => {

Oh, it is our fault.

There is an unexpected error in our app, and it's not your fault.

- You can reload to see if this guy is gone. If you meet this guy + You can restart the app to see if this guy is gone. If you meet this guy repeatly, please report to us.

- +
From a3ef5d9cf55e45e3fb4151cb5084aeef1921a236 Mon Sep 17 00:00:00 2001 From: thislight Date: Mon, 18 Nov 2024 18:08:14 +0800 Subject: [PATCH 15/18] StackedRouter: prototype of generic swipe to back --- src/platform/StackedRouter.tsx | 96 +++++++++++++++++++++++++++++++++- 1 file changed, 95 insertions(+), 1 deletion(-) diff --git a/src/platform/StackedRouter.tsx b/src/platform/StackedRouter.tsx index 5bc6d8c..372c851 100644 --- a/src/platform/StackedRouter.tsx +++ b/src/platform/StackedRouter.tsx @@ -15,7 +15,10 @@ import { createStore, unwrap } from "solid-js/store"; import "./StackedRouter.css"; import { animateSlideInFromRight, animateSlideOutToRight } from "./anim"; import { ANIM_CURVE_DECELERATION, ANIM_CURVE_STD } from "../material/theme"; -import { makeEventListener } from "@solid-primitives/event-listener"; +import { + eventListener, + makeEventListener, +} from "@solid-primitives/event-listener"; export type StackedRouterProps = Omit; @@ -289,6 +292,93 @@ const StackedRouter: Component = (oprops) => { }); }; + let reenterableAnimation: Animation | undefined; + let origX = 0, + origWidth = 0; + + const resetAnimation = () => { + reenterableAnimation = undefined; + }; + + const onDialogTouchStart = ( + event: TouchEvent & { currentTarget: HTMLDialogElement }, + ) => { + if (event.touches.length !== 1) { + return; + } + const [fig0] = event.touches; + const { x, width } = event.currentTarget.getBoundingClientRect(); + if (fig0.clientX < x - 22 || fig0.clientX > x + 22) { + return; + } + origX = x; + origWidth = width; + + const lastFr = stack[stack.length - 1]; + const createAnimation = lastFr.animateClose ?? animateClose; + reenterableAnimation = createAnimation(event.currentTarget); + reenterableAnimation.pause(); + reenterableAnimation.addEventListener("finish", resetAnimation); + reenterableAnimation.addEventListener("cancel", resetAnimation); + }; + + const onDialogTouchMove = ( + event: TouchEvent & { currentTarget: HTMLDialogElement }, + ) => { + if (event.touches.length !== 1) { + if (reenterableAnimation) { + reenterableAnimation.reverse(); + reenterableAnimation.play(); + } + } + + if (!reenterableAnimation) return; + + event.preventDefault(); + event.stopPropagation(); + + const [fig0] = event.touches; + const ofsX = fig0.clientX - origX; + const pc = ofsX / origWidth / window.devicePixelRatio; + + const { activeDuration, delay } = + reenterableAnimation.effect!.getComputedTiming(); + + const totalTime = (delay || 0) + Number(activeDuration); + reenterableAnimation.currentTime = totalTime * pc; + }; + + const onDialogTouchEnd = (event: TouchEvent) => { + if (!reenterableAnimation) return; + + event.preventDefault(); + event.stopPropagation(); + + const { activeDuration, delay } = + reenterableAnimation.effect!.getComputedTiming(); + const totalTime = (delay || 0) + Number(activeDuration); + + if ( + Number(reenterableAnimation.currentTime) / totalTime > + 0.1 + ) { + reenterableAnimation.addEventListener("finish", () => { + onlyPopFrame(1); + }); + reenterableAnimation.play(); + } else { + reenterableAnimation.cancel(); + } + }; + + const onDialogTouchCancel = (event: TouchEvent) => { + if (!reenterableAnimation) return; + + event.preventDefault(); + event.stopPropagation(); + reenterableAnimation.cancel(); + }; + return ( = (oprops) => { class="StackedPage" onCancel={[popFrame, 1]} onClick={[onDialogClick, popFrame]} + onTouchStart={onDialogTouchStart} + onTouchMove={onDialogTouchMove} + onTouchEnd={onDialogTouchEnd} + onTouchCancel={onDialogTouchCancel} id={frame().rootId} > From 00554a045f05d50b977ca8c1b6483db388399c31 Mon Sep 17 00:00:00 2001 From: thislight Date: Mon, 18 Nov 2024 18:12:32 +0800 Subject: [PATCH 16/18] Settings: minor cleanup --- src/settings/Settings.tsx | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 2351f8d..aca4c59 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -1,10 +1,7 @@ import { - children, - createSignal, For, Show, - type JSX, - type ParentComponent, + type Component, } from "solid-js"; import Scaffold from "../material/Scaffold.js"; import { @@ -44,7 +41,6 @@ import { useDateFnLocale, } from "../platform/i18n.jsx"; import { type Template } from "@solid-primitives/i18n"; -import BottomSheet from "../material/BottomSheet.jsx"; import { useServiceWorker } from "../platform/host.js"; import { useSessions } from "../masto/clients.js"; import { useNavigator } from "../platform/StackedRouter.jsx"; @@ -163,7 +159,7 @@ type Strings = { ["lang.auto"]: Template<{ detected: string }>; } & Record; -const Settings: ParentComponent = (props) => { +const Settings: Component = () => { const [t] = createTranslator( (code) => import(`./i18n/${code}.json`) as Promise<{ @@ -173,7 +169,7 @@ const Settings: ParentComponent = (props) => { ); const {pop} = useNavigator(); const settings$ = useStore($settings); - const { needRefresh, offlineReady } = useServiceWorker(); + const { needRefresh } = useServiceWorker(); const dateFnLocale = useDateFnLocale(); const profiles = useSessions(); @@ -182,8 +178,6 @@ const Settings: ParentComponent = (props) => { signOut((a) => a.site === acct.site && a.accessToken === acct.accessToken); }; - const subpage = children(() => props.children); - css` ul { padding: 0; @@ -209,10 +203,6 @@ const Settings: ParentComponent = (props) => { } > - pop(1)}> - {subpage()} - -
    • From 3b5cc1e64e81f42b70abc803504930890082327f Mon Sep 17 00:00:00 2001 From: thislight Date: Mon, 18 Nov 2024 18:32:06 +0800 Subject: [PATCH 17/18] StackedRouter: fix backward does not pop page --- src/platform/StackedRouter.tsx | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/src/platform/StackedRouter.tsx b/src/platform/StackedRouter.tsx index 372c851..7c82858 100644 --- a/src/platform/StackedRouter.tsx +++ b/src/platform/StackedRouter.tsx @@ -194,11 +194,13 @@ 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, - }; + return fr.animateClose || fr.animateOpen + ? { + path: fr.path, + rootId: fr.rootId, + state: fr.state, + } + : fr; }); } @@ -258,11 +260,6 @@ const StackedRouter: Component = (oprops) => { } }); - /* createEffect(() => { - const length = stack.length; - console.debug("stack is changed", length, unwrap(stack)); - }); */ - createRenderEffect(() => { if (stack.length === 0) { pushFrame(window.location.pathname); @@ -271,8 +268,12 @@ const StackedRouter: Component = (oprops) => { createRenderEffect(() => { makeEventListener(window, "popstate", (event) => { - if (event.state && stack.length !== event.state.length) { + if (!event.state) return; + + if (stack.length === 0) { mutStack(event.state); + } else if (stack.length > event.state.length) { + popFrame(stack.length - event.state.length); } }); }); @@ -358,10 +359,7 @@ const StackedRouter: Component = (oprops) => { reenterableAnimation.effect!.getComputedTiming(); const totalTime = (delay || 0) + Number(activeDuration); - if ( - Number(reenterableAnimation.currentTime) / totalTime > - 0.1 - ) { + if (Number(reenterableAnimation.currentTime) / totalTime > 0.1) { reenterableAnimation.addEventListener("finish", () => { onlyPopFrame(1); }); From 44b7354e742f843c7b5f448ea492ef5966fb5f74 Mon Sep 17 00:00:00 2001 From: thislight Date: Mon, 18 Nov 2024 18:34:25 +0800 Subject: [PATCH 18/18] StackedRouter: minor cleanup --- src/platform/StackedRouter.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/platform/StackedRouter.tsx b/src/platform/StackedRouter.tsx index 7c82858..e1477f1 100644 --- a/src/platform/StackedRouter.tsx +++ b/src/platform/StackedRouter.tsx @@ -16,7 +16,6 @@ import "./StackedRouter.css"; import { animateSlideInFromRight, animateSlideOutToRight } from "./anim"; import { ANIM_CURVE_DECELERATION, ANIM_CURVE_STD } from "../material/theme"; import { - eventListener, makeEventListener, } from "@solid-primitives/event-listener";