adds element handler prop with new props arg

This commit is contained in:
ChaosExAnima 2025-10-02 16:53:04 +02:00
parent f046106612
commit 5a9d9d1132
No known key found for this signature in database
GPG Key ID: 8F2B333100FB6117
3 changed files with 82 additions and 59 deletions

View File

@ -4,6 +4,7 @@ import classNames from 'classnames';
import type { CustomEmojiMapArg } from '@/mastodon/features/emoji/types'; import type { CustomEmojiMapArg } from '@/mastodon/features/emoji/types';
import { isModernEmojiEnabled } from '@/mastodon/utils/environment'; import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
import type { OnElementHandler } from '@/mastodon/utils/html';
import { htmlStringToComponents } from '@/mastodon/utils/html'; import { htmlStringToComponents } from '@/mastodon/utils/html';
import { polymorphicForwardRef } from '@/types/polymorphic'; import { polymorphicForwardRef } from '@/types/polymorphic';
@ -14,6 +15,7 @@ interface EmojiHTMLProps {
htmlString: string; htmlString: string;
extraEmojis?: CustomEmojiMapArg; extraEmojis?: CustomEmojiMapArg;
className?: string; className?: string;
onElement?: OnElementHandler;
} }
export const ModernEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>( export const ModernEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
@ -23,13 +25,15 @@ export const ModernEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
htmlString, htmlString,
as: asProp = 'div', // Rename for syntax highlighting as: asProp = 'div', // Rename for syntax highlighting
className = '', className = '',
onElement,
...props ...props
}, },
ref, ref,
) => { ) => {
const contents = useMemo( const contents = useMemo(
() => htmlStringToComponents(htmlString, { onText: textToEmojis }), () =>
[htmlString], htmlStringToComponents(htmlString, { onText: textToEmojis, onElement }),
[htmlString, onElement],
); );
return ( return (
@ -46,6 +50,7 @@ export const ModernEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
); );
}, },
); );
ModernEmojiHTML.displayName = 'ModernEmojiHTML';
export const LegacyEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>( export const LegacyEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
(props, ref) => { (props, ref) => {
@ -54,6 +59,7 @@ export const LegacyEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
htmlString, htmlString,
extraEmojis, extraEmojis,
className, className,
onElement,
...rest ...rest
} = props; } = props;
const Wrapper = asElement ?? 'div'; const Wrapper = asElement ?? 'div';
@ -67,6 +73,7 @@ export const LegacyEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
); );
}, },
); );
LegacyEmojiHTML.displayName = 'LegacyEmojiHTML';
export const EmojiHTML = isModernEmojiEnabled() export const EmojiHTML = isModernEmojiEnabled()
? ModernEmojiHTML ? ModernEmojiHTML

View File

@ -53,13 +53,19 @@ describe('html', () => {
it('calls onElement callback', () => { it('calls onElement callback', () => {
const input = '<p>lorem ipsum</p>'; const input = '<p>lorem ipsum</p>';
const onElement = vi.fn( const onElement = vi.fn<html.OnElementHandler>(
(element: HTMLElement, children: React.ReactNode[]) => (element, props, children) =>
React.createElement(element.tagName.toLowerCase(), {}, ...children), React.createElement(
element.tagName.toLowerCase(),
props,
...children,
),
); );
html.htmlStringToComponents(input, { onElement }); html.htmlStringToComponents(input, { onElement });
expect(onElement).toHaveBeenCalledExactlyOnceWith( expect(onElement).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ tagName: 'P' }), expect.objectContaining({ tagName: 'P' }),
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
expect.objectContaining({ key: expect.any(String) }),
expect.arrayContaining(['lorem ipsum']), expect.arrayContaining(['lorem ipsum']),
{}, {},
); );
@ -71,6 +77,8 @@ describe('html', () => {
const output = html.htmlStringToComponents(input, { onElement }); const output = html.htmlStringToComponents(input, { onElement });
expect(onElement).toHaveBeenCalledExactlyOnceWith( expect(onElement).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ tagName: 'P' }), expect.objectContaining({ tagName: 'P' }),
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
expect.objectContaining({ key: expect.any(String) }),
expect.arrayContaining(['lorem ipsum']), expect.arrayContaining(['lorem ipsum']),
{}, {},
); );

View File

@ -32,14 +32,21 @@ interface QueueItem {
depth: number; depth: number;
} }
export interface HTMLToStringOptions<Arg extends Record<string, unknown>> { export type OnElementHandler<
Arg extends Record<string, unknown> = Record<string, unknown>,
> = (
element: HTMLElement,
props: Record<string, unknown>,
children: React.ReactNode[],
extra: Arg,
) => React.ReactNode;
export interface HTMLToStringOptions<
Arg extends Record<string, unknown> = Record<string, unknown>,
> {
maxDepth?: number; maxDepth?: number;
onText?: (text: string, extra: Arg) => React.ReactNode; onText?: (text: string, extra: Arg) => React.ReactNode;
onElement?: ( onElement?: OnElementHandler<Arg>;
element: HTMLElement,
children: React.ReactNode[],
extra: Arg,
) => React.ReactNode;
onAttribute?: ( onAttribute?: (
name: string, name: string,
value: string, value: string,
@ -125,9 +132,57 @@ export function htmlStringToComponents<Arg extends Record<string, unknown>>(
const children: React.ReactNode[] = []; const children: React.ReactNode[] = [];
let element: React.ReactNode = undefined; let element: React.ReactNode = undefined;
// Generate props from attributes.
const key = `html-${uniqueIdCounter++}`; // Get the current key and then increment it.
const props: Record<string, unknown> = { 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 is provided, use it to create the element.
if (onElement) { if (onElement) {
const component = onElement(node, children, extraArgs); const component = onElement(node, props, children, extraArgs);
// Check for undefined to allow returning null. // Check for undefined to allow returning null.
if (component !== undefined) { if (component !== undefined) {
@ -137,53 +192,6 @@ export function htmlStringToComponents<Arg extends Record<string, unknown>>(
// If the element wasn't created, use the default conversion. // If the element wasn't created, use the default conversion.
if (element === undefined) { if (element === undefined) {
const props: Record<string, unknown> = {};
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( element = React.createElement(
tagName, tagName,
props, props,