Skip to content

Commit

Permalink
frontend: Implement basic MFM renderer (#21)
Browse files Browse the repository at this point in the history
* frontend: Implement basic MFM renderer

* Use twemoji-parser instead of full twemoji

* Remove @storybook/addon-onboarding
  • Loading branch information
tirr-c committed Aug 25, 2023
1 parent 4f6addc commit 182cd01
Show file tree
Hide file tree
Showing 10 changed files with 290 additions and 33 deletions.
2 changes: 0 additions & 2 deletions frontend/.storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@ const config: StorybookConfig = {
addons: [
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/addon-onboarding",
"@storybook/addon-interactions",
"@storybook/addon-styling",
],
framework: {
name: "@storybook/nextjs",
Expand Down
4 changes: 3 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,21 @@
},
"dependencies": {
"clsx": "^2.0.0",
"mfm-js": "^0.23.3",
"negotiator": "^0.6.3",
"next": "^13.4.13",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"sharp": "^0.32.4",
"swr": "^2.2.0",
"twemoji-parser": "^14.0.0",
"webpack": "^5.88.2",
"zod": "^3.22.1"
},
"devDependencies": {
"@storybook/addon-essentials": "^7.3.1",
"@storybook/addon-interactions": "^7.3.1",
"@storybook/addon-links": "^7.3.1",
"@storybook/addon-onboarding": "^1.0.8",
"@storybook/addon-styling": "^1.3.6",
"@storybook/blocks": "^7.3.1",
"@storybook/global": "^5.0.0",
Expand All @@ -40,6 +41,7 @@
"@types/node": "^20.4.3",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@types/twemoji-parser": "^13.1.1",
"@typescript-eslint/eslint-plugin": "^6.1.0",
"@typescript-eslint/parser": "^6.1.0",
"autoprefixer": "^10.4.14",
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/components/mfm/Mfm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { parse as parseMfm } from "mfm-js";

import MfmRenderer from "./MfmRenderer";

export type CustomEmojiMapper = (code: string) => string | undefined;

interface Props {
content: string;
customEmojiMapper?: CustomEmojiMapper;
}

export default function Mfm(props: Props) {
const parsed = parseMfm(props.content);
return <MfmRenderer nodes={parsed} customEmojiMapper={props.customEmojiMapper} />;
}
41 changes: 41 additions & 0 deletions frontend/src/components/mfm/MfmEmoji.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { parse as parseEmoji } from "twemoji-parser";

import type { CustomEmojiMapper } from "./Mfm";

type Props =
| { custom?: false; code: string }
| { custom: true; code: string; srcMapper?: CustomEmojiMapper };

export default function MfmEmoji(props: Props) {
let containerClassName = "inline-block align-bottom overflow-hidden h-6";
let id;
let src;
let alt;
if (props.custom) {
id = props.code;
src = props.srcMapper?.(props.code);
alt = `:${props.code}:`;
} else {
containerClassName += " aspect-square";
const parsed = parseEmoji(props.code, {
buildUrl(codepoints) {
return `https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/svg/${codepoints}.svg`;
},
assetType: "svg",
});
if (parsed[0]) {
id = parsed[0].text;
src = parsed[0].url;
} else {
id = props.code;
src = undefined;
}
alt = props.code;
}

return (
<div key={id} className={containerClassName}>
<img src={src} alt={alt} loading="lazy" decoding="async" className="h-full" />
</div>
);
}
137 changes: 137 additions & 0 deletions frontend/src/components/mfm/MfmRenderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import type * as Mfm from "mfm-js";
import Link from "next/link";

import type { CustomEmojiMapper } from "./Mfm";
import MfmEmoji from "./MfmEmoji";

interface Props {
nodes: Mfm.MfmNode[];
customEmojiMapper?: CustomEmojiMapper;
}

export default function MfmRenderer({ nodes, customEmojiMapper }: Props) {
const rendered = nodes.map((node, idx) => {
switch (node.type) {
case "text":
return <MfmText key={idx} {...node.props} />;
case "bold":
return (
<span key={idx} className="font-bold">
<MfmRenderer nodes={node.children} customEmojiMapper={customEmojiMapper} />
</span>
);
case "italic":
return (
<span key={idx} className="italic">
<MfmRenderer nodes={node.children} customEmojiMapper={customEmojiMapper} />
</span>
);
case "strike":
return (
<span key={idx} className="line-through">
<MfmRenderer nodes={node.children} customEmojiMapper={customEmojiMapper} />
</span>
);
case "center":
return (
<div key={idx} className="text-center">
<MfmRenderer nodes={node.children} customEmojiMapper={customEmojiMapper} />
</div>
);
case "small":
return (
<span key={idx} className="text-sm text-slate-400">
<MfmRenderer nodes={node.children} customEmojiMapper={customEmojiMapper} />
</span>
);
case "inlineCode":
return (
<code key={idx} className="font-mono bg-slate-100 rounded p-1">
{node.props.code}
</code>
);
case "mention":
return <MfmMention key={idx} {...node.props} />;
case "quote":
return (
<blockquote key={idx} className="border-l-4 border-slate-200 pl-2 text-slate-600">
<MfmRenderer nodes={node.children} customEmojiMapper={customEmojiMapper} />
</blockquote>
);
case "blockCode":
return <MfmCodeBlock key={idx} {...node.props} />;
case "hashtag":
return <MfmHashtag key={idx} {...node.props} />;
case "url":
case "link":
return <MfmUrlLike key={idx} {...node} customEmojiMapper={customEmojiMapper} />;
case "plain":
return (
<MfmRenderer key={idx} nodes={node.children} customEmojiMapper={customEmojiMapper} />
);
case "unicodeEmoji":
return <MfmEmoji key={idx} code={node.props.emoji} />;
case "emojiCode":
return <MfmEmoji key={idx} custom code={node.props.name} srcMapper={customEmojiMapper} />;
default:
return null;
}
});

return <>{rendered}</>;
}

function MfmText({ text }: Mfm.MfmText["props"]) {
const rendered: React.ReactNode[] = [];
text.split(/\r?\n/g).forEach((line, idx) => {
if (idx > 0) {
rendered.push(<br key={idx} />);
}
rendered.push(line);
});

return <span>{rendered}</span>;
}

function MfmMention({ username, host }: Mfm.MfmMention["props"]) {
return (
<span className="text-emerald-700">
{`@${username}`}
{host && `@${host}`}
</span>
);
}

function MfmHashtag({ hashtag }: Mfm.MfmHashtag["props"]) {
return (
<span className="text-emerald-700">
{`#${hashtag}`}
</span>
);
}

function MfmUrlLike(props: (Mfm.MfmUrl | Mfm.MfmLink) & { customEmojiMapper?: CustomEmojiMapper }) {
let text: React.ReactNode;
let href: string;
if (props.type === "url") {
text = props.props.url;
href = props.props.url;
} else {
text = <MfmRenderer nodes={props.children} />;
href = props.props.url;
}

return (
<Link href={href} className="text-emerald-700">
{text}
</Link>
);
}

function MfmCodeBlock({ code }: Mfm.MfmCodeBlock["props"]) {
return (
<pre className="font-mono bg-slate-100 rounded p-2">
{code}
</pre>
);
}
66 changes: 66 additions & 0 deletions frontend/src/stories/Mfm.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { Meta, StoryObj } from "@storybook/react";
import React from "react";

import Mfm from "@/components/mfm/Mfm";

import plachta from "./assets/plachta.png";
import recruitSimulatorTestdata from "./assets/recruit.json";
import sendMoney from "./assets/send-money.png";
import unicodeEmojisTestdata from "./assets/spice.json";

const meta = {
title: "Mfm/Mfm",
component: Mfm,
parameters: {
layout: "centered",
},
decorators: [
Story =>
React.createElement(
"div",
{ className: "border w-[320px] p-2" },
React.createElement(Story),
),
],
args: {
content: "",
customEmojiMapper: code => {
switch (code) {
case "plachta":
return plachta.src;
case "send_money":
return sendMoney.src;
default:
return undefined;
}
},
},
argTypes: {
customEmojiMapper: {
type: "function",
},
},
} satisfies Meta<typeof Mfm>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Simple: Story = {
args: {
content: "@tirr @pbzweihander@yuri.garden\n와! 샌즈! #언더테일",
},
};

export const UnicodeEmojis: Story = {
args: unicodeEmojisTestdata,
};

export const CustomEmojis: Story = {
args: {
content: ":plachta: :send_money:",
},
};

export const RecruitSimulator: Story = {
args: recruitSimulatorTestdata,
};
3 changes: 3 additions & 0 deletions frontend/src/stories/assets/recruit.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"content": "<center>🟨🟨🟦🟦🟦\n🟦🟦🟦🟪🟪</center>\n\nアイリ フウカ チナツ シミコ ヨシミ\nハルカ ハルカ スズミ **アズサ** **メル**\n\n【シミュレーター】メル ピックアップ募集\n募集ポイント: 20\n\n今までの⭐︎3生徒(4人)\nアル、ミユ、アズサ、メル\n\nhttps://misskey.io/play/9fb45bd5za"
}
Binary file added frontend/src/stories/assets/send-money.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions frontend/src/stories/assets/spice.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"content": "\uc2a4\ud31f\ud30c\u2763\ufe0f\ud83d\ude33\ud83d\udca6\ud30c\ub78f\ud30c\ud83c\udf6b\uc2a4\ud31f\ud30c\ud30c\ud30c\ud83d\udc6d\ud83d\udc83\ud83d\udc95\ud30c\ub78f\ud30c\u2755\u2753\ud83d\ude31\ud83d\udca6\ud83d\udca6\ud83d\ude22\uc2a4\ud31f\ud31f\u2935\ufe0f\ud30c\ub78f\ud30c\ud83c\udf6b\uc2a4\ud30c\uc774\ud83d\ude26\uc564\ub4dc\ud83d\ude22\ud83d\udc94\ud83d\ude16\u2935\ufe0f\uc2a4\ud30c\uc774\uc2a4\ud83d\ude2d\ud83d\udc94\ud83d\udca6\uc2a4\ud31f\ud83d\ude4a\ud83d\udcad\ud31f\ud83c\udf1d\u2728\ud30c\ub78f\ud30c\ud83d\ude18\ud83d\udc66\ud83d\udc95\uc2a4\ud31f\ud30c\ud30c\ud30c\ud83d\udc90\ud83d\udc8c\ud83d\udc96\ud30c\ub78f\ud83d\ude46\u2728\ud30c\ud83d\ude33\ud83d\ude4c\uc2a4\ud31f\ud31f\u2935\ufe0f\ud30c\ub78f\ud30c\ud83c\udf6b\uc2a4\ud30c\uc774\ud83d\ude26\uc564\ub4dc\ud83d\ude22\ud83d\udc94\ud83d\ude16\u2935\ufe0f\uc2a4\ud30c\uc774\uc2a4\ud83d\ude2d\ud83d\udc94\ud83d\udca6"}
Loading

0 comments on commit 182cd01

Please sign in to comment.