diff --git a/app/javascript/mastodon/components/emoji/html.tsx b/app/javascript/mastodon/components/emoji/html.tsx
index e025fb56886..73ad5fa2330 100644
--- a/app/javascript/mastodon/components/emoji/html.tsx
+++ b/app/javascript/mastodon/components/emoji/html.tsx
@@ -4,6 +4,7 @@ import classNames from 'classnames';
import type { CustomEmojiMapArg } from '@/mastodon/features/emoji/types';
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
+import type { OnElementHandler } from '@/mastodon/utils/html';
import { htmlStringToComponents } from '@/mastodon/utils/html';
import { polymorphicForwardRef } from '@/types/polymorphic';
@@ -14,6 +15,7 @@ interface EmojiHTMLProps {
htmlString: string;
extraEmojis?: CustomEmojiMapArg;
className?: string;
+ onElement?: OnElementHandler;
}
export const ModernEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
@@ -23,13 +25,15 @@ export const ModernEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
htmlString,
as: asProp = 'div', // Rename for syntax highlighting
className = '',
+ onElement,
...props
},
ref,
) => {
const contents = useMemo(
- () => htmlStringToComponents(htmlString, { onText: textToEmojis }),
- [htmlString],
+ () =>
+ htmlStringToComponents(htmlString, { onText: textToEmojis, onElement }),
+ [htmlString, onElement],
);
return (
@@ -46,6 +50,7 @@ export const ModernEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
);
},
);
+ModernEmojiHTML.displayName = 'ModernEmojiHTML';
export const LegacyEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
(props, ref) => {
@@ -54,6 +59,7 @@ export const LegacyEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
htmlString,
extraEmojis,
className,
+ onElement,
...rest
} = props;
const Wrapper = asElement ?? 'div';
@@ -67,6 +73,7 @@ export const LegacyEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
);
},
);
+LegacyEmojiHTML.displayName = 'LegacyEmojiHTML';
export const EmojiHTML = isModernEmojiEnabled()
? ModernEmojiHTML
diff --git a/app/javascript/mastodon/utils/__tests__/html-test.ts b/app/javascript/mastodon/utils/__tests__/html-test.ts
index 6aacc396dc8..a48a8b572b3 100644
--- a/app/javascript/mastodon/utils/__tests__/html-test.ts
+++ b/app/javascript/mastodon/utils/__tests__/html-test.ts
@@ -53,13 +53,19 @@ describe('html', () => {
it('calls onElement callback', () => {
const input = '
lorem ipsum
';
- const onElement = vi.fn(
- (element: HTMLElement, children: React.ReactNode[]) =>
- React.createElement(element.tagName.toLowerCase(), {}, ...children),
+ const onElement = vi.fn(
+ (element, props, children) =>
+ React.createElement(
+ element.tagName.toLowerCase(),
+ props,
+ ...children,
+ ),
);
html.htmlStringToComponents(input, { onElement });
expect(onElement).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ tagName: 'P' }),
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ expect.objectContaining({ key: expect.any(String) }),
expect.arrayContaining(['lorem ipsum']),
{},
);
@@ -71,6 +77,8 @@ describe('html', () => {
const output = html.htmlStringToComponents(input, { onElement });
expect(onElement).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ tagName: 'P' }),
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ expect.objectContaining({ key: expect.any(String) }),
expect.arrayContaining(['lorem ipsum']),
{},
);
diff --git a/app/javascript/mastodon/utils/html.ts b/app/javascript/mastodon/utils/html.ts
index 971aefa6d16..f37018d86d7 100644
--- a/app/javascript/mastodon/utils/html.ts
+++ b/app/javascript/mastodon/utils/html.ts
@@ -32,14 +32,21 @@ interface QueueItem {
depth: number;
}
-export interface HTMLToStringOptions> {
+export type OnElementHandler<
+ Arg extends Record = Record,
+> = (
+ element: HTMLElement,
+ props: Record,
+ children: React.ReactNode[],
+ extra: Arg,
+) => React.ReactNode;
+
+export interface HTMLToStringOptions<
+ Arg extends Record = Record,
+> {
maxDepth?: number;
onText?: (text: string, extra: Arg) => React.ReactNode;
- onElement?: (
- element: HTMLElement,
- children: React.ReactNode[],
- extra: Arg,
- ) => React.ReactNode;
+ onElement?: OnElementHandler;
onAttribute?: (
name: string,
value: string,
@@ -125,9 +132,57 @@ export function htmlStringToComponents>(
const children: React.ReactNode[] = [];
let element: React.ReactNode = undefined;
+ // Generate props from attributes.
+ const key = `html-${uniqueIdCounter++}`; // Get the current key and then increment it.
+ const props: Record = { key };
+ for (const attr of node.attributes) {
+ let name = attr.name.toLowerCase();
+
+ // Custom attribute handler.
+ if (onAttribute) {
+ const result = onAttribute(
+ name,
+ attr.value,
+ node.tagName.toLowerCase(),
+ extraArgs,
+ );
+ if (result) {
+ const [cbName, value] = result;
+ props[cbName] = value;
+ }
+ } else {
+ // Check global attributes first, then tag-specific ones.
+ const globalAttr = globalAttributes[name];
+ const tagAttr = tagInfo.attributes?.[name];
+
+ // Exit if neither global nor tag-specific attribute is allowed.
+ if (!globalAttr && !tagAttr) {
+ continue;
+ }
+
+ // Rename if needed.
+ if (typeof tagAttr === 'string') {
+ name = tagAttr;
+ } else if (typeof globalAttr === 'string') {
+ name = globalAttr;
+ }
+
+ let value: string | boolean | number = attr.value;
+
+ // Handle boolean attributes.
+ if (value === 'true') {
+ value = true;
+ } else if (value === 'false') {
+ value = false;
+ }
+
+ props[name] = value;
+ }
+ }
+
// If onElement is provided, use it to create the element.
if (onElement) {
- const component = onElement(node, children, extraArgs);
+ const component = onElement(node, props, children, extraArgs);
// Check for undefined to allow returning null.
if (component !== undefined) {
@@ -137,53 +192,6 @@ export function htmlStringToComponents>(
// If the element wasn't created, use the default conversion.
if (element === undefined) {
- const props: Record = {};
- props.key = `html-${uniqueIdCounter++}`; // Get the current key and then increment it.
- for (const attr of node.attributes) {
- let name = attr.name.toLowerCase();
-
- // Custom attribute handler.
- if (onAttribute) {
- const result = onAttribute(
- name,
- attr.value,
- node.tagName.toLowerCase(),
- extraArgs,
- );
- if (result) {
- const [cbName, value] = result;
- props[cbName] = value;
- }
- } else {
- // Check global attributes first, then tag-specific ones.
- const globalAttr = globalAttributes[name];
- const tagAttr = tagInfo.attributes?.[name];
-
- // Exit if neither global nor tag-specific attribute is allowed.
- if (!globalAttr && !tagAttr) {
- continue;
- }
-
- // Rename if needed.
- if (typeof tagAttr === 'string') {
- name = tagAttr;
- } else if (typeof globalAttr === 'string') {
- name = globalAttr;
- }
-
- let value: string | boolean | number = attr.value;
-
- // Handle boolean attributes.
- if (value === 'true') {
- value = true;
- } else if (value === 'false') {
- value = false;
- }
-
- props[name] = value;
- }
- }
-
element = React.createElement(
tagName,
props,