Compare commits
5 commits
6726ffe664
...
57b242c93f
Author | SHA1 | Date | |
---|---|---|---|
|
57b242c93f | ||
|
7e5692549d | ||
|
b58e2a50e3 | ||
|
37b38be1d2 | ||
|
76f7e08e78 |
5 changed files with 191 additions and 113 deletions
|
@ -79,6 +79,11 @@ export type Navigator<PushGuide = Record<string, any>> = {
|
||||||
|
|
||||||
const NavigatorContext = /* @__PURE__ */ createContext<Navigator>();
|
const NavigatorContext = /* @__PURE__ */ createContext<Navigator>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the possible navigator of the {@link StackedRouter}.
|
||||||
|
*
|
||||||
|
* @see useNavigator for the navigator usage.
|
||||||
|
*/
|
||||||
export function useMaybeNavigator() {
|
export function useMaybeNavigator() {
|
||||||
return useContext(NavigatorContext);
|
return useContext(NavigatorContext);
|
||||||
}
|
}
|
||||||
|
@ -91,6 +96,8 @@ export function useMaybeNavigator() {
|
||||||
* path and its state. If you need push guide, you may want to
|
* path and its state. If you need push guide, you may want to
|
||||||
* define your own function (like `useAppNavigator`) and cast the
|
* define your own function (like `useAppNavigator`) and cast the
|
||||||
* navigator to the type you need.
|
* navigator to the type you need.
|
||||||
|
*
|
||||||
|
* @see {@link useMaybeNavigator} if you are not sure you are under a {@link StackedRouter}.
|
||||||
*/
|
*/
|
||||||
export function useNavigator() {
|
export function useNavigator() {
|
||||||
const navigator = useMaybeNavigator();
|
const navigator = useMaybeNavigator();
|
||||||
|
@ -110,10 +117,20 @@ export type CurrentFrame = {
|
||||||
const CurrentFrameContext =
|
const CurrentFrameContext =
|
||||||
/* @__PURE__ */ createContext<Accessor<Readonly<CurrentFrame>>>();
|
/* @__PURE__ */ createContext<Accessor<Readonly<CurrentFrame>>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the current, if possible.
|
||||||
|
*
|
||||||
|
* @see {@link useCurrentFrame} asserts the frame exists
|
||||||
|
*/
|
||||||
export function useMaybeCurrentFrame() {
|
export function useMaybeCurrentFrame() {
|
||||||
return useContext(CurrentFrameContext);
|
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() {
|
export function useCurrentFrame() {
|
||||||
const frame = useMaybeCurrentFrame();
|
const frame = useMaybeCurrentFrame();
|
||||||
|
|
||||||
|
@ -130,8 +147,11 @@ export function useCurrentFrame() {
|
||||||
* A suspended frame is the one not on the top. "Suspended"
|
* A suspended frame is the one not on the top. "Suspended"
|
||||||
* is the description of a certain situtation, not in the life cycle
|
* is the description of a certain situtation, not in the life cycle
|
||||||
* of a frame.
|
* 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() || {};
|
const { frames } = useMaybeNavigator() || {};
|
||||||
|
|
||||||
if (typeof frames === "undefined") {
|
if (typeof frames === "undefined") {
|
||||||
|
@ -203,10 +223,161 @@ 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function contains the state for swipe to back.
|
||||||
|
*
|
||||||
|
* @returns the props for dialogs to feature swipe to back.
|
||||||
|
*/
|
||||||
|
function createManagedSwipeToBack(
|
||||||
|
stack: readonly Readonly<StackFrame>[],
|
||||||
|
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.
|
* 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 `<Route />` from `@solidjs/router`.
|
* You can seamlessly use the `<Route />` from `@solidjs/router`.
|
||||||
*
|
*
|
||||||
* Be advised that this component is not a drop-in replacement of that router.
|
* Be advised that this component is not a drop-in replacement of that router.
|
||||||
|
@ -240,7 +411,7 @@ function serializableStack(stack: readonly StackFrame[]) {
|
||||||
* Navigation animations (even the custom ones) will be played during
|
* Navigation animations (even the custom ones) will be played during
|
||||||
* swipe to back, please keep in mind when designing animations.
|
* 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<StackedRouterProps> = (oprops) => {
|
const StackedRouter: Component<StackedRouterProps> = (oprops) => {
|
||||||
const [stack, mutStack] = createStore([] as StackFrame[], { name: "stack" });
|
const [stack, mutStack] = createStore([] as StackFrame[], { name: "stack" });
|
||||||
|
@ -348,7 +519,6 @@ const StackedRouter: Component<StackedRouterProps> = (oprops) => {
|
||||||
const oinsetRight = computedStyle
|
const oinsetRight = computedStyle
|
||||||
.getPropertyValue("--safe-area-inset-right")
|
.getPropertyValue("--safe-area-inset-right")
|
||||||
.split("px", 1)[0];
|
.split("px", 1)[0];
|
||||||
console.debug("insets-inline", oinsetLeft, oinsetRight);
|
|
||||||
const left = Number(oinsetLeft),
|
const left = Number(oinsetLeft),
|
||||||
right = Number(oinsetRight.slice(0, oinsetRight.length - 2));
|
right = Number(oinsetRight.slice(0, oinsetRight.length - 2));
|
||||||
const totalWidth = SUBPAGE_MAX_WIDTH + left + right;
|
const totalWidth = SUBPAGE_MAX_WIDTH + left + right;
|
||||||
|
@ -365,106 +535,7 @@ const StackedRouter: Component<StackedRouterProps> = (oprops) => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
let reenterableAnimation: Animation | undefined;
|
const swipeToBackProps = createManagedSwipeToBack(stack, onlyPopFrame);
|
||||||
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;
|
|
||||||
|
|
||||||
const isNotInSwipeToBackArea =
|
|
||||||
(fig0.clientX > 22 && fig0.clientX < window.innerWidth - 22) ||
|
|
||||||
(fig0.clientX < -22 && fig0.clientX > window.innerWidth + 22);
|
|
||||||
|
|
||||||
if (isNotInSwipeToBackArea) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Prevent the default swipe to back/forward on iOS
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
};
|
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<NavigatorContext.Provider
|
<NavigatorContext.Provider
|
||||||
|
@ -492,6 +563,7 @@ const StackedRouter: Component<StackedRouterProps> = (oprops) => {
|
||||||
class="StackedPage"
|
class="StackedPage"
|
||||||
id={frame().rootId}
|
id={frame().rootId}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
|
on:touchstart={onEntryTouchStart}
|
||||||
>
|
>
|
||||||
<StaticRouter url={frame().path} {...oprops} />
|
<StaticRouter url={frame().path} {...oprops} />
|
||||||
</div>
|
</div>
|
||||||
|
@ -502,10 +574,7 @@ const StackedRouter: Component<StackedRouterProps> = (oprops) => {
|
||||||
class="StackedPage"
|
class="StackedPage"
|
||||||
onCancel={[popFrame, 1]}
|
onCancel={[popFrame, 1]}
|
||||||
onClick={[onDialogClick, popFrame]}
|
onClick={[onDialogClick, popFrame]}
|
||||||
on:touchstart={onDialogTouchStart}
|
{...swipeToBackProps}
|
||||||
on:touchmove={onDialogTouchMove}
|
|
||||||
on:touchend={onDialogTouchEnd}
|
|
||||||
on:touchcancel={onDialogTouchCancel}
|
|
||||||
id={frame().rootId}
|
id={frame().rootId}
|
||||||
style={subInsets()}
|
style={subInsets()}
|
||||||
>
|
>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
.MediaAttachmentGrid {
|
.MediaAttachmentGrid {
|
||||||
/* Note: MeidaAttachmentGrid has hard-coded layout calcalation */
|
/* Note: MeidaAttachmentGrid has hard-coded layout calcalation */
|
||||||
margin-top: 1em;
|
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);
|
margin-right: var(--card-pad, 0);
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
contain: layout style;
|
contain: layout style;
|
||||||
|
@ -33,4 +33,8 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.thread-top, .thread-mid) > .MediaAttachmentGrid {
|
||||||
|
margin-left: calc(var(--card-pad, 0) + var(--toot-avatar-size, 0) + 8px);
|
||||||
}
|
}
|
|
@ -207,6 +207,7 @@ const MediaAttachmentGrid: Component<{
|
||||||
style={style()}
|
style={style()}
|
||||||
data-sort={index}
|
data-sort={index}
|
||||||
data-media-type={item().type}
|
data-media-type={item().type}
|
||||||
|
preload="metadata"
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={itemType() === "gifv"}>
|
<Match when={itemType() === "gifv"}>
|
||||||
|
@ -222,6 +223,7 @@ const MediaAttachmentGrid: Component<{
|
||||||
style={style()}
|
style={style()}
|
||||||
data-sort={index}
|
data-sort={index}
|
||||||
data-media-type={item().type}
|
data-media-type={item().type}
|
||||||
|
preload="metadata"
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={itemType() === "audio"}>
|
<Match when={itemType() === "audio"}>
|
||||||
|
|
|
@ -10,7 +10,7 @@ import { Refresh as RefreshIcon } from "@suid/icons-material";
|
||||||
import { CircularProgress } from "@suid/material";
|
import { CircularProgress } from "@suid/material";
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener";
|
import { makeEventListener } from "@solid-primitives/event-listener";
|
||||||
import { createVisibilityObserver } from "@solid-primitives/intersection-observer";
|
import { createVisibilityObserver } from "@solid-primitives/intersection-observer";
|
||||||
import { useMaybeIsFrameSuspended } from "../platform/StackedRouter";
|
import { useIsFrameSuspended } from "../platform/StackedRouter";
|
||||||
|
|
||||||
const PullDownToRefresh: Component<{
|
const PullDownToRefresh: Component<{
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
@ -34,7 +34,7 @@ const PullDownToRefresh: Component<{
|
||||||
});
|
});
|
||||||
|
|
||||||
const rootVisible = obvx(() => rootElement);
|
const rootVisible = obvx(() => rootElement);
|
||||||
const isFrameSuspended = useMaybeIsFrameSuspended()
|
const isFrameSuspended = useIsFrameSuspended()
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!rootVisible()) setPullDown(0);
|
if (!rootVisible()) setPullDown(0);
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
.TootContent {
|
.TootContent {
|
||||||
composes: cardNoPad from "../material/cards.module.css";
|
margin-left: var(--card-pad, 0);
|
||||||
margin-left: calc(var(--card-pad, 0) + var(--toot-avatar-size, 0) + 8px);
|
|
||||||
margin-right: var(--card-pad, 0);
|
margin-right: var(--card-pad, 0);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
|
||||||
|
@ -30,4 +29,8 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.thread-top, .thread-mid) > .TootContent {
|
||||||
|
margin-left: calc(var(--card-pad, 0) + var(--toot-avatar-size, 0) + 8px);
|
||||||
}
|
}
|
Loading…
Reference in a new issue