pl-fe: support MFM

Signed-off-by: Nicole Mikołajczyk <git@mkljczk.pl>
This commit is contained in:
Nicole Mikołajczyk
2025-05-27 11:09:14 +02:00
parent 3e1a9f6cb8
commit 7c58922dce
8 changed files with 811 additions and 37 deletions

View File

@ -23,11 +23,11 @@ const GREENTEXT_CLASS = 'dark:text-accent-green text-lime-600';
const nodesToText = (nodes: Array<DOMNode>): string =>
nodes.map(node => node.type === 'text' ? node.data : node.type === 'tag' ? nodesToText(node.children as Array<DOMNode>) : '').join('');
const isHostNotVisible = (href: string, nodes: Array<DOMNode>): false | string => {
const isHostNotVisible = (href: string, text?: string): false | string => {
try {
let { host } = new URL(href);
host = host.replace(/^www\./, '');
const text = nodesToText(nodes).trim();
if (!text) return host;
if (new RegExp(`^(https?://)?(www.)?${host}(/|$)`, 'i').test(text)) {
return false;
@ -39,6 +39,55 @@ const isHostNotVisible = (href: string, nodes: Array<DOMNode>): false | string =
}
};
interface IParsedUrl extends React.HTMLAttributes<HTMLAnchorElement> {
/** Whether to call a function to remove tracking parameters from URLs. */
cleanUrls?: boolean;
/** Whether to call a function to redirect URLs to popular websites to privacy-respecting proxy services. */
redirectUrls?: boolean;
/** Whether to display link target domain when it's not part of the text. */
displayTargetHost?: boolean;
href: string;
childrenPlain?: string;
}
const ParsedUrl: React.FC<IParsedUrl> = React.memo((props) => {
const { urlPrivacy } = useSettings();
props = { ...props };
if (props.cleanUrls === undefined) props.cleanUrls = urlPrivacy.clearLinksInContent;
if (props.redirectUrls === undefined) props.redirectUrls = urlPrivacy.redirectLinksMode !== 'off';
if (props.displayTargetHost === undefined) props.displayTargetHost = urlPrivacy.displayTargetHost;
let href = props.href;
if (props.cleanUrls) {
try {
href = Purify.clearUrl(href, props.cleanUrls, props.redirectUrls);
} catch (_) {
//
}
}
const host = props.displayTargetHost && isHostNotVisible(href, props.childrenPlain);
return (
<a
{...props}
href={href}
onClick={(e) => e.stopPropagation()}
rel='nofollow noopener noreferrer'
target='_blank'
title={props.href}
>
{props.children}
{host && (
<span className='text-xs lowercase'>{' '}[{host}]</span>
)}
</a>
);
});
interface IParsedContent {
/** HTML content to display. */
html: string;
@ -177,34 +226,39 @@ function parseContent({
domNode.greentext = true;
}
let href = domNode.attribs.href;
// let href = domNode.attribs.href;
if (cleanUrls) {
try {
href = Purify.clearUrl(href, cleanUrls, redirectUrls);
} catch (_) {
//
}
}
// if (cleanUrls) {
// try {
// href = Purify.clearUrl(href, cleanUrls, redirectUrls);
// } catch (_) {
// //
// }
// }
const host = displayTargetHost && isHostNotVisible(href, domNode.children as Array<DOMNode>);
// const host = displayTargetHost && isHostNotVisible(href, domNode.children as Array<DOMNode>);
const fallback = (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<a
{...domNode.attribs}
href={href}
onClick={(e) => e.stopPropagation()}
rel='nofollow noopener noreferrer'
target='_blank'
title={domNode.attribs.href}
>
<ParsedUrl {...domNode.attribs as any} childrenPlain={nodesToText(domNode.children as Array<DOMNode>).trim()}>
{domToReact(domNode.children as Array<DOMNode>, options)}
{host && (
<span className='text-xs lowercase'>{' '}[{host}]</span>
)}
</a>
</ParsedUrl>
);
// (
// // eslint-disable-next-line jsx-a11y/no-static-element-interactions
// <a
// {...domNode.attribs}
// href={href}
// onClick={(e) => e.stopPropagation()}
// rel='nofollow noopener noreferrer'
// target='_blank'
// title={domNode.attribs.href}
// >
// {domToReact(domNode.children as Array<DOMNode>, options)}
// {host && (
// <span className='text-xs lowercase'>{' '}[{host}]</span>
// )}
// </a>
// );
if (classes?.includes('mention')) {
if (mentions) {
@ -291,4 +345,4 @@ const ParsedContent: React.FC<IParsedContent> = React.memo((props) => {
return parseContent(props, false);
}, (prevProps, nextProps) => prevProps.html === nextProps.html);
export { ParsedContent, parseContent };
export { ParsedContent, ParsedUrl, parseContent };

View File

@ -0,0 +1,524 @@
// ~~Shamelessly stolen~~ ported to React from Sharkey
// https://activitypub.software/TransFem-org/Sharkey/-/blob/develop/packages/frontend/src/components/global/MkMfm.ts
import * as mfm from '@transfem-org/sfm-js';
import { clamp } from 'lodash';
import React, { CSSProperties } from 'react';
import { Link } from 'react-router-dom';
import { useSettings } from 'pl-fe/hooks/use-settings';
import { makeEmojiMap } from 'pl-fe/utils/normalizers';
import nyaize from 'pl-fe/utils/nyaize';
import HashtagLink from './hashtag-link';
import HoverAccountWrapper from './hover-account-wrapper';
import { ParsedUrl } from './parsed-content';
import Emoji from './ui/emoji';
import type { CustomEmoji, Mention } from 'pl-api';
const safeParseFloat = (str: unknown): number | null => {
if (typeof str !== 'string' || str === '') return null;
const num = parseFloat(str);
if (isNaN(num)) return null;
return num;
};
const validTime = (t: string | boolean | null | undefined) => {
if (t === null || t === undefined) return null;
if (typeof t === 'boolean') return null;
return t.match(/^\-?[0-9.]+s$/) ? t : null;
};
const validColor = (c: unknown): string | null => {
if (typeof c !== 'string') return null;
return c.match(/^[0-9a-f]{3,6}$/i) ? c : null;
};
interface IParsedMfm {
text: string;
emojis: Array<CustomEmoji>;
mentions?: Array<Mention>;
speakAsCat?: boolean;
}
const ParsedMfm: React.FC<IParsedMfm> = React.memo(({ text, emojis, mentions, speakAsCat }) => {
const rootAst = mfm.parse(text);
const { renderAdvancedMfm, renderAnimatedMfm } = useSettings();
const emojiMap = makeEmojiMap(emojis);
const genPlainText = (ast: mfm.MfmNode[]) => ast.map((token): string => {
if (token.type === 'text') return token.props.text;
if (token.children) return genPlainText(token.children);
return '';
}).join('');
const genEl = (ast: mfm.MfmNode[], scale: number) => ast.map((token): JSX.Element | string | (JSX.Element | string)[] => {
switch (token.type) {
case 'text': {
let text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n');
if (speakAsCat) text = nyaize(text);
const res: (JSX.Element | string)[] = [];
for (const t of text.split('\n')) {
res.push(<br />);
res.push(t);
}
res.shift();
return res;
}
case 'bold': {
return (
<b>{genEl(token.children, scale)}</b>
);
}
case 'strike': {
return (
<del>{genEl(token.children, scale)}</del>
);
}
case 'italic': {
return (
<i className='italic'>{genEl(token.children, scale)}</i>
);
}
case 'fn': {
let style: CSSProperties | undefined;
switch (token.props.name) {
case 'tada': {
const speed = validTime(token.props.args.speed) ?? '1s';
const delay = validTime(token.props.args.delay) ?? '0s';
style = {
fontSize: '150%',
...renderAnimatedMfm ? { animation: `global-tada ${speed} linear infinite both`, animationDelay: delay } : {},
};
break;
}
case 'jelly': {
const speed = validTime(token.props.args.speed) ?? '1s';
const delay = validTime(token.props.args.delay) ?? '0s';
if (renderAnimatedMfm) style = {
animation: `mfm-rubber-band ${speed} linear infinite both`,
animationDelay: delay,
};
break;
}
case 'twitch': {
const speed = validTime(token.props.args.speed) ?? '0.5s';
const delay = validTime(token.props.args.delay) ?? '0s';
style = renderAnimatedMfm ? {
animation: `mfm-twitch ${speed} ease infinite`,
animationDelay: delay,
} : {};
break;
}
case 'shake': {
const speed = validTime(token.props.args.speed) ?? '0.5s';
const delay = validTime(token.props.args.delay) ?? '0s';
style = renderAnimatedMfm ? {
animation: `mfm-shake ${speed} ease infinite`,
animationDelay: delay,
} : {};
break;
}
case 'spin': {
const direction =
token.props.args.left ? 'reverse' :
token.props.args.alternate ? 'alternate' :
'normal';
const anime =
token.props.args.x ? 'mfm-spin-x' :
token.props.args.y ? 'mfm-spin-y' :
'mfm-spin';
const speed = validTime(token.props.args.speed) ?? '1.5s';
const delay = validTime(token.props.args.delay) ?? '0s';
style = renderAnimatedMfm ? { animation: `${anime} ${speed} linear infinite`, animationDirection: direction, animationDelay: delay } : {};
break;
}
case 'jump': {
const speed = validTime(token.props.args.speed) ?? '0.75s';
const delay = validTime(token.props.args.delay) ?? '0s';
style = renderAnimatedMfm ? { animation: `mfm-jump ${speed} linear infinite`, animationDelay: delay } : {};
break;
}
case 'bounce': {
const speed = validTime(token.props.args.speed) ?? '0.75s';
const delay = validTime(token.props.args.delay) ?? '0s';
style = renderAnimatedMfm ? { animation: `mfm-bounce ${speed} linear infinite`, transformOrigin: 'center bottom', animationDelay: delay } : {};
break;
}
case 'flip': {
const transform =
(token.props.args.h && token.props.args.v) ? 'scale(-1, -1)' :
token.props.args.v ? 'scaleY(-1)' :
'scaleX(-1)';
style = { transform };
break;
}
case 'x2': {
return (
<span className={renderAdvancedMfm ? 'mfm-x2' : ''}>
{genEl(token.children, scale * 2)}
</span>
);
}
case 'x3': {
return (
<span className={renderAdvancedMfm ? 'mfm-x3' : ''}>
{genEl(token.children, scale * 3)}
</span>
);
}
case 'x4': {
return (
<span className={renderAdvancedMfm ? 'mfm-x4' : ''}>
{genEl(token.children, scale * 4)}
</span>
);
}
case 'font': {
const family =
token.props.args.serif ? 'serif' :
token.props.args.monospace ? 'monospace' :
token.props.args.cursive ? 'cursive' :
token.props.args.fantasy ? 'fantasy' :
token.props.args.emoji ? 'emoji' :
token.props.args.math ? 'math' :
null;
if (family) style = { fontFamily: family };
break;
}
case 'blur': {
return (
<span className='_mfm_blur_'>
{genEl(token.children, scale)}
</span>
);
}
case 'rainbow': {
if (!renderAnimatedMfm) {
return (
<span className='_mfm_rainbow_fallback_'>
{genEl(token.children, scale)}
</span>
);
}
const speed = validTime(token.props.args.speed) ?? '1s';
const delay = validTime(token.props.args.delay) ?? '0s';
style = { animation: `mfm-rainbow ${speed} linear infinite`, animationDelay: delay };
break;
}
case 'sparkle': {
// if (!renderAnimatedMfm) {
return genEl(token.children, scale).flat();
// }
// return h(MkSparkle, {}, genEl(token.children, scale));
}
case 'fade': {
if (!renderAnimatedMfm) {
style = {};
break;
}
const direction = token.props.args.out
? 'alternate-reverse'
: 'alternate';
const speed = validTime(token.props.args.speed) ?? '1.5s';
const delay = validTime(token.props.args.delay) ?? '0s';
const loop = safeParseFloat(token.props.args.loop) ?? 'infinite';
style = { animation: `mfm-fade ${speed} ${delay} linear ${loop}`, animationDirection: direction };
break;
}
case 'rotate': {
const degrees = safeParseFloat(token.props.args.deg) ?? 90;
style = { transform: `rotate(${degrees}deg); transform-origin: center center` };
break;
}
// TODO
// case 'followmouse': {
case 'position': {
if (!renderAdvancedMfm) break;
const x = safeParseFloat(token.props.args.x) ?? 0;
const y = safeParseFloat(token.props.args.y) ?? 0;
style = { transform: `translateX(${x}em) translateY(${y}em)` };
break;
}
case 'crop': {
const top = Number.parseFloat(
(token.props.args.top ?? '0').toString(),
);
const right = Number.parseFloat(
(token.props.args.right ?? '0').toString(),
);
const bottom = Number.parseFloat(
(token.props.args.bottom ?? '0').toString(),
);
const left = Number.parseFloat(
(token.props.args.left ?? '0').toString(),
);
style = { clipPath: `inset(${top}% ${right}% ${bottom}% ${left}%)` };
break;
}
case 'scale': {
if (!renderAdvancedMfm) {
style = {};
break;
}
const x = clamp(safeParseFloat(token.props.args.x) ?? 1, -5, 5);
const y = clamp(safeParseFloat(token.props.args.y) ?? 1, -5, 5);
style = { transform: `scale(${x}, ${y})` };
scale = scale * Math.max(Math.abs(x), Math.abs(y));
break;
}
case 'fg': {
let color = validColor(token.props.args.color);
color = color ?? 'f00';
style = { color: color, overflowWrap: 'anywhere' };
break;
}
case 'bg': {
let color = validColor(token.props.args.color);
color = color ?? 'f00';
style = { backgroundColor: color, overflowWrap: 'anywhere' };
break;
}
case 'border': {
let color = validColor(token.props.args.color);
color = color ? `#${color}` : 'var(--MI_THEME-accent)';
let b_style = token.props.args.style;
if (
typeof b_style !== 'string' ||
!['hidden', 'dotted', 'dashed', 'solid', 'double', 'groove', 'ridge', 'inset', 'outset']
.includes(b_style)
) b_style = 'solid';
const width = safeParseFloat(token.props.args.width) ?? 1;
const radius = safeParseFloat(token.props.args.radius) ?? 0;
style = {
border: `${width}px ${b_style} ${color}`,
borderRadius: `${radius}px`,
...token.props.args.noclip ? {} : { overflow: 'clip' },
};
break;
}
case 'ruby': {
if (token.children.length === 1) {
const child = token.children[0];
let text = child.type === 'text' ? child.props.text : '';
if (speakAsCat) {
text = nyaize(text);
}
return (
<ruby>
{text.split(' ')[0]}
<rt>
{text.split(' ')[1]}
</rt>
</ruby>
);
} else {
const rt = token.children.at(-1)!;
let text = rt.type === 'text' ? rt.props.text : '';
if (speakAsCat) {
text = nyaize(text);
}
return (
<ruby>
{genEl(token.children.slice(0, token.children.length - 1), scale)}
<rt>
{text.trim()}
</rt>
</ruby>
);
}
}
case 'group': { // this is mostly a hack for the insides of `ruby`
style = {};
break;
}
// TODO
// case 'unixtime': {
// case 'clickable': {
}
if (style === undefined) {
return (
<span>
{`$[${token.props.name} `}
{genEl(token.children, scale)}
]
</span>
);
} else {
return (
<span
className='inline-block'
style={style}
>
{genEl(token.children, scale)}
</span>
);
}
}
case 'small': {
return (
<small className='opacity-70'>
{genEl(token.children, scale)}
</small>
);
}
case 'center': {
return (
<div className='text-center'>
<bdi>
{genEl(token.children, scale)}
</bdi>
</div>
);
}
case 'url': {
return (
<bdi>
<ParsedUrl href={token.props.url} childrenPlain={token.props.url}>
{token.props.url}
</ParsedUrl>
</bdi>
);
}
case 'link': {
return (
<bdi>
<ParsedUrl href={token.props.url} displayTargetHost childrenPlain={genPlainText(token.children)}>
{genEl(token.children, scale)}
</ParsedUrl>
</bdi>
);
}
case 'mention': {
if (mentions) {
const mention = mentions.find(({ acct }) => token.props.acct.slice(1) === acct);
if (mention) {
return (
<bdi>
<HoverAccountWrapper accountId={mention.id} element='span'>
<Link
to={`/@${mention.acct}`}
className='text-primary-600 hover:underline dark:text-accent-blue'
dir='ltr'
onClick={(e) => e.stopPropagation()}
>
@{mention.username}
</Link>
</HoverAccountWrapper>
</bdi>
);
}
}
return (
<bdi>
<Link
to={`/@${token.props.acct.slice(1)}`}
className='text-primary-600 hover:underline dark:text-accent-blue'
dir='ltr'
onClick={(e) => e.stopPropagation()}
>
@{token.props.username}
</Link>
</bdi>
);
}
case 'hashtag': {
return (
<HashtagLink hashtag={token.props.hashtag} />
);
}
case 'blockCode': {
return (
<bdi className='block'>
<pre lang={token.props.lang || undefined}>
{token.props.code}
</pre>
</bdi>
);
}
case 'inlineCode':
return (
<bdi>
<code>
{token.props.code}
</code>
</bdi>
);
case 'quote': {
return (
<blockquote>
<bdi>{genEl(token.children, scale)}</bdi>
</blockquote>
);
}
case 'emojiCode': {
const emoji = emojiMap[`:${token.props.name}:`];
if (!emoji) return <bdi>{token.props.name}</bdi>;
const filename = emoji.static_url;
if (filename?.length > 0) {
return <img draggable={false} className='emojione !h-[2em] !w-[2em] transition-transform hover:scale-125' alt={token.props.name} title={token.props.name} src={filename} />;
}
return <bdi>{token.props.name}</bdi>;
}
case 'unicodeEmoji': {
return <Emoji emoji={token.props.emoji} className='!h-[2em] !w-[2em]' />;
}
// TODO
// case 'mathInline':
// case 'mathBlock':
// case 'search':
case 'plain': {
return (
<bdi>
<span>
{genEl(token.children, scale)}
</span>
</bdi>
);
}
default: {
console.error('unrecognized ast type:', token.type, token);
return [];
}
}
}).flat(Infinity);
return (
<bdi className='plfe-mfm'>
<span>
{genEl(rootAst, 1)}
</span>
</bdi>
);
});
export { ParsedMfm };

View File

@ -19,6 +19,7 @@ import { getTextDirection } from '../utils/rtl';
import HashtagsBar from './hashtags-bar';
import Markup from './markup';
import { parseContent } from './parsed-content';
import { ParsedMfm } from './parsed-mfm';
import Poll from './polls/poll';
import StatusMedia from './status-media';
import SensitiveContentOverlay from './statuses/sensitive-content-overlay';
@ -82,7 +83,7 @@ const StatusContent: React.FC<IStatusContent> = React.memo(({
preview,
withMedia,
}) => {
const { urlPrivacy, displaySpoilers } = useSettings();
const { urlPrivacy, displaySpoilers, renderMfm } = useSettings();
const { greentext } = usePlFeConfig();
const [collapsed, setCollapsed] = useState<boolean | null>(null);
@ -141,17 +142,26 @@ const StatusContent: React.FC<IStatusContent> = React.memo(({
[status.content, translation, statusMeta.currentLanguage],
);
const { content: parsedContent, hashtags } = useMemo(() => parseContent({
html: content,
mentions: status.mentions,
hasQuote: !!status.quote_id,
emojis: status.emojis,
cleanUrls: urlPrivacy.clearLinksInContent,
redirectUrls: urlPrivacy.redirectLinksMode !== 'off',
displayTargetHost: urlPrivacy.displayTargetHost,
greentext,
speakAsCat: status.account.speak_as_cat,
}, true), [content]);
const { content: parsedContent, hashtags } = useMemo(() => {
if (renderMfm && !translation && status.content_type === 'text/x.misskeymarkdown' && status.text) {
return {
content: <ParsedMfm text={status.text} emojis={status.emojis} mentions={status.mentions} />,
hashtags: [],
};
}
return parseContent({
html: content,
mentions: status.mentions,
hasQuote: !!status.quote_id,
emojis: status.emojis,
cleanUrls: urlPrivacy.clearLinksInContent,
redirectUrls: urlPrivacy.redirectLinksMode !== 'off',
displayTargetHost: urlPrivacy.displayTargetHost,
greentext,
speakAsCat: status.account.speak_as_cat,
}, true);
}, [content, renderMfm]);
useEffect(() => {
setLineClamp(!spoilerNode.current || spoilerNode.current.clientHeight >= 96);

View File

@ -352,6 +352,29 @@ const Preferences = () => {
</ListItem>
</List>
{instance.pleroma.metadata.post_formats.includes('text/x.misskeymarkdown') && (
<List>
<ListItem
label={<FormattedMessage id='preferences.fields.render_mfm_label' defaultMessage='Render Misskey Flavored Markdown' />}
hint={<FormattedMessage id='preferences.fields.render_mfm_hint' defaultMessage='MFM support is experimental, not all node types are supported.' />}
>
<SettingToggle settings={settings} settingPath={['renderMfm']} onChange={onToggleChange} />
</ListItem>
<ListItem
label={<FormattedMessage id='preferences.fields.render_advanced_mfm_label' defaultMessage='Enable advanced MFM' />}
>
<SettingToggle settings={settings} settingPath={['renderAdvancedMfm']} onChange={onToggleChange} disabled={!settings.renderMfm} />
</ListItem>
<ListItem
label={<FormattedMessage id='preferences.fields.render_animated_mfm_label' defaultMessage='Enable animated MFM' />}
>
<SettingToggle settings={settings} settingPath={['renderAnimatedMfm']} onChange={onToggleChange} disabled={!settings.renderMfm} />
</ListItem>
</List>
)}
{features.translations && (
<List>
<ListItem label={<FormattedMessage id='preferences.fields.auto_translate_label' defaultMessage='Automatically translate posts in unknown languages' />}>

View File

@ -1354,6 +1354,10 @@
"preferences.fields.preserve_spoilers_label": "Preserve content warning when replying",
"preferences.fields.privacy_label": "Default post privacy",
"preferences.fields.reduce_motion_label": "Reduce motion in animations",
"preferences.fields.render_advanced_mfm_label": "Enable advanced MFM",
"preferences.fields.render_animated_mfm_label": "Enable animated MFM",
"preferences.fields.render_mfm_hint": "MFM support is experimental, not all node types are supported.",
"preferences.fields.render_mfm_label": "Render Misskey Flavored Markdown",
"preferences.fields.spoilers_display_label": "Automatically expand text behind spoilers",
"preferences.fields.system_emoji_font_label": "Use system emoji font",
"preferences.fields.system_font_label": "Use system's default font",

View File

@ -10,6 +10,9 @@ const settingsSchema = v.object({
onboarded: v.fallback(v.boolean(), false),
skinTone: v.fallback(skinToneSchema, 1),
reduceMotion: v.fallback(v.boolean(), false),
renderMfm: v.fallback(v.boolean(), false),
renderAdvancedMfm: v.fallback(v.boolean(), true),
renderAnimatedMfm: v.fallback(v.boolean(), true),
underlineLinks: v.fallback(v.boolean(), false),
autoPlayGif: v.fallback(v.boolean(), true),
displayMedia: v.fallback(v.picklist(['default', 'hide_all', 'show_all']), 'default'),

View File

@ -18,3 +18,4 @@
@use 'forms';
@use 'utilities';
@use 'components/datepicker';
@use 'mfm';

View File

@ -0,0 +1,155 @@
// Shamelessly stolen from Sharkey
// https://activitypub.software/TransFem-org/Sharkey/-/blob/develop/packages/frontend/src/style.scss
.plfe-mfm {
._mfm_blur_ {
filter: blur(6px);
transition: filter 0.3s;
&:hover {
filter: blur(0);
}
}
.mfm-x2 {
--mfm-zoom-size: 200%;
}
.mfm-x3 {
--mfm-zoom-size: 400%;
}
.mfm-x4 {
--mfm-zoom-size: 600%;
}
.mfm-x2, .mfm-x3, .mfm-x4 {
font-size: var(--mfm-zoom-size);
.mfm-x2, .mfm-x3, .mfm-x4 {
/* only half effective */
font-size: calc(var(--mfm-zoom-size) / 2 + 50%);
.mfm-x2, .mfm-x3, .mfm-x4 {
/* disabled */
font-size: 100%;
}
}
}
._mfm_rainbow_fallback_ {
background-image: linear-gradient(to right, rgb(255, 0, 0) 0%, rgb(255, 165, 0) 17%, rgb(255, 255, 0) 33%, rgb(0, 255, 0) 50%, rgb(0, 255, 255) 67%, rgb(0, 0, 255) 83%, rgb(255, 0, 255) 100%);
background-clip: text;
color: transparent;
}
}
@keyframes mfm-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes mfm-spin-x {
0% { transform: perspective(128px) rotateX(0deg); }
100% { transform: perspective(128px) rotateX(360deg); }
}
@keyframes mfm-spin-y {
0% { transform: perspective(128px) rotateY(0deg); }
100% { transform: perspective(128px) rotateY(360deg); }
}
@keyframes mfm-jump {
0% { transform: translateY(0); }
25% { transform: translateY(-16px); }
50% { transform: translateY(0); }
75% { transform: translateY(-8px); }
100% { transform: translateY(0); }
}
@keyframes mfm-bounce {
0% { transform: translateY(0) scale(1, 1); }
25% { transform: translateY(-16px) scale(1, 1); }
50% { transform: translateY(0) scale(1, 1); }
75% { transform: translateY(0) scale(1.5, 0.75); }
100% { transform: translateY(0) scale(1, 1); }
}
// const val = () => `translate(${Math.floor(Math.random() * 20) - 10}px, ${Math.floor(Math.random() * 20) - 10}px)`;
// let css = '';
// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; }
@keyframes mfm-twitch {
0% { transform: translate(7px, -2px) }
5% { transform: translate(-3px, 1px) }
10% { transform: translate(-7px, -1px) }
15% { transform: translate(0, -1px) }
20% { transform: translate(-8px, 6px) }
25% { transform: translate(-4px, -3px) }
30% { transform: translate(-4px, -6px) }
35% { transform: translate(-8px, -8px) }
40% { transform: translate(4px, 6px) }
45% { transform: translate(-3px, 1px) }
50% { transform: translate(2px, -10px) }
55% { transform: translate(-7px, 0) }
60% { transform: translate(-2px, 4px) }
65% { transform: translate(3px, -8px) }
70% { transform: translate(6px, 7px) }
75% { transform: translate(-7px, -2px) }
80% { transform: translate(-7px, -8px) }
85% { transform: translate(9px, 3px) }
90% { transform: translate(-3px, -2px) }
95% { transform: translate(-10px, 2px) }
100% { transform: translate(-2px, -6px) }
}
// const val = () => `translate(${Math.floor(Math.random() * 6) - 3}px, ${Math.floor(Math.random() * 6) - 3}px) rotate(${Math.floor(Math.random() * 24) - 12}deg)`;
// let css = '';
// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; }
@keyframes mfm-shake {
0% { transform: translate(-3px, -1px) rotate(-8deg) }
5% { transform: translate(0, -1px) rotate(-10deg) }
10% { transform: translate(1px, -3px) rotate(0deg) }
15% { transform: translate(1px, 1px) rotate(11deg) }
20% { transform: translate(-2px, 1px) rotate(1deg) }
25% { transform: translate(-1px, -2px) rotate(-2deg) }
30% { transform: translate(-1px, 2px) rotate(-3deg) }
35% { transform: translate(2px, 1px) rotate(6deg) }
40% { transform: translate(-2px, -3px) rotate(-9deg) }
45% { transform: translate(0, -1px) rotate(-12deg) }
50% { transform: translate(1px, 2px) rotate(10deg) }
55% { transform: translate(0, -3px) rotate(8deg) }
60% { transform: translate(1px, -1px) rotate(8deg) }
65% { transform: translate(0, -1px) rotate(-7deg) }
70% { transform: translate(-1px, -3px) rotate(6deg) }
75% { transform: translate(0, -2px) rotate(4deg) }
80% { transform: translate(-2px, -1px) rotate(3deg) }
85% { transform: translate(1px, -3px) rotate(-10deg) }
90% { transform: translate(1px, 0) rotate(3deg) }
95% { transform: translate(-2px, 0) rotate(-3deg) }
100% { transform: translate(2px, 1px) rotate(2deg) }
}
@keyframes mfm-rubber-band {
0% { transform: scale3d(1, 1, 1); }
30% { transform: scale3d(1.25, 0.75, 1); }
40% { transform: scale3d(0.75, 1.25, 1); }
50% { transform: scale3d(1.15, 0.85, 1); }
65% { transform: scale3d(0.95, 1.05, 1); }
75% { transform: scale3d(1.05, 0.95, 1); }
100% { transform: scale3d(1, 1, 1); }
}
@keyframes mfm-rainbow {
0% { filter: hue-rotate(0deg) contrast(150%) saturate(150%); }
100% { filter: hue-rotate(360deg) contrast(150%) saturate(150%); }
}
@keyframes mfm-fade {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}