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 { 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

View File

@ -53,13 +53,19 @@ describe('html', () => {
it('calls onElement callback', () => {
const input = '<p>lorem ipsum</p>';
const onElement = vi.fn(
(element: HTMLElement, children: React.ReactNode[]) =>
React.createElement(element.tagName.toLowerCase(), {}, ...children),
const onElement = vi.fn<html.OnElementHandler>(
(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']),
{},
);

View File

@ -32,14 +32,21 @@ interface QueueItem {
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;
onText?: (text: string, extra: Arg) => React.ReactNode;
onElement?: (
element: HTMLElement,
children: React.ReactNode[],
extra: Arg,
) => React.ReactNode;
onElement?: OnElementHandler<Arg>;
onAttribute?: (
name: string,
value: string,
@ -125,9 +132,57 @@ export function htmlStringToComponents<Arg extends Record<string, unknown>>(
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<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) {
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<Arg extends Record<string, unknown>>(
// If the element wasn't created, use the default conversion.
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(
tagName,
props,