From 7c58922dce94502b0a4a00b50bb3070145de04e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicole=20Miko=C5=82ajczyk?= Date: Tue, 27 May 2025 11:09:14 +0200 Subject: [PATCH] pl-fe: support MFM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicole Mikołajczyk --- .../pl-fe/src/components/parsed-content.tsx | 104 +++- packages/pl-fe/src/components/parsed-mfm.tsx | 524 ++++++++++++++++++ .../pl-fe/src/components/status-content.tsx | 34 +- .../pl-fe/src/features/preferences/index.tsx | 23 + packages/pl-fe/src/locales/en.json | 4 + packages/pl-fe/src/schemas/pl-fe/settings.ts | 3 + packages/pl-fe/src/styles/application.scss | 1 + packages/pl-fe/src/styles/mfm.scss | 155 ++++++ 8 files changed, 811 insertions(+), 37 deletions(-) create mode 100644 packages/pl-fe/src/components/parsed-mfm.tsx create mode 100644 packages/pl-fe/src/styles/mfm.scss diff --git a/packages/pl-fe/src/components/parsed-content.tsx b/packages/pl-fe/src/components/parsed-content.tsx index cea3d34be..f805c6bb0 100644 --- a/packages/pl-fe/src/components/parsed-content.tsx +++ b/packages/pl-fe/src/components/parsed-content.tsx @@ -23,11 +23,11 @@ const GREENTEXT_CLASS = 'dark:text-accent-green text-lime-600'; const nodesToText = (nodes: Array): string => nodes.map(node => node.type === 'text' ? node.data : node.type === 'tag' ? nodesToText(node.children as Array) : '').join(''); -const isHostNotVisible = (href: string, nodes: Array): 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): false | string = } }; +interface IParsedUrl extends React.HTMLAttributes { + /** 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 = 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 ( + e.stopPropagation()} + rel='nofollow noopener noreferrer' + target='_blank' + title={props.href} + > + {props.children} + {host && ( + {' '}[{host}] + )} + + ); +}); + 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); + // const host = displayTargetHost && isHostNotVisible(href, domNode.children as Array); const fallback = ( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions - e.stopPropagation()} - rel='nofollow noopener noreferrer' - target='_blank' - title={domNode.attribs.href} - > + ).trim()}> {domToReact(domNode.children as Array, options)} - {host && ( - {' '}[{host}] - )} - + ); + // ( + // // eslint-disable-next-line jsx-a11y/no-static-element-interactions + // e.stopPropagation()} + // rel='nofollow noopener noreferrer' + // target='_blank' + // title={domNode.attribs.href} + // > + // {domToReact(domNode.children as Array, options)} + // {host && ( + // {' '}[{host}] + // )} + // + // ); if (classes?.includes('mention')) { if (mentions) { @@ -291,4 +345,4 @@ const ParsedContent: React.FC = React.memo((props) => { return parseContent(props, false); }, (prevProps, nextProps) => prevProps.html === nextProps.html); -export { ParsedContent, parseContent }; +export { ParsedContent, ParsedUrl, parseContent }; diff --git a/packages/pl-fe/src/components/parsed-mfm.tsx b/packages/pl-fe/src/components/parsed-mfm.tsx new file mode 100644 index 000000000..f59c3055b --- /dev/null +++ b/packages/pl-fe/src/components/parsed-mfm.tsx @@ -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; + mentions?: Array; + speakAsCat?: boolean; +} + +const ParsedMfm: React.FC = 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(
); + res.push(t); + } + res.shift(); + return res; + } + + case 'bold': { + return ( + {genEl(token.children, scale)} + ); + } + + case 'strike': { + return ( + {genEl(token.children, scale)} + ); + } + + case 'italic': { + return ( + {genEl(token.children, scale)} + ); + } + + 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 ( + + {genEl(token.children, scale * 2)} + + ); + } + case 'x3': { + return ( + + {genEl(token.children, scale * 3)} + + ); + } + case 'x4': { + return ( + + {genEl(token.children, scale * 4)} + + ); + } + 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 ( + + {genEl(token.children, scale)} + + ); + } + case 'rainbow': { + if (!renderAnimatedMfm) { + return ( + + {genEl(token.children, scale)} + + ); + } + 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 ( + + {text.split(' ')[0]} + + {text.split(' ')[1]} + + + ); + } else { + const rt = token.children.at(-1)!; + let text = rt.type === 'text' ? rt.props.text : ''; + if (speakAsCat) { + text = nyaize(text); + } + return ( + + {genEl(token.children.slice(0, token.children.length - 1), scale)} + + {text.trim()} + + + ); + } + } + case 'group': { // this is mostly a hack for the insides of `ruby` + style = {}; + break; + } + // TODO + // case 'unixtime': { + // case 'clickable': { + } + if (style === undefined) { + return ( + + {`$[${token.props.name} `} + {genEl(token.children, scale)} + ] + + ); + } else { + return ( + + {genEl(token.children, scale)} + + ); + } + } + + case 'small': { + return ( + + {genEl(token.children, scale)} + + ); + } + + case 'center': { + return ( +
+ + {genEl(token.children, scale)} + +
+ ); + } + + case 'url': { + return ( + + + {token.props.url} + + + ); + } + + case 'link': { + return ( + + + {genEl(token.children, scale)} + + + ); + } + + case 'mention': { + if (mentions) { + const mention = mentions.find(({ acct }) => token.props.acct.slice(1) === acct); + if (mention) { + return ( + + + e.stopPropagation()} + > + @{mention.username} + + + + ); + } + } + + return ( + + e.stopPropagation()} + > + @{token.props.username} + + + ); + } + + case 'hashtag': { + return ( + + ); + } + + case 'blockCode': { + return ( + +
+              {token.props.code}
+            
+
+ ); + } + + case 'inlineCode': + return ( + + + {token.props.code} + + + ); + + case 'quote': { + return ( +
+ {genEl(token.children, scale)} +
+ ); + } + + case 'emojiCode': { + const emoji = emojiMap[`:${token.props.name}:`]; + if (!emoji) return {token.props.name}; + + const filename = emoji.static_url; + + if (filename?.length > 0) { + return {token.props.name}; + } + + return {token.props.name}; + } + + case 'unicodeEmoji': { + return ; + } + + // TODO + // case 'mathInline': + // case 'mathBlock': + // case 'search': + + case 'plain': { + return ( + + + {genEl(token.children, scale)} + + + ); + } + + default: { + console.error('unrecognized ast type:', token.type, token); + + return []; + } + } + }).flat(Infinity); + + return ( + + + {genEl(rootAst, 1)} + + + ); +}); + +export { ParsedMfm }; diff --git a/packages/pl-fe/src/components/status-content.tsx b/packages/pl-fe/src/components/status-content.tsx index 385fe45d9..4b8f52dfc 100644 --- a/packages/pl-fe/src/components/status-content.tsx +++ b/packages/pl-fe/src/components/status-content.tsx @@ -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 = React.memo(({ preview, withMedia, }) => { - const { urlPrivacy, displaySpoilers } = useSettings(); + const { urlPrivacy, displaySpoilers, renderMfm } = useSettings(); const { greentext } = usePlFeConfig(); const [collapsed, setCollapsed] = useState(null); @@ -141,17 +142,26 @@ const StatusContent: React.FC = 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: , + 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); diff --git a/packages/pl-fe/src/features/preferences/index.tsx b/packages/pl-fe/src/features/preferences/index.tsx index 9aeaa3d89..7befceffc 100644 --- a/packages/pl-fe/src/features/preferences/index.tsx +++ b/packages/pl-fe/src/features/preferences/index.tsx @@ -352,6 +352,29 @@ const Preferences = () => { + {instance.pleroma.metadata.post_formats.includes('text/x.misskeymarkdown') && ( + + } + hint={} + > + + + + } + > + + + + } + > + + + + )} + {features.translations && ( }> diff --git a/packages/pl-fe/src/locales/en.json b/packages/pl-fe/src/locales/en.json index 1e65973e3..b9c0a6efb 100644 --- a/packages/pl-fe/src/locales/en.json +++ b/packages/pl-fe/src/locales/en.json @@ -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", diff --git a/packages/pl-fe/src/schemas/pl-fe/settings.ts b/packages/pl-fe/src/schemas/pl-fe/settings.ts index 5c346d805..9fd597ff9 100644 --- a/packages/pl-fe/src/schemas/pl-fe/settings.ts +++ b/packages/pl-fe/src/schemas/pl-fe/settings.ts @@ -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'), diff --git a/packages/pl-fe/src/styles/application.scss b/packages/pl-fe/src/styles/application.scss index 9aa67a7cd..521099e93 100644 --- a/packages/pl-fe/src/styles/application.scss +++ b/packages/pl-fe/src/styles/application.scss @@ -18,3 +18,4 @@ @use 'forms'; @use 'utilities'; @use 'components/datepicker'; +@use 'mfm'; diff --git a/packages/pl-fe/src/styles/mfm.scss b/packages/pl-fe/src/styles/mfm.scss new file mode 100644 index 000000000..55d3bc30c --- /dev/null +++ b/packages/pl-fe/src/styles/mfm.scss @@ -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; + } +}