forked from mirrors/akkoma-fe
Use Marked and marked-mfm for MFM rendering (#29)
Reviewed-on: https://akkoma.dev/AkkomaGang/pleroma-fe/pulls/29 Co-authored-by: sfr <sol@solfisher.com> Co-committed-by: sfr <sol@solfisher.com>
This commit is contained in:
parent
28d5a55352
commit
931ed3d3c7
9 changed files with 3812 additions and 2817 deletions
|
@ -33,6 +33,8 @@
|
||||||
"escape-html": "1.0.3",
|
"escape-html": "1.0.3",
|
||||||
"js-cookie": "^3.0.1",
|
"js-cookie": "^3.0.1",
|
||||||
"localforage": "1.10.0",
|
"localforage": "1.10.0",
|
||||||
|
"marked": "^4.0.17",
|
||||||
|
"marked-mfm": "^0.4.0",
|
||||||
"mfm-js": "^0.22.1",
|
"mfm-js": "^0.22.1",
|
||||||
"parse-link-header": "1.0.1",
|
"parse-link-header": "1.0.1",
|
||||||
"phoenix": "1.6.2",
|
"phoenix": "1.6.2",
|
||||||
|
|
|
@ -1,286 +0,0 @@
|
||||||
import { defineComponent, h } from 'vue'
|
|
||||||
import * as mfm from 'mfm-js'
|
|
||||||
import MentionLink from '../mention_link/mention_link.vue'
|
|
||||||
|
|
||||||
function concat (xss) {
|
|
||||||
return ([]).concat(...xss)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'font', 'blur', 'rainbow', 'rotate']
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
props: {
|
|
||||||
status: {
|
|
||||||
type: Object,
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
render () {
|
|
||||||
if (!this.status) return null
|
|
||||||
const ast = mfm.parse(this.status.mfm_content, { fnNameList: MFM_TAGS })
|
|
||||||
const validTime = (t) => {
|
|
||||||
if (t == null) return null
|
|
||||||
return t.match(/^[0-9.]+s$/) ? t : null
|
|
||||||
}
|
|
||||||
|
|
||||||
const genEl = (ast) => concat(ast.map((token) => {
|
|
||||||
switch (token.type) {
|
|
||||||
case 'text': {
|
|
||||||
const text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n')
|
|
||||||
|
|
||||||
const res = []
|
|
||||||
for (const t of text.split('\n')) {
|
|
||||||
res.push(h('br'))
|
|
||||||
res.push(t)
|
|
||||||
}
|
|
||||||
res.shift()
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'bold': {
|
|
||||||
return [h('b', genEl(token.children))]
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'strike': {
|
|
||||||
return [h('del', genEl(token.children))]
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'italic': {
|
|
||||||
return h('i', {
|
|
||||||
style: 'font-style: oblique;'
|
|
||||||
}, genEl(token.children))
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'fn': {
|
|
||||||
// TODO: CSSを文字列で組み立てていくと token.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる
|
|
||||||
let style
|
|
||||||
switch (token.props.name) {
|
|
||||||
case 'tada': {
|
|
||||||
style = `font-size: 150%;` + 'animation: tada 1s linear infinite both;'
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'jelly': {
|
|
||||||
const speed = validTime(token.props.args.speed) || '1s'
|
|
||||||
style = `animation: mfm-rubberBand ${speed} linear infinite both;`
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'twitch': {
|
|
||||||
const speed = validTime(token.props.args.speed) || '0.5s'
|
|
||||||
style = `animation: mfm-twitch ${speed} ease infinite;`
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'shake': {
|
|
||||||
const speed = validTime(token.props.args.speed) || '0.5s'
|
|
||||||
style = `animation: mfm-shake ${speed} ease infinite;`
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'spin': {
|
|
||||||
const direction =
|
|
||||||
token.props.args.left ? 'reverse'
|
|
||||||
: token.props.args.alternate ? 'alternate'
|
|
||||||
: 'normal'
|
|
||||||
const anime =
|
|
||||||
token.props.args.x ? 'mfm-spinX'
|
|
||||||
: token.props.args.y ? 'mfm-spinY'
|
|
||||||
: 'mfm-spin'
|
|
||||||
const speed = validTime(token.props.args.speed) || '1.5s'
|
|
||||||
style = `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction};`
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'jump': {
|
|
||||||
style = 'animation: mfm-jump 0.75s linear infinite;'
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'bounce': {
|
|
||||||
style = 'animation: mfm-bounce 0.75s linear infinite; transform-origin: center bottom;'
|
|
||||||
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: ${transform};`
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'x2': {
|
|
||||||
style = `font-size: 200%;`
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'x3': {
|
|
||||||
style = `font-size: 400%;`
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'x4': {
|
|
||||||
style = `font-size: 600%;`
|
|
||||||
break
|
|
||||||
}
|
|
||||||
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 = `font-family: ${family};`
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'blur': {
|
|
||||||
return h('span', {
|
|
||||||
class: '_mfm_blur_'
|
|
||||||
}, genEl(token.children))
|
|
||||||
}
|
|
||||||
case 'rainbow': {
|
|
||||||
style = 'animation: mfm-rainbow 1s linear infinite;'
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'rotate': {
|
|
||||||
const degrees = parseInt(token.props.args.deg) || '90'
|
|
||||||
style = `transform: rotate(${degrees}deg); transform-origin: center center;`
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (style == null) {
|
|
||||||
return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children), ']'])
|
|
||||||
} else {
|
|
||||||
return h('span', {
|
|
||||||
style: 'display: inline-block;' + style
|
|
||||||
}, genEl(token.children))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'small': {
|
|
||||||
return [h('small', {
|
|
||||||
style: 'opacity: 0.7;'
|
|
||||||
}, genEl(token.children))]
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'center': {
|
|
||||||
return [h('div', {
|
|
||||||
style: 'text-align:center;'
|
|
||||||
}, genEl(token.children))]
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'url': {
|
|
||||||
return [h('a', {
|
|
||||||
key: Math.random(),
|
|
||||||
href: token.props.url,
|
|
||||||
rel: 'nofollow noopener'
|
|
||||||
}, token.props.url)]
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'link': {
|
|
||||||
console.log(token.props)
|
|
||||||
return [h('a', {
|
|
||||||
key: Math.random(),
|
|
||||||
href: token.props.url,
|
|
||||||
rel: 'nofollow noopener'
|
|
||||||
}, genEl(token.children))]
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'mention': {
|
|
||||||
const user = this.status.attentions.find((mention) => `@${mention.screen_name}` === token.props.acct || mention.screen_name === token.props.username)
|
|
||||||
if (user) {
|
|
||||||
return [h(MentionLink, {
|
|
||||||
url: user.statusnet_profile_url,
|
|
||||||
content: token.props.acct,
|
|
||||||
userScreenName: token.props.acct
|
|
||||||
})]
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'hashtag': {
|
|
||||||
return [h('a', {
|
|
||||||
rel: 'noopener noreferrer',
|
|
||||||
target: '_blank',
|
|
||||||
key: token.props.hashtag,
|
|
||||||
href: this.status.tags.find((hash) => hash.name === token.props.hashtag).url
|
|
||||||
}, `#${token.props.hashtag}`)]
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'blockCode': {
|
|
||||||
return [h('pre', {
|
|
||||||
key: Math.random(),
|
|
||||||
lang: token.props.lang
|
|
||||||
}, token.props.code)]
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'inlineCode': {
|
|
||||||
return [h('code', {
|
|
||||||
key: Math.random(),
|
|
||||||
display: 'inline'
|
|
||||||
}, token.props.code)]
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'quote': {
|
|
||||||
if (!this.nowrap) {
|
|
||||||
return [h('div', {
|
|
||||||
class: 'quote'
|
|
||||||
}, genEl(token.children))]
|
|
||||||
} else {
|
|
||||||
return [h('span', {
|
|
||||||
class: 'quote'
|
|
||||||
}, genEl(token.children))]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'emojiCode': {
|
|
||||||
const emoj = this.status.emojis.find((emoji) => emoji.shortcode === token.props.name)
|
|
||||||
if (emoj) {
|
|
||||||
return [h('div', {
|
|
||||||
class: 'still-image emoji img'
|
|
||||||
},
|
|
||||||
[h('img', {
|
|
||||||
key: Math.random(),
|
|
||||||
title: token.props.name,
|
|
||||||
alt: token.props.name,
|
|
||||||
src: this.status.emojis.find((emoji) => emoji.shortcode === token.props.name).static_url
|
|
||||||
})]
|
|
||||||
)]
|
|
||||||
} else {
|
|
||||||
return `:${token.props.name}:`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'unicodeEmoji': {
|
|
||||||
return token.props.emoji
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'math': {
|
|
||||||
return [h('pre', {
|
|
||||||
key: Math.random(),
|
|
||||||
code: token.props.code
|
|
||||||
})]
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'mathInline': {
|
|
||||||
return [h('pre', {
|
|
||||||
key: Math.random(),
|
|
||||||
code: token.props.code,
|
|
||||||
inline: true
|
|
||||||
})]
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'search': {
|
|
||||||
return [h('a', {
|
|
||||||
href: `https://www.google.com/search?q=${token.props.query}`
|
|
||||||
}, token.props.content)
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
default: {
|
|
||||||
console.error('unrecognized ast type:', token.type)
|
|
||||||
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Parse ast to DOM
|
|
||||||
return h('span', genEl(ast))
|
|
||||||
}
|
|
||||||
})
|
|
|
@ -2,6 +2,8 @@ import { unescape, flattenDeep } from 'lodash'
|
||||||
import { getTagName, processTextForEmoji, getAttrs } from 'src/services/html_converter/utility.service.js'
|
import { getTagName, processTextForEmoji, getAttrs } from 'src/services/html_converter/utility.service.js'
|
||||||
import { convertHtmlToTree } from 'src/services/html_converter/html_tree_converter.service.js'
|
import { convertHtmlToTree } from 'src/services/html_converter/html_tree_converter.service.js'
|
||||||
import { convertHtmlToLines } from 'src/services/html_converter/html_line_converter.service.js'
|
import { convertHtmlToLines } from 'src/services/html_converter/html_line_converter.service.js'
|
||||||
|
import { marked } from 'marked'
|
||||||
|
import markedMfm from 'marked-mfm'
|
||||||
import StillImage from 'src/components/still-image/still-image.vue'
|
import StillImage from 'src/components/still-image/still-image.vue'
|
||||||
import MentionsLine, { MENTIONS_LIMIT } from 'src/components/mentions_line/mentions_line.vue'
|
import MentionsLine, { MENTIONS_LIMIT } from 'src/components/mentions_line/mentions_line.vue'
|
||||||
import HashtagLink from 'src/components/hashtag_link/hashtag_link.vue'
|
import HashtagLink from 'src/components/hashtag_link/hashtag_link.vue'
|
||||||
|
@ -58,10 +60,21 @@ export default {
|
||||||
required: false,
|
required: false,
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
|
},
|
||||||
|
// Render Misskey Markdown
|
||||||
|
mfm: {
|
||||||
|
required: false,
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// NEVER EVER TOUCH DATA INSIDE RENDER
|
// NEVER EVER TOUCH DATA INSIDE RENDER
|
||||||
render () {
|
render () {
|
||||||
|
// Don't greentext MFM
|
||||||
|
if (this.mfm) {
|
||||||
|
this.greentext = false
|
||||||
|
}
|
||||||
|
|
||||||
// Pre-process HTML
|
// Pre-process HTML
|
||||||
const { newHtml: html } = preProcessPerLine(this.html, this.greentext)
|
const { newHtml: html } = preProcessPerLine(this.html, this.greentext)
|
||||||
let currentMentions = null // Current chain of mentions, we group all mentions together
|
let currentMentions = null // Current chain of mentions, we group all mentions together
|
||||||
|
@ -112,6 +125,34 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderMisskeyMarkdown = (content) => {
|
||||||
|
marked.use(markedMfm, {
|
||||||
|
mangle: false,
|
||||||
|
gfm: false,
|
||||||
|
breaks: true
|
||||||
|
})
|
||||||
|
const mfmHtml = document.createElement('template')
|
||||||
|
mfmHtml.innerHTML = marked.parse(content)
|
||||||
|
|
||||||
|
// Add options with set values to CSS
|
||||||
|
Array.from(mfmHtml.content.firstChild.getElementsByClassName('mfm')).map((el) => {
|
||||||
|
if (el.dataset.speed) {
|
||||||
|
el.style.animationDuration = el.dataset.speed
|
||||||
|
}
|
||||||
|
if (el.dataset.deg) {
|
||||||
|
el.style.transform = `rotate(${el.dataset.deg}deg)`
|
||||||
|
}
|
||||||
|
if (Array.from(el.classList).includes('_mfm_font_')) {
|
||||||
|
const font = Object.keys(el.dataset)[0]
|
||||||
|
if (['serif', 'monospace', 'cursive', 'fantasy', 'emoji', 'math'].includes(font)) {
|
||||||
|
el.style.fontFamily = font
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return mfmHtml.innerHTML
|
||||||
|
}
|
||||||
|
|
||||||
// Processor to use with html_tree_converter
|
// Processor to use with html_tree_converter
|
||||||
const processItem = (item, index, array, what) => {
|
const processItem = (item, index, array, what) => {
|
||||||
// Handle text nodes - just add emoji
|
// Handle text nodes - just add emoji
|
||||||
|
@ -249,7 +290,7 @@ export default {
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
const pass1 = convertHtmlToTree(html).map(processItem)
|
const pass1 = convertHtmlToTree(this.mfm ? renderMisskeyMarkdown(html) : html).map(processItem)
|
||||||
const pass2 = [...pass1].reverse().map(processItemReverse).reverse()
|
const pass2 = [...pass1].reverse().map(processItemReverse).reverse()
|
||||||
// DO NOT USE SLOTS they cause a re-render feedback loop here.
|
// DO NOT USE SLOTS they cause a re-render feedback loop here.
|
||||||
// slots updated -> rerender -> emit -> update up the tree -> rerender -> ...
|
// slots updated -> rerender -> emit -> update up the tree -> rerender -> ...
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import fileType from 'src/services/file_type/file_type.service'
|
import fileType from 'src/services/file_type/file_type.service'
|
||||||
import RichContent from 'src/components/rich_content/rich_content.jsx'
|
import RichContent from 'src/components/rich_content/rich_content.jsx'
|
||||||
import MFMContent from 'src/components/mfm_content/mfm_content.jsx'
|
|
||||||
import { mapGetters } from 'vuex'
|
import { mapGetters } from 'vuex'
|
||||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||||
import {
|
import {
|
||||||
|
@ -84,8 +83,7 @@ const StatusContent = {
|
||||||
...mapGetters(['mergedConfig'])
|
...mapGetters(['mergedConfig'])
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
RichContent,
|
RichContent
|
||||||
MFMContent
|
|
||||||
},
|
},
|
||||||
mounted () {
|
mounted () {
|
||||||
this.status.attentions && this.status.attentions.forEach(attn => {
|
this.status.attentions && this.status.attentions.forEach(attn => {
|
||||||
|
|
|
@ -17,7 +17,6 @@
|
||||||
& .text,
|
& .text,
|
||||||
& .summary {
|
& .summary {
|
||||||
font-family: var(--postFont, sans-serif);
|
font-family: var(--postFont, sans-serif);
|
||||||
white-space: pre-wrap;
|
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
@ -31,6 +30,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
|
& > * {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
&.-single-line {
|
&.-single-line {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|
|
@ -44,18 +44,13 @@
|
||||||
<div
|
<div
|
||||||
v-if="!hideSubjectStatus && !(singleLine && status.summary_raw_html)"
|
v-if="!hideSubjectStatus && !(singleLine && status.summary_raw_html)"
|
||||||
>
|
>
|
||||||
<MFMContent
|
|
||||||
v-if="renderMisskeyMarkdown && status.mfm_content"
|
|
||||||
class="RichContent text media-body mfm-post-content"
|
|
||||||
:status="status"
|
|
||||||
/>
|
|
||||||
<RichContent
|
<RichContent
|
||||||
v-else
|
|
||||||
:class="{ '-single-line': singleLine }"
|
:class="{ '-single-line': singleLine }"
|
||||||
class="text media-body"
|
class="text media-body"
|
||||||
:html="status.raw_html"
|
:html="status.raw_html"
|
||||||
:emoji="status.emojis"
|
:emoji="status.emojis"
|
||||||
:handle-links="true"
|
:handle-links="true"
|
||||||
|
:mfm="renderMisskeyMarkdown && (status.media_type === 'text/x.misskeymarkdown')"
|
||||||
:greentext="mergedConfig.greentext"
|
:greentext="mergedConfig.greentext"
|
||||||
:attentions="status.attentions"
|
:attentions="status.attentions"
|
||||||
@parseReady="onParseReady"
|
@parseReady="onParseReady"
|
||||||
|
|
|
@ -282,11 +282,9 @@ export const parseStatus = (data) => {
|
||||||
|
|
||||||
if (data.akkoma) {
|
if (data.akkoma) {
|
||||||
const { akkoma } = data
|
const { akkoma } = data
|
||||||
if (akkoma && akkoma.source && akkoma.source.mediaType === 'text/x.misskeymarkdown') {
|
if (akkoma && akkoma.source) {
|
||||||
output.mfm_content = akkoma.source.content
|
output.media_type = akkoma.source.mediaType
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
output.mfm_content = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
output.in_reply_to_status_id = data.in_reply_to_id
|
output.in_reply_to_status_id = data.in_reply_to_id
|
||||||
|
|
|
@ -1,4 +1,86 @@
|
||||||
@keyframes tada {
|
.mfm {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
._mfm_tada_ {
|
||||||
|
font-size: 150%;
|
||||||
|
animation: mfm-tada 1s linear infinite both;
|
||||||
|
}
|
||||||
|
|
||||||
|
._mfm_jelly_ {
|
||||||
|
animation: mfm-jelly 1s linear infinite both;
|
||||||
|
}
|
||||||
|
|
||||||
|
._mfm_twitch_ {
|
||||||
|
animation: mfm-twitch 0.5s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
._mfm_shake_ {
|
||||||
|
animation: mfm-shake 0.5s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
._mfm_spin_ {
|
||||||
|
animation: mfm-spin 0.5s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
._mfm_spin_[x] {
|
||||||
|
animation-name: mfm-spinX;
|
||||||
|
}
|
||||||
|
._mfm_spin_[y] {
|
||||||
|
animation-name: mfm-spinY;
|
||||||
|
}
|
||||||
|
._mfm_spin_[left] {
|
||||||
|
animation-direction: reverse;
|
||||||
|
}
|
||||||
|
._mfm_spin_[alternate] {
|
||||||
|
animation-direction: alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
._mfm_jump_ {
|
||||||
|
animation: mfm-jump 0.75 linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
._mfm_bounce_ {
|
||||||
|
animation: mfm-bounce 0.75 linear infinite;
|
||||||
|
transform-origin: center bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
._mfm_flip_[data-h][data-v] {
|
||||||
|
transform: scale(-1, -1);
|
||||||
|
}
|
||||||
|
._mfm_flip_[data-v] {
|
||||||
|
transform: scaleY(-1);
|
||||||
|
}
|
||||||
|
._mfm_flip_:not([data-v]) {
|
||||||
|
transform: scaleX(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
._mfm_x2_ {
|
||||||
|
font-size: 200%;
|
||||||
|
}
|
||||||
|
|
||||||
|
._mfm_x3_ {
|
||||||
|
font-size: 400%;
|
||||||
|
}
|
||||||
|
|
||||||
|
._mfm_x4_ {
|
||||||
|
font-size: 600%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* blur */
|
||||||
|
|
||||||
|
._mfm_rainbow_ {
|
||||||
|
animation: mfm-rainbow 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
._mfm_rotate_ {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
transform-origin: center center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* sparkle */
|
||||||
|
|
||||||
|
@keyframes mfm-tada {
|
||||||
from {
|
from {
|
||||||
transform: scale3d(1, 1, 1);
|
transform: scale3d(1, 1, 1);
|
||||||
}
|
}
|
||||||
|
@ -123,7 +205,7 @@
|
||||||
100% { transform: translate(2px, 1px) rotate(2deg); }
|
100% { transform: translate(2px, 1px) rotate(2deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes mfm-rubberBand {
|
@keyframes mfm-jelly {
|
||||||
from { transform: scale3d(1, 1, 1); }
|
from { transform: scale3d(1, 1, 1); }
|
||||||
30% { transform: scale3d(1.25, 0.75, 1); }
|
30% { transform: scale3d(1.25, 0.75, 1); }
|
||||||
40% { transform: scale3d(0.75, 1.25, 1); }
|
40% { transform: scale3d(0.75, 1.25, 1); }
|
||||||
|
|
Loading…
Reference in a new issue