mirror of https://github.com/mastodon/mastodon
Adds new HTMLBlock component (#36262)
parent
1571514e49
commit
e07b9dfdc1
@ -0,0 +1,61 @@
|
||||
{
|
||||
"global": {
|
||||
"class": "className",
|
||||
"id": true,
|
||||
"title": true,
|
||||
"dir": true,
|
||||
"lang": true
|
||||
},
|
||||
"tags": {
|
||||
"p": {},
|
||||
"br": {
|
||||
"children": false
|
||||
},
|
||||
"span": {
|
||||
"attributes": {
|
||||
"translate": true
|
||||
}
|
||||
},
|
||||
"a": {
|
||||
"attributes": {
|
||||
"href": true,
|
||||
"rel": true,
|
||||
"translate": true,
|
||||
"target": true
|
||||
}
|
||||
},
|
||||
"del": {},
|
||||
"s": {},
|
||||
"pre": {},
|
||||
"blockquote": {},
|
||||
"code": {},
|
||||
"b": {},
|
||||
"strong": {},
|
||||
"u": {},
|
||||
"i": {},
|
||||
"img": {
|
||||
"children": false,
|
||||
"attributes": {
|
||||
"src": true,
|
||||
"alt": true,
|
||||
"title": true
|
||||
}
|
||||
},
|
||||
"em": {},
|
||||
"ul": {},
|
||||
"ol": {
|
||||
"attributes": {
|
||||
"start": true,
|
||||
"reversed": true
|
||||
}
|
||||
},
|
||||
"li": {
|
||||
"attributes": {
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
"ruby": {},
|
||||
"rt": {},
|
||||
"rp": {}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect } from 'storybook/test';
|
||||
|
||||
import { HTMLBlock } from './index';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/HTMLBlock',
|
||||
component: HTMLBlock,
|
||||
args: {
|
||||
contents:
|
||||
'<p>Hello, world!</p>\n<p><a href="#">A link</a></p>\n<p>This should be filtered out: <button>Bye!</button></p>',
|
||||
},
|
||||
render(args) {
|
||||
return (
|
||||
// Just for visual clarity in Storybook.
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid black',
|
||||
padding: '1rem',
|
||||
minWidth: '300px',
|
||||
}}
|
||||
>
|
||||
<HTMLBlock {...args} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
} satisfies Meta<typeof HTMLBlock>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
async play({ canvas }) {
|
||||
const link = canvas.queryByRole('link');
|
||||
await expect(link).toBeInTheDocument();
|
||||
const button = canvas.queryByRole('button');
|
||||
await expect(button).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,50 @@
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { cleanExtraEmojis } from '@/mastodon/features/emoji/normalize';
|
||||
import type { CustomEmojiMapArg } from '@/mastodon/features/emoji/types';
|
||||
import { createLimitedCache } from '@/mastodon/utils/cache';
|
||||
|
||||
import { htmlStringToComponents } from '../../utils/html';
|
||||
|
||||
// Use a module-level cache to avoid re-rendering the same HTML multiple times.
|
||||
const cache = createLimitedCache<ReactNode>({ maxSize: 1000 });
|
||||
|
||||
interface HTMLBlockProps {
|
||||
contents: string;
|
||||
extraEmojis?: CustomEmojiMapArg;
|
||||
}
|
||||
|
||||
export const HTMLBlock: FC<HTMLBlockProps> = ({
|
||||
contents: raw,
|
||||
extraEmojis,
|
||||
}) => {
|
||||
const customEmojis = useMemo(
|
||||
() => cleanExtraEmojis(extraEmojis),
|
||||
[extraEmojis],
|
||||
);
|
||||
const contents = useMemo(() => {
|
||||
const key = JSON.stringify({ raw, customEmojis });
|
||||
if (cache.has(key)) {
|
||||
return cache.get(key);
|
||||
}
|
||||
|
||||
const rendered = htmlStringToComponents(raw, {
|
||||
onText,
|
||||
extraArgs: { customEmojis },
|
||||
});
|
||||
|
||||
cache.set(key, rendered);
|
||||
return rendered;
|
||||
}, [raw, customEmojis]);
|
||||
|
||||
return contents;
|
||||
};
|
||||
|
||||
function onText(
|
||||
text: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Doesn't do anything, just showing how typing would work.
|
||||
{ customEmojis }: { customEmojis: CustomEmojiMapArg | null },
|
||||
) {
|
||||
return text;
|
||||
}
|
||||
Loading…
Reference in New Issue