Compare commits

...

6 commits

Author SHA1 Message Date
thislight
8a435be4c8
BottomSheet: adjust hero animation
All checks were successful
/ depoly (push) Successful in 1m17s
2024-11-03 18:39:09 +08:00
thislight
06e988e0e5
TootFilterButton: show the menu in the click pos 2024-11-03 18:24:10 +08:00
thislight
c69d54e171
Profile: remove the divier when no pinned toots 2024-11-03 18:23:24 +08:00
thislight
012b078cee
anim: adjust speed for grow and shrink 2024-11-03 18:10:28 +08:00
thislight
20a5e565b1
Profile: add items into menu 2024-11-03 18:00:13 +08:00
thislight
b61012f12b
BottomSheet: move slides animations to platform 2024-11-03 17:36:56 +08:00
5 changed files with 216 additions and 78 deletions

View file

@ -11,6 +11,15 @@ import {
import "./BottomSheet.css"; import "./BottomSheet.css";
import { useHeroSignal } from "../platform/anim"; import { useHeroSignal } from "../platform/anim";
import material from "./material.module.css"; import material from "./material.module.css";
import {
ANIM_CURVE_ACELERATION,
ANIM_CURVE_DECELERATION,
ANIM_CURVE_STD,
} from "./theme";
import {
animateSlideInFromRight,
animateSlideOutToRight,
} from "../platform/anim";
export type BottomSheetProps = { export type BottomSheetProps = {
open?: boolean; open?: boolean;
@ -39,7 +48,7 @@ function composeAnimationFrame(
}; };
} }
const MOVE_SPEED = 1200; const MOVE_SPEED = 1600;
const BottomSheet: ParentComponent<BottomSheetProps> = (props) => { const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
let element: HTMLDialogElement; let element: HTMLDialogElement;
@ -87,11 +96,16 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
onClose(); onClose();
return; return;
} }
const animation = props.bottomUp const onAnimationEnd = () => {
element.classList.remove("animated");
onClose();
};
element.classList.add("animated");
animation = props.bottomUp
? animateSlideInFromBottom(element, true) ? animateSlideInFromBottom(element, true)
: animateSlideInFromRight(element, true); : animateSlideOutToRight(element, { easing: ANIM_CURVE_ACELERATION });
animation.addEventListener("finish", onClose); animation.addEventListener("finish", onAnimationEnd);
animation.addEventListener("cancel", onClose); animation.addEventListener("cancel", onAnimationEnd);
} }
}; };
@ -109,35 +123,16 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
} else if (props.bottomUp) { } else if (props.bottomUp) {
animateSlideInFromBottom(element); animateSlideInFromBottom(element);
} else if (window.innerWidth <= 560) { } else if (window.innerWidth <= 560) {
animateSlideInFromRight(element);
}
};
const animateSlideInFromRight = (element: HTMLElement, reserve?: boolean) => {
const rect = element.getBoundingClientRect();
const easing = "cubic-bezier(0.4, 0, 0.2, 1)";
element.classList.add("animated"); element.classList.add("animated");
const oldOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const distance = Math.abs(rect.left - window.innerWidth);
const duration = (distance / MOVE_SPEED) * 1000;
animation = element.animate(
{
left: reserve
? [`${rect.left}px`, `${window.innerWidth}px`]
: [`${window.innerWidth}px`, `${rect.left}px`],
},
{ easing, duration },
);
const onAnimationEnd = () => { const onAnimationEnd = () => {
element.classList.remove("animated"); element.classList.remove("animated");
document.body.style.overflow = oldOverflow;
animation = undefined;
}; };
animation.addEventListener("cancel", onAnimationEnd); animation = animateSlideInFromRight(element, {
easing: ANIM_CURVE_DECELERATION,
});
animation.addEventListener("finish", onAnimationEnd); animation.addEventListener("finish", onAnimationEnd);
return animation; animation.addEventListener("cancel", onAnimationEnd);
}
}; };
const animateSlideInFromBottom = ( const animateSlideInFromBottom = (
@ -176,13 +171,19 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
element: HTMLElement, element: HTMLElement,
reserve?: boolean, reserve?: boolean,
) => { ) => {
const easing = "cubic-bezier(0.4, 0, 0.2, 1)"; const easing = ANIM_CURVE_STD;
element.classList.add("animated"); element.classList.add("animated");
const distance = Math.sqrt( // 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.top - endRect.top), 2) +
Math.pow(Math.abs(startRect.left - startRect.top), 2), Math.pow(Math.abs(startRect.left - endRect.left), 2),
); );
const duration = (distance / MOVE_SPEED) * 1000; 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( animation = element.animate(
[ [
composeAnimationFrame(startRect, { transform: "none" }), composeAnimationFrame(startRect, { transform: "none" }),

View file

@ -13,10 +13,12 @@ import {
animateShrinkToTopRight, animateShrinkToTopRight,
} from "../platform/anim"; } from "../platform/anim";
type Anchor = Pick<DOMRect, "top" | "left" | "right">
type Props = { type Props = {
open?: boolean; open?: boolean;
onClose?: JSX.EventHandlerUnion<HTMLDialogElement, Event>; onClose?: JSX.EventHandlerUnion<HTMLDialogElement, Event>;
anchor: () => DOMRect; anchor: () => Anchor;
}; };
function px(n?: number) { function px(n?: number) {

View file

@ -49,7 +49,7 @@ export function useHeroSignal(
return [get, set]; return [get, set];
} else { } else {
console.debug("no hero source") console.debug("no hero source");
return [() => undefined, () => undefined]; return [() => undefined, () => undefined];
} }
} }
@ -85,7 +85,6 @@ export function animateRollOutFromTop(
return animation; return animation;
} }
export function animateRollInFromBottom( export function animateRollInFromBottom(
root: HTMLElement, root: HTMLElement,
options?: Omit<KeyframeAnimationOptions, "duration">, options?: Omit<KeyframeAnimationOptions, "duration">,
@ -126,8 +125,10 @@ export function animateGrowFromTopRight(
const { width, height } = root.getBoundingClientRect(); const { width, height } = root.getBoundingClientRect();
const durationX = Math.floor((height / 1600) * 1000); const speed = transitionSpeedForEnter(window.innerHeight);
const durationY = Math.floor((width / 1600) * 1000);
const durationX = Math.floor(height / speed);
const durationY = Math.floor(width / speed);
// finds the offset for the center frame, // finds the offset for the center frame,
// it will stops at the (minDuration / maxDuration)% // it will stops at the (minDuration / maxDuration)%
@ -137,20 +138,19 @@ export function animateGrowFromTopRight(
const centerOffset = minDuration / maxDuration; const centerOffset = minDuration / maxDuration;
const keyframes = [ const keyframes = [
{ transform: "scaleX(0)", opacity: 0, height: "0px", offset: 0 }, { transform: "scaleX(0.5)", opacity: 0, height: "0px", offset: 0 },
{ {
transform: `scaleX(${minDuration === durationX ? "1" : centerOffset})`, transform: `scaleX(${minDuration === durationX ? "1" : centerOffset / 2 + 0.5})`,
height: `${(minDuration === durationY ? 1 : centerOffset) * height}px`, height: `${(minDuration === durationY ? 1 : centerOffset) * height}px`,
offset: centerOffset, offset: centerOffset,
opacity: 1,
}, },
{ transform: "scaleX(1)", height: `${height}px`, offset: 1 }, { transform: "scaleX(1)", height: `${height}px`, opacity: 1, offset: 1 },
]; ];
const animation = root.animate( const animation = root.animate(keyframes, {
keyframes, ...options,
{ ...options, duration: maxDuration }, duration: maxDuration,
); });
const restore = () => { const restore = () => {
root.style.transformOrigin = transformOrigin; root.style.transformOrigin = transformOrigin;
@ -173,9 +173,9 @@ export function animateShrinkToTopRight(
const { width, height } = root.getBoundingClientRect(); const { width, height } = root.getBoundingClientRect();
const duration = Math.floor( const speed = transitionSpeedForLeave(window.innerWidth);
Math.max((width / 1600) * 1000, (height / 1600) * 1000),
); const duration = Math.floor(Math.max(width / speed, height / speed));
const animation = root.animate( const animation = root.animate(
{ {
@ -195,3 +195,105 @@ export function animateShrinkToTopRight(
return animation; return animation;
} }
// Contribution to the animation speed:
// - the screen size: mobiles should have longer transition,
// the transition time should be longer as the travelling distance longer,
// but it's not linear. The larger screen should have higher velocity,
// to avoid the transition is too long.
// As the screen larger, on desktops, the transition should be simpler and
// signficantly faster.
// On much smaller screens, like wearables, the transition should be shorter
// than on mobiles.
// - Animation complexity: On mobile:
// - large, complex, full-screen transitions may have longer durations, over 375ms
// - entering screen over 225ms
// - leaving screen over 195ms
function transitionSpeedForEnter(innerWidth: number) {
if (innerWidth < 300) {
return 2.4;
} else if (innerWidth < 560) {
return 1.6;
} else if (innerWidth < 1200) {
return 2.4;
} else {
return 2.55;
}
}
function transitionSpeedForLeave(innerWidth: number) {
if (innerWidth < 300) {
return 2.8;
} else if (innerWidth < 560) {
return 1.96;
} else if (innerWidth < 1200) {
return 2.8;
} else {
return 2.55;
}
}
export function animateSlideInFromRight(
root: HTMLElement,
options?: Omit<KeyframeAnimationOptions, "duration">,
) {
const { left } = root.getBoundingClientRect();
const { innerWidth } = window;
const oldOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const distance = Math.abs(left - innerWidth);
const duration = Math.floor(distance / transitionSpeedForEnter(innerWidth));
const opts = Object.assign({ duration }, options);
const animation = root.animate(
{
left: [`${innerWidth}px`, `${left}px`],
},
opts,
);
const restore = () => {
document.body.style.overflow = oldOverflow;
};
animation.addEventListener("cancel", restore);
animation.addEventListener("finish", restore);
return animation;
}
export function animateSlideOutToRight(
root: HTMLElement,
options?: Omit<KeyframeAnimationOptions, "duration">,
) {
const { left } = root.getBoundingClientRect();
const { innerWidth } = window;
const oldOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const distance = Math.abs(left - innerWidth);
const duration = Math.floor(distance / transitionSpeedForLeave(innerWidth));
const opts = Object.assign({ duration }, options);
const animation = root.animate(
{
left: [`${left}px`, `${innerWidth}px`],
},
opts,
);
const restore = () => {
document.body.style.overflow = oldOverflow;
};
animation.addEventListener("cancel", restore);
animation.addEventListener("finish", restore);
return animation;
}

View file

@ -26,10 +26,14 @@ import {
Close, Close,
Edit, Edit,
ExpandMore, ExpandMore,
Group,
MoreVert, MoreVert,
OpenInBrowser, OpenInBrowser,
PersonOff,
PlaylistAdd,
Send, Send,
Share, Share,
Translate,
Verified, Verified,
} from "@suid/icons-material"; } from "@suid/icons-material";
import { Title } from "../material/typography"; import { Title } from "../material/typography";
@ -90,6 +94,10 @@ const Profile: Component = () => {
console.error(err); console.error(err);
}); });
const isCurrentSessionProfile = () => {
return session().account?.inf?.url === profile()?.url;
};
const [recentTootFilter, setRecentTootFilter] = createSignal({ const [recentTootFilter, setRecentTootFilter] = createSignal({
pinned: true, pinned: true,
boost: false, boost: false,
@ -260,6 +268,17 @@ const Profile: Component = () => {
anchor={() => anchor={() =>
document.getElementById(menuButId)!.getBoundingClientRect() document.getElementById(menuButId)!.getBoundingClientRect()
} }
>
<Show
when={isCurrentSessionProfile()}
fallback={
<MenuItem disabled>
<ListItemIcon>
<PlaylistAdd />
</ListItemIcon>
<ListItemText>Subscribe...</ListItemText>
</MenuItem>
}
> >
<MenuItem disabled> <MenuItem disabled>
<ListItemIcon> <ListItemIcon>
@ -267,12 +286,31 @@ const Profile: Component = () => {
</ListItemIcon> </ListItemIcon>
<ListItemText>Edit...</ListItemText> <ListItemText>Edit...</ListItemText>
</MenuItem> </MenuItem>
</Show>
<Divider /> <Divider />
<MenuItem disabled>
<ListItemIcon>
<Group />
</ListItemIcon>
<ListItemText>Subscribers</ListItemText>
</MenuItem>
<MenuItem disabled>
<ListItemIcon>
<PersonOff />
</ListItemIcon>
<ListItemText>Blocklist</ListItemText>
</MenuItem>
<MenuItem disabled>
<ListItemIcon>
<Translate />
</ListItemIcon>
<ListItemText>Translate Name and Bio...</ListItemText>
</MenuItem>
<MenuItem disabled> <MenuItem disabled>
<ListItemIcon> <ListItemIcon>
<Send /> <Send />
</ListItemIcon> </ListItemIcon>
<ListItemText>Mention {profile()?.displayName || ""}...</ListItemText> <ListItemText>Mention in...</ListItemText>
</MenuItem> </MenuItem>
<Divider /> <Divider />
<MenuItem <MenuItem
@ -399,7 +437,7 @@ const Profile: Component = () => {
</div> </div>
<TimeSourceProvider value={time}> <TimeSourceProvider value={time}>
<Show when={recentTootFilter().pinned}> <Show when={recentTootFilter().pinned && pinnedToots.list.length > 0}>
<TootList <TootList
threads={pinnedToots.list} threads={pinnedToots.list}
onUnknownThread={pinnedToots.getPath} onUnknownThread={pinnedToots.getPath}

View file

@ -1,15 +1,5 @@
import { import { Button, MenuItem, Checkbox, ListItemText } from "@suid/material";
Button, import { createMemo, createSignal, createUniqueId, For } from "solid-js";
MenuItem,
Checkbox,
ListItemText,
} from "@suid/material";
import {
createMemo,
createSignal,
createUniqueId,
For,
} from "solid-js";
import Menu from "../material/Menu"; import Menu from "../material/Menu";
import { FilterList, FilterListOff } from "@suid/icons-material"; import { FilterList, FilterListOff } from "@suid/icons-material";
@ -68,9 +58,20 @@ function TootFilterButton<F extends Record<string, string>>(props: Props<F>) {
); );
}; };
let anchor: { left: number; top: number; right: number };
const onClick = (event: MouseEvent) => {
anchor = {
left: event.clientX,
right: event.clientX,
top: event.clientY,
};
setOpen(true);
};
return ( return (
<> <>
<Button size="large" onClick={[setOpen, true]} id={buttonId}> <Button size="large" onClick={onClick} id={buttonId}>
{appliedKeys().length === optionKeys().length ? ( {appliedKeys().length === optionKeys().length ? (
<FilterListOff /> <FilterListOff />
) : ( ) : (
@ -79,13 +80,7 @@ function TootFilterButton<F extends Record<string, string>>(props: Props<F>) {
<span style={{ "margin-left": "0.5em" }}>{text()}</span> <span style={{ "margin-left": "0.5em" }}>{text()}</span>
</Button> </Button>
<Menu <Menu open={open()} onClose={[setOpen, false]} anchor={() => anchor}>
open={open()}
onClose={[setOpen, false]}
anchor={() =>
document.getElementById(buttonId)!.getBoundingClientRect()
}
>
<For each={Object.keys(props.options)}> <For each={Object.keys(props.options)}>
{(item, idx) => ( {(item, idx) => (
<> <>