From 76f7e08e78d33eb17e935582e88c58d90a1dc52d Mon Sep 17 00:00:00 2001 From: thislight Date: Thu, 21 Nov 2024 22:31:56 +0800 Subject: [PATCH 1/5] StackedRouter: blocks the ios swipe gesture - added more docs --- src/platform/StackedRouter.tsx | 79 ++++++++++++++++++++++++----- src/timelines/PullDownToRefresh.tsx | 4 +- 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/src/platform/StackedRouter.tsx b/src/platform/StackedRouter.tsx index eab70ea..991c436 100644 --- a/src/platform/StackedRouter.tsx +++ b/src/platform/StackedRouter.tsx @@ -79,6 +79,11 @@ export type Navigator> = { const NavigatorContext = /* @__PURE__ */ createContext(); +/** + * Get the possible navigator of the {@link StackedRouter}. + * + * @see useNavigator for the navigator usage. + */ export function useMaybeNavigator() { return useContext(NavigatorContext); } @@ -91,6 +96,8 @@ export function useMaybeNavigator() { * 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. + * + * @see {@link useMaybeNavigator} if you are not sure you are under a {@link StackedRouter}. */ export function useNavigator() { const navigator = useMaybeNavigator(); @@ -110,10 +117,20 @@ export type CurrentFrame = { const CurrentFrameContext = /* @__PURE__ */ createContext>>(); +/** + * Return the current, if possible. + * + * @see {@link useCurrentFrame} asserts the frame exists + */ export function useMaybeCurrentFrame() { return useContext(CurrentFrameContext); } +/** + * Return the current frame, assert the frame exists. + * + * @see {@link useMaybeCurrentFrame} if you are not sure you are under a {@link StackedRouter}. + */ export function useCurrentFrame() { const frame = useMaybeCurrentFrame(); @@ -130,8 +147,11 @@ export function useCurrentFrame() { * 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. + * + * If this is not called under a {@link StackedRouter}, it always + * returns `false`. */ -export function useMaybeIsFrameSuspended() { +export function useIsFrameSuspended() { const { frames } = useMaybeNavigator() || {}; if (typeof frames === "undefined") { @@ -203,10 +223,31 @@ function serializableStack(stack: readonly StackFrame[]) { }); } +function isNotInIOSSwipeToBackArea(x: number) { + return ( + (x > 22 && x < window.innerWidth - 22) || + (x < -22 && x > window.innerWidth + 22) + ); +} + +function onEntryTouchStart(event: TouchEvent) { + if (event.touches.length !== 1) { + return; + } + + const [fig0] = event.touches; + + if (isNotInIOSSwipeToBackArea(fig0.clientX)) { + return; + } + + event.preventDefault(); +} + /** * The router that stacks the pages. * - * **Routes** The router accepts the {@link RouteProps} excluding the "url" field. + * **Routes** The router accepts the {@link RouterProps} excluding the "url" field. * You can seamlessly use the `` from `@solidjs/router`. * * Be advised that this component is not a drop-in replacement of that router. @@ -240,7 +281,7 @@ function serializableStack(stack: readonly StackFrame[]) { * Navigation animations (even the custom ones) will be played during * swipe to back, please keep in mind when designing animations. * - * The iOS default gesture is blocked on those pages. + * The iOS default gesture is blocked on all pages. */ const StackedRouter: Component = (oprops) => { const [stack, mutStack] = createStore([] as StackFrame[], { name: "stack" }); @@ -388,11 +429,7 @@ const StackedRouter: Component = (oprops) => { origFigX = fig0.clientX; origFigY = fig0.clientY; - const isNotInSwipeToBackArea = - (fig0.clientX > 22 && fig0.clientX < window.innerWidth - 22) || - (fig0.clientX < -22 && fig0.clientX > window.innerWidth + 22); - - if (isNotInSwipeToBackArea) { + if (isNotInIOSSwipeToBackArea(fig0.clientX)) { return; } // Prevent the default swipe to back/forward on iOS @@ -400,6 +437,21 @@ const StackedRouter: Component = (oprops) => { event.preventDefault(); }; + let animationProgressUpdateRequested = false + let nextAnimationProgress = 0 + + const updateAnimationProgress = () => { + if (!reenterableAnimation) return; + + const { activeDuration, delay } = + reenterableAnimation.effect!.getComputedTiming(); + + const totalTime = (delay || 0) + Number(activeDuration); + reenterableAnimation.currentTime = totalTime * nextAnimationProgress; + + animationProgressUpdateRequested = false + } + const onDialogTouchMove = ( event: TouchEvent & { currentTarget: HTMLDialogElement }, ) => { @@ -429,13 +481,13 @@ const StackedRouter: Component = (oprops) => { event.preventDefault(); event.stopPropagation(); - const pc = ofsX / origWidth / window.devicePixelRatio; + nextAnimationProgress = ofsX / origWidth / window.devicePixelRatio; - const { activeDuration, delay } = - reenterableAnimation.effect!.getComputedTiming(); + if (!animationProgressUpdateRequested) { + animationProgressUpdateRequested = true; - const totalTime = (delay || 0) + Number(activeDuration); - reenterableAnimation.currentTime = totalTime * pc; + requestAnimationFrame(updateAnimationProgress) + } }; const onDialogTouchEnd = (event: TouchEvent) => { @@ -492,6 +544,7 @@ const StackedRouter: Component = (oprops) => { class="StackedPage" id={frame().rootId} role="presentation" + on:touchstart={onEntryTouchStart} > diff --git a/src/timelines/PullDownToRefresh.tsx b/src/timelines/PullDownToRefresh.tsx index 7cbacdb..5a61b46 100644 --- a/src/timelines/PullDownToRefresh.tsx +++ b/src/timelines/PullDownToRefresh.tsx @@ -10,7 +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"; +import { useIsFrameSuspended } from "../platform/StackedRouter"; const PullDownToRefresh: Component<{ loading?: boolean; @@ -34,7 +34,7 @@ const PullDownToRefresh: Component<{ }); const rootVisible = obvx(() => rootElement); - const isFrameSuspended = useMaybeIsFrameSuspended() + const isFrameSuspended = useIsFrameSuspended() createEffect(() => { if (!rootVisible()) setPullDown(0); From 37b38be1d2c44c871fdacc75c512db8d2f836136 Mon Sep 17 00:00:00 2001 From: thislight Date: Thu, 21 Nov 2024 22:53:31 +0800 Subject: [PATCH 2/5] StackedRouter: contain swipe to back state --- src/platform/StackedRouter.tsx | 248 ++++++++++++++++++--------------- 1 file changed, 132 insertions(+), 116 deletions(-) diff --git a/src/platform/StackedRouter.tsx b/src/platform/StackedRouter.tsx index 991c436..0686c9b 100644 --- a/src/platform/StackedRouter.tsx +++ b/src/platform/StackedRouter.tsx @@ -244,6 +244,136 @@ function onEntryTouchStart(event: TouchEvent) { event.preventDefault(); } +/** + * This function contains the state for swipe to back. + * + * @returns the props for dialogs to feature swipe to back. + */ +function createManagedSwipeToBack( + stack: readonly Readonly[], + onlyPopFrame: (depth: number) => void, +) { + let reenterableAnimation: Animation | undefined; + let origWidth = 0, + origFigX = 0, + origFigY = 0; + + const resetAnimation = () => { + reenterableAnimation = undefined; + }; + + const onDialogTouchStart = ( + event: TouchEvent & { currentTarget: HTMLDialogElement }, + ) => { + if (event.touches.length !== 1) { + return; + } + event.stopPropagation(); + + const [fig0] = event.touches; + const { width } = event.currentTarget.getBoundingClientRect(); + origWidth = width; + origFigX = fig0.clientX; + origFigY = fig0.clientY; + + if (isNotInIOSSwipeToBackArea(fig0.clientX)) { + return; + } + // Prevent the default swipe to back/forward on iOS + + event.preventDefault(); + }; + + let animationProgressUpdateReleased = true; + let nextAnimationProgress = 0; + + const updateAnimationProgress = () => { + try { + if (!reenterableAnimation) return; + const { activeDuration, delay } = + reenterableAnimation.effect!.getComputedTiming(); + + const totalTime = (delay || 0) + Number(activeDuration); + reenterableAnimation.currentTime = totalTime * nextAnimationProgress; + } finally { + animationProgressUpdateReleased = true; + } + }; + + const onDialogTouchMove = ( + event: TouchEvent & { currentTarget: HTMLDialogElement }, + ) => { + if (event.touches.length !== 1) { + if (reenterableAnimation) { + reenterableAnimation.reverse(); + reenterableAnimation.play(); + } + } + + const [fig0] = event.touches; + + const ofsX = fig0.clientX - origFigX; + + if (!reenterableAnimation) { + if (!(ofsX > 22) || !(Math.abs(fig0.clientY - origFigY) < 44)) { + return; + } + 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); + } + + event.preventDefault(); + event.stopPropagation(); + + nextAnimationProgress = ofsX / origWidth / window.devicePixelRatio; + + if (animationProgressUpdateReleased) { + animationProgressUpdateReleased = false; + + requestAnimationFrame(updateAnimationProgress); + } + }; + + 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 { + "on:touchstart": onDialogTouchStart, + "on:touchmove": onDialogTouchMove, + "on:touchend": onDialogTouchEnd, + "on:touchcancel": onDialogTouchCancel, + }; +} + /** * The router that stacks the pages. * @@ -389,7 +519,6 @@ const StackedRouter: Component = (oprops) => { const oinsetRight = computedStyle .getPropertyValue("--safe-area-inset-right") .split("px", 1)[0]; - console.debug("insets-inline", oinsetLeft, oinsetRight); const left = Number(oinsetLeft), right = Number(oinsetRight.slice(0, oinsetRight.length - 2)); const totalWidth = SUBPAGE_MAX_WIDTH + left + right; @@ -406,117 +535,7 @@ const StackedRouter: Component = (oprops) => { }; }); - let reenterableAnimation: Animation | undefined; - let origWidth = 0, - origFigX = 0, - origFigY = 0; - - const resetAnimation = () => { - reenterableAnimation = undefined; - }; - - const onDialogTouchStart = ( - event: TouchEvent & { currentTarget: HTMLDialogElement }, - ) => { - if (event.touches.length !== 1) { - return; - } - event.stopPropagation(); - - const [fig0] = event.touches; - const { width } = event.currentTarget.getBoundingClientRect(); - origWidth = width; - origFigX = fig0.clientX; - origFigY = fig0.clientY; - - if (isNotInIOSSwipeToBackArea(fig0.clientX)) { - return; - } - // Prevent the default swipe to back/forward on iOS - - event.preventDefault(); - }; - - let animationProgressUpdateRequested = false - let nextAnimationProgress = 0 - - const updateAnimationProgress = () => { - if (!reenterableAnimation) return; - - const { activeDuration, delay } = - reenterableAnimation.effect!.getComputedTiming(); - - const totalTime = (delay || 0) + Number(activeDuration); - reenterableAnimation.currentTime = totalTime * nextAnimationProgress; - - animationProgressUpdateRequested = false - } - - const onDialogTouchMove = ( - event: TouchEvent & { currentTarget: HTMLDialogElement }, - ) => { - if (event.touches.length !== 1) { - if (reenterableAnimation) { - reenterableAnimation.reverse(); - reenterableAnimation.play(); - } - } - - const [fig0] = event.touches; - - const ofsX = fig0.clientX - origFigX; - - if (!reenterableAnimation) { - if (!(ofsX > 22) || !(Math.abs(fig0.clientY - origFigY) < 44)) { - return; - } - 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); - } - - event.preventDefault(); - event.stopPropagation(); - - nextAnimationProgress = ofsX / origWidth / window.devicePixelRatio; - - if (!animationProgressUpdateRequested) { - animationProgressUpdateRequested = true; - - requestAnimationFrame(updateAnimationProgress) - } - }; - - 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(); - }; + const swipeToBackProps = createManagedSwipeToBack(stack, onlyPopFrame); return ( = (oprops) => { class="StackedPage" onCancel={[popFrame, 1]} onClick={[onDialogClick, popFrame]} - on:touchstart={onDialogTouchStart} - on:touchmove={onDialogTouchMove} - on:touchend={onDialogTouchEnd} - on:touchcancel={onDialogTouchCancel} + {...swipeToBackProps} id={frame().rootId} style={subInsets()} > From b58e2a50e3d8c1bb862bf11fc45c023f4ef6c3ea Mon Sep 17 00:00:00 2001 From: thislight Date: Thu, 21 Nov 2024 22:54:00 +0800 Subject: [PATCH 3/5] TootContent: remove unused composes property --- src/timelines/toots/TootContent.css | 1 - 1 file changed, 1 deletion(-) diff --git a/src/timelines/toots/TootContent.css b/src/timelines/toots/TootContent.css index 79c4ffc..b45411c 100644 --- a/src/timelines/toots/TootContent.css +++ b/src/timelines/toots/TootContent.css @@ -1,5 +1,4 @@ .TootContent { - composes: cardNoPad from "../material/cards.module.css"; margin-left: calc(var(--card-pad, 0) + var(--toot-avatar-size, 0) + 8px); margin-right: var(--card-pad, 0); line-height: 1.5; From 7e5692549d94c8da3ad73483c25e2c3e4a6ca7a0 Mon Sep 17 00:00:00 2001 From: thislight Date: Thu, 21 Nov 2024 23:35:56 +0800 Subject: [PATCH 4/5] MediaAttachmentGrid: preload=metadata for videos --- src/timelines/MediaAttachmentGrid.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/timelines/MediaAttachmentGrid.tsx b/src/timelines/MediaAttachmentGrid.tsx index 8fd14e3..65097b8 100644 --- a/src/timelines/MediaAttachmentGrid.tsx +++ b/src/timelines/MediaAttachmentGrid.tsx @@ -207,6 +207,7 @@ const MediaAttachmentGrid: Component<{ style={style()} data-sort={index} data-media-type={item().type} + preload="metadata" /> @@ -222,6 +223,7 @@ const MediaAttachmentGrid: Component<{ style={style()} data-sort={index} data-media-type={item().type} + preload="metadata" /> From 57b242c93f24e93aa3d215e5894c0900db8af9e0 Mon Sep 17 00:00:00 2001 From: thislight Date: Thu, 21 Nov 2024 23:45:26 +0800 Subject: [PATCH 5/5] RegularToot: remove the left margin of content --- src/timelines/MediaAttachmentGrid.css | 6 +++++- src/timelines/toots/TootContent.css | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/timelines/MediaAttachmentGrid.css b/src/timelines/MediaAttachmentGrid.css index 880151a..34b22c9 100644 --- a/src/timelines/MediaAttachmentGrid.css +++ b/src/timelines/MediaAttachmentGrid.css @@ -1,7 +1,7 @@ .MediaAttachmentGrid { /* Note: MeidaAttachmentGrid has hard-coded layout calcalation */ margin-top: 1em; - margin-left: calc(var(--card-pad, 0) + var(--toot-avatar-size, 0) + 8px); + margin-left: var(--card-pad, 0); margin-right: var(--card-pad, 0); gap: 4px; contain: layout style; @@ -33,4 +33,8 @@ align-items: center; justify-content: center; } +} + +:where(.thread-top, .thread-mid) > .MediaAttachmentGrid { + margin-left: calc(var(--card-pad, 0) + var(--toot-avatar-size, 0) + 8px); } \ No newline at end of file diff --git a/src/timelines/toots/TootContent.css b/src/timelines/toots/TootContent.css index b45411c..86ed977 100644 --- a/src/timelines/toots/TootContent.css +++ b/src/timelines/toots/TootContent.css @@ -1,5 +1,5 @@ .TootContent { - margin-left: calc(var(--card-pad, 0) + var(--toot-avatar-size, 0) + 8px); + margin-left: var(--card-pad, 0); margin-right: var(--card-pad, 0); line-height: 1.5; @@ -29,4 +29,8 @@ } } } +} + +:where(.thread-top, .thread-mid) > .TootContent { + margin-left: calc(var(--card-pad, 0) + var(--toot-avatar-size, 0) + 8px); } \ No newline at end of file