firefish/packages/client/src/components/mfm.ts

467 lines
11 KiB
TypeScript

import { defineComponent, h } from "vue";
import * as mfm from "mfm-js";
import type { VNode } from "vue";
import MkUrl from "@/components/global/MkUrl.vue";
import MkLink from "@/components/MkLink.vue";
import MkMention from "@/components/MkMention.vue";
import MkEmoji from "@/components/global/MkEmoji.vue";
import { concat } from "@/scripts/array";
import MkFormula from "@/components/MkFormula.vue";
import MkCode from "@/components/MkCode.vue";
import MkGoogle from "@/components/MkGoogle.vue";
import MkSparkle from "@/components/MkSparkle.vue";
import MkA from "@/components/global/MkA.vue";
import { host } from "@/config";
import { MFM_TAGS } from "@/scripts/mfm-tags";
import { reducedMotion } from "@/scripts/reduced-motion";
export default defineComponent({
props: {
text: {
type: String,
required: true,
},
plain: {
type: Boolean,
default: false,
},
nowrap: {
type: Boolean,
default: false,
},
author: {
type: Object,
default: null,
},
i: {
type: Object,
default: null,
},
customEmojis: {
required: false,
},
isNote: {
type: Boolean,
default: true,
},
},
render() {
if (this.text == null || this.text === "") return;
const ast = (this.plain ? mfm.parseSimple : mfm.parse)(this.text, {
fnNameList: MFM_TAGS,
});
const validTime = (t: string | null | undefined) => {
if (t == null) return null;
return t.match(/^[0-9.]+s$/) ? t : null;
};
const genEl = (ast: mfm.MfmNode[]) =>
concat(
ast.map((token): VNode[] => {
switch (token.type) {
case "text": {
const text = token.props.text.replace(/(\r\n|\n|\r)/g, "\n");
if (!this.plain) {
const res = [];
for (const t of text.split("\n")) {
res.push(h("br"));
res.push(t);
}
res.shift();
return res;
} else {
return [text.replace(/\n/g, " ")];
}
}
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": {
const speed = validTime(token.props.args.speed) || "1s";
style = `font-size: 150%;${
this.$store.state.animatedMfm
? `animation: tada ${speed} linear infinite both;`
: ""
}`;
break;
}
case "jelly": {
const speed = validTime(token.props.args.speed) || "1s";
style =
this.$store.state.animatedMfm && !reducedMotion()
? `animation: mfm-rubberBand ${speed} linear infinite both;`
: "";
break;
}
case "twitch": {
const speed = validTime(token.props.args.speed) || "0.5s";
style =
this.$store.state.animatedMfm && !reducedMotion()
? `animation: mfm-twitch ${speed} ease infinite;`
: "";
break;
}
case "shake": {
const speed = validTime(token.props.args.speed) || "0.5s";
style =
this.$store.state.animatedMfm && !reducedMotion()
? `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 =
this.$store.state.animatedMfm && !reducedMotion()
? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction};`
: "";
break;
}
case "jump": {
const speed = validTime(token.props.args.speed) || "0.75s";
style =
this.$store.state.animatedMfm && !reducedMotion()
? `animation: mfm-jump ${speed} linear infinite;`
: "";
break;
}
case "bounce": {
const speed = validTime(token.props.args.speed) || "0.75s";
style =
this.$store.state.animatedMfm && !reducedMotion()
? `animation: mfm-bounce ${speed} linear infinite; transform-origin: center bottom;`
: "";
break;
}
case "rainbow": {
const speed = validTime(token.props.args.speed) || "1s";
style =
this.$store.state.animatedMfm && !reducedMotion()
? `animation: mfm-rainbow ${speed} linear infinite;`
: "";
break;
}
case "sparkle": {
if (!(this.$store.state.animatedMfm || reducedMotion())) {
return genEl(token.children);
}
return h(MkSparkle, {}, genEl(token.children));
}
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": {
return h(
"span",
{
class: "mfm-x2",
},
genEl(token.children),
);
}
case "x3": {
return h(
"span",
{
class: "mfm-x3",
},
genEl(token.children),
);
}
case "x4": {
return h(
"span",
{
class: "mfm-x4",
},
genEl(token.children),
);
}
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 "rotate": {
const rotate = token.props.args.x
? "perspective(128px) rotateX"
: token.props.args.y
? "perspective(128px) rotateY"
: "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(MkUrl, {
key: Math.random(),
url: token.props.url,
rel: "nofollow noopener",
}),
];
}
case "link": {
return [
h(
MkLink,
{
key: Math.random(),
url: token.props.url,
rel: "nofollow noopener",
},
genEl(token.children),
),
];
}
case "mention": {
return [
h(MkMention, {
key: Math.random(),
host:
(token.props.host == null &&
this.author &&
this.author.host != null
? this.author.host
: token.props.host) || host,
username: token.props.username,
}),
];
}
case "hashtag": {
return [
h(
MkA,
{
key: Math.random(),
to: this.isNote
? `/tags/${encodeURIComponent(token.props.hashtag)}`
: `/explore/tags/${encodeURIComponent(
token.props.hashtag,
)}`,
style: "color:var(--hashtag);",
},
`#${token.props.hashtag}`,
),
];
}
case "blockCode": {
return [
h(MkCode, {
key: Math.random(),
code: token.props.code,
lang: token.props.lang,
}),
];
}
case "inlineCode": {
return [
h(MkCode, {
key: Math.random(),
code: token.props.code,
inline: true,
}),
];
}
case "quote": {
if (!this.nowrap) {
return [
h(
"blockquote",
genEl(token.children),
),
];
} else {
return [
h(
"span",
{
class: "quote",
},
genEl(token.children),
),
];
}
}
case "emojiCode": {
return [
h(MkEmoji, {
key: Math.random(),
emoji: `:${token.props.name}:`,
customEmojis: this.customEmojis,
normal: this.plain,
}),
];
}
case "unicodeEmoji": {
return [
h(MkEmoji, {
key: Math.random(),
emoji: token.props.emoji,
customEmojis: this.customEmojis,
normal: this.plain,
}),
];
}
case "mathInline": {
return [
h(MkFormula, {
key: Math.random(),
formula: token.props.formula,
block: false,
}),
];
}
case "mathBlock": {
return [
h(MkFormula, {
key: Math.random(),
formula: token.props.formula,
block: true,
}),
];
}
case "search": {
return [
h(MkGoogle, {
key: Math.random(),
q: token.props.query,
}),
];
}
case "plain": {
return [h("span", genEl(token.children))];
}
default: {
console.error("unrecognized ast type:", token.type);
return [];
}
}
}),
);
// Parse ast to DOM
return h("span", genEl(ast));
},
});