diff --git a/docs/devnotes.md b/docs/devnotes.md index cf6e9a0..31f8cb9 100644 --- a/docs/devnotes.md +++ b/docs/devnotes.md @@ -10,6 +10,10 @@ You can debug on the Safari on iOS only if you have mac (and run macOS). The cer - For visual bugs: on you iDevice, redirect the localhost.direct to your dev computer. Now you have the hot reload on you iDevice. - You can use network debugging apps like "Shadowrocket" to do such thing. +## Hero Animation won't work (after hot reload) + +That's a known issue. Hot reload won't refresh the module sets the hero cache. Refresh the whole page and it should work. + ## The components don't react to the change as I setting the store, until the page reloaded The `WritableAtom.set` might do an equals check. You must set a different object to ensure the atom sending a notify. @@ -43,9 +47,3 @@ Ja, the code is weird, but that's the best we know. Anyway, you need new object Idk why, but transition on logical directions may not work on WebKit - sometimes they work. Use physical directions to avoid trouble, like "margin-top, margin-bottom". - -## Safe area insets - -For isolating control of the UI effect, we already setup css variables `--safe-area-inset-*`. In components, you should use the variables unless you have reasons to use `env()`. - -Using `--safe-area-inset-*`, you can control the global value in settings (under dev mode). diff --git a/src/material/BottomSheet.tsx b/src/material/BottomSheet.tsx index a5d5d90..c7e0671 100644 --- a/src/material/BottomSheet.tsx +++ b/src/material/BottomSheet.tsx @@ -9,10 +9,12 @@ import { type ResolvedChildren, } from "solid-js"; import "./BottomSheet.css"; +import { useHeroSignal } from "../platform/anim"; import material from "./material.module.css"; import { ANIM_CURVE_ACELERATION, ANIM_CURVE_DECELERATION, + ANIM_CURVE_STD, } from "./theme"; import { animateSlideInFromRight, @@ -26,11 +28,32 @@ export type BottomSheetProps = { onClose?(reason: "backdrop"): void; }; +export const HERO = Symbol("BottomSheet Hero Symbol"); + +function composeAnimationFrame( + { + top, + left, + height, + width, + }: Record<"top" | "left" | "height" | "width", number>, + x: Record, +) { + return { + top: `${top}px`, + left: `${left}px`, + height: `${height}px`, + width: `${width}px`, + ...x, + }; +} + const MOVE_SPEED = 1600; const BottomSheet: ParentComponent = (props) => { let element: HTMLDialogElement; let animation: Animation | undefined; + const [hero, setHero] = useHeroSignal(HERO); const [cache, setCache] = createSignal(); const ochildren = children(() => props.children); @@ -51,11 +74,24 @@ const BottomSheet: ParentComponent = (props) => { }); const onClose = () => { + const srcElement = hero(); + if (srcElement) { + srcElement.style.visibility = "unset"; + } + element.close(); + setHero(); }; const animatedClose = () => { - + const srcElement = hero(); + const endRect = srcElement?.getBoundingClientRect(); + if (endRect) { + const startRect = element.getBoundingClientRect(); + const animation = animateHero(startRect, endRect, element, true); + animation.addEventListener("finish", onClose); + animation.addEventListener("cancel", onClose); + } else { if (window.innerWidth > 560 && !props.bottomUp) { onClose(); return; @@ -70,12 +106,21 @@ const BottomSheet: ParentComponent = (props) => { : animateSlideOutToRight(element, { easing: ANIM_CURVE_ACELERATION }); animation.addEventListener("finish", onAnimationEnd); animation.addEventListener("cancel", onAnimationEnd); - + } }; const animatedOpen = () => { element.showModal(); - if (props.bottomUp) { + const srcElement = hero(); + const startRect = srcElement?.getBoundingClientRect(); + if (!startRect) { + console.debug("no source element"); + } + if (startRect) { + srcElement!.style.visibility = "hidden"; + const endRect = element.getBoundingClientRect(); + animateHero(startRect, endRect, element); + } else if (props.bottomUp) { animateSlideInFromBottom(element); } else if (window.innerWidth <= 560) { element.classList.add("animated"); @@ -120,6 +165,41 @@ const BottomSheet: ParentComponent = (props) => { return animation; }; + const animateHero = ( + startRect: DOMRect, + endRect: DOMRect, + element: HTMLElement, + reserve?: boolean, + ) => { + const easing = ANIM_CURVE_STD; + element.classList.add("animated"); + // distance_lt = (|top_start - top_end|^2 + |left_end - left_end|^2)^(-2) + const distancelt = Math.sqrt( + Math.pow(Math.abs(startRect.top - endRect.top), 2) + + Math.pow(Math.abs(startRect.left - endRect.left), 2), + ); + const distancerb = Math.sqrt( + Math.pow(Math.abs(startRect.bottom - endRect.bottom), 2) + + Math.pow(Math.abs(startRect.right - endRect.right), 2), + ); + const distance = distancelt + distancerb; + const duration = distance / 1.6; + animation = element.animate( + [ + composeAnimationFrame(startRect, { transform: "none" }), + composeAnimationFrame(endRect, { transform: "none" }), + ], + { easing, duration }, + ); + const onAnimationEnd = () => { + element.classList.remove("animated"); + animation = undefined; + }; + animation.addEventListener("finish", onAnimationEnd); + animation.addEventListener("cancel", onAnimationEnd); + return animation; + }; + onCleanup(() => { if (animation) { animation.cancel(); diff --git a/src/material/Scaffold.css b/src/material/Scaffold.css index b4a7c74..56b2cd0 100644 --- a/src/material/Scaffold.css +++ b/src/material/Scaffold.css @@ -32,6 +32,7 @@ left: 0; right: 0; z-index: var(--tutu-zidx-nav, auto); + padding-bottom: var(--safe-area-inset-bottom, 0); } .Scaffold { diff --git a/src/platform/anim.ts b/src/platform/anim.ts index f606872..dfffc87 100644 --- a/src/platform/anim.ts +++ b/src/platform/anim.ts @@ -1,3 +1,58 @@ +import { + createContext, + createRenderEffect, + createSignal, + untrack, + useContext, + type Accessor, + type Signal, +} from "solid-js"; + +export type HeroSource = { + [key: string | symbol | number]: HTMLElement | undefined; +}; + +const HeroSourceContext = createContext>( + /* __@PURE__ */ undefined, +); + +export const HeroSourceProvider = HeroSourceContext.Provider; + +export function useHeroSource() { + return useContext(HeroSourceContext); +} + +/** + * Use hero value for the {@link key}. + * + * Note: the setter here won't set the value of the hero source. + * To set hero source, use {@link useHeroSource} and set the corresponding key. + */ +export function useHeroSignal( + key: string | symbol | number, +): Signal { + const source = useHeroSource(); + if (source) { + const [get, set] = createSignal(); + + createRenderEffect(() => { + const value = source[0](); + if (value[key]) { + set(value[key]); + source[1]((x) => { + const cpy = Object.assign({}, x); + delete cpy[key]; + return cpy; + }); + } + }); + + return [get, set]; + } else { + console.debug("no hero source"); + return [() => undefined, () => undefined]; + } +} export function animateRollOutFromTop( root: HTMLElement, diff --git a/src/timelines/TootComposer.tsx b/src/timelines/TootComposer.tsx index 5693332..50d5e0e 100644 --- a/src/timelines/TootComposer.tsx +++ b/src/timelines/TootComposer.tsx @@ -98,7 +98,7 @@ const TootVisibilityPickerDialog: Component<{ style={{ "border-top": "1px solid #ddd", background: "var(--tutu-color-surface)", - padding: "8px 16px calc(8px + var(--safe-area-inset-bottom, 0px))", + padding: "8px 16px", width: "100%", "text-align": "end", }} diff --git a/src/timelines/TootList.tsx b/src/timelines/TootList.tsx index 41e01f7..38670e2 100644 --- a/src/timelines/TootList.tsx +++ b/src/timelines/TootList.tsx @@ -10,6 +10,8 @@ import { import { type mastodon } from "masto"; import { vibrate } from "../platform/hardware"; 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 RegularToot, { findElementActionable, @@ -51,6 +53,7 @@ const TootList: Component<{ onChangeToot: (id: string, value: mastodon.v1.Status) => void; }> = (props) => { const session = useDefaultSession(); + const heroSrc = useHeroSource(); const [expandedThreadId, setExpandedThreadId] = createSignal(); const { push } = useNavigator(); @@ -121,6 +124,9 @@ const TootList: Component<{ console.warn("no account info?"); return; } + if (heroSrc) { + heroSrc[1]((x) => ({ ...x, [BOTTOM_SHEET_HERO]: srcElement })); + } const acct = `${inf.username}@${p.site}`; setTootBottomSheetCache(acct, toot);