From 5a9d9d1132a715980df441a9968c48ed4ac903fe Mon Sep 17 00:00:00 2001 From: ChaosExAnima Date: Thu, 2 Oct 2025 16:53:04 +0200 Subject: [PATCH] adds element handler prop with new props arg --- .../mastodon/components/emoji/html.tsx | 11 +- .../mastodon/utils/__tests__/html-test.ts | 14 ++- app/javascript/mastodon/utils/html.ts | 116 ++++++++++-------- 3 files changed, 82 insertions(+), 59 deletions(-) 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,