-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
frontend: Implement basic MFM renderer (#21)
* frontend: Implement basic MFM renderer * Use twemoji-parser instead of full twemoji * Remove @storybook/addon-onboarding
- Loading branch information
Showing
10 changed files
with
290 additions
and
33 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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} />; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"} |
Oops, something went wrong.