Emoji: Link Replacement (#36341)
This commit is contained in:
@@ -6,9 +6,10 @@ import { useLinks } from 'mastodon/hooks/useLinks';
|
|||||||
|
|
||||||
import { useAppSelector } from '../store';
|
import { useAppSelector } from '../store';
|
||||||
import { isModernEmojiEnabled } from '../utils/environment';
|
import { isModernEmojiEnabled } from '../utils/environment';
|
||||||
|
import type { OnElementHandler } from '../utils/html';
|
||||||
|
|
||||||
import { AnimateEmojiProvider } from './emoji/context';
|
|
||||||
import { EmojiHTML } from './emoji/html';
|
import { EmojiHTML } from './emoji/html';
|
||||||
|
import { HandledLink } from './status/handled_link';
|
||||||
|
|
||||||
interface AccountBioProps {
|
interface AccountBioProps {
|
||||||
className: string;
|
className: string;
|
||||||
@@ -24,13 +25,37 @@ export const AccountBio: React.FC<AccountBioProps> = ({
|
|||||||
const handleClick = useLinks(showDropdown);
|
const handleClick = useLinks(showDropdown);
|
||||||
const handleNodeChange = useCallback(
|
const handleNodeChange = useCallback(
|
||||||
(node: HTMLDivElement | null) => {
|
(node: HTMLDivElement | null) => {
|
||||||
if (!showDropdown || !node || node.childNodes.length === 0) {
|
if (
|
||||||
|
!showDropdown ||
|
||||||
|
!node ||
|
||||||
|
node.childNodes.length === 0 ||
|
||||||
|
isModernEmojiEnabled()
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
addDropdownToHashtags(node, accountId);
|
addDropdownToHashtags(node, accountId);
|
||||||
},
|
},
|
||||||
[showDropdown, accountId],
|
[showDropdown, accountId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleLink = useCallback<OnElementHandler>(
|
||||||
|
(element, { key, ...props }) => {
|
||||||
|
if (element instanceof HTMLAnchorElement) {
|
||||||
|
return (
|
||||||
|
<HandledLink
|
||||||
|
{...props}
|
||||||
|
key={key as string} // React requires keys to not be part of spread props.
|
||||||
|
href={element.href}
|
||||||
|
text={element.innerText}
|
||||||
|
hashtagAccountId={accountId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
[accountId],
|
||||||
|
);
|
||||||
|
|
||||||
const note = useAppSelector((state) => {
|
const note = useAppSelector((state) => {
|
||||||
const account = state.accounts.get(accountId);
|
const account = state.accounts.get(accountId);
|
||||||
if (!account) {
|
if (!account) {
|
||||||
@@ -48,13 +73,14 @@ export const AccountBio: React.FC<AccountBioProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimateEmojiProvider
|
<EmojiHTML
|
||||||
|
htmlString={note}
|
||||||
|
extraEmojis={extraEmojis}
|
||||||
className={classNames(className, 'translate')}
|
className={classNames(className, 'translate')}
|
||||||
onClickCapture={handleClick}
|
onClickCapture={isModernEmojiEnabled() ? undefined : handleClick}
|
||||||
ref={handleNodeChange}
|
ref={handleNodeChange}
|
||||||
>
|
onElement={handleLink}
|
||||||
<EmojiHTML htmlString={note} extraEmojis={extraEmojis} />
|
/>
|
||||||
</AnimateEmojiProvider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,60 +1,79 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import type { ComponentPropsWithoutRef, ElementType } from 'react';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
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 { AnimateEmojiProvider, CustomEmojiProvider } from './context';
|
import { AnimateEmojiProvider, CustomEmojiProvider } from './context';
|
||||||
import { textToEmojis } from './index';
|
import { textToEmojis } from './index';
|
||||||
|
|
||||||
type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
|
interface EmojiHTMLProps {
|
||||||
ComponentPropsWithoutRef<Element>,
|
|
||||||
'dangerouslySetInnerHTML' | 'className'
|
|
||||||
> & {
|
|
||||||
htmlString: string;
|
htmlString: string;
|
||||||
extraEmojis?: CustomEmojiMapArg;
|
extraEmojis?: CustomEmojiMapArg;
|
||||||
as?: Element;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
onElement?: OnElementHandler;
|
||||||
|
}
|
||||||
|
|
||||||
export const ModernEmojiHTML = ({
|
export const ModernEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
|
||||||
extraEmojis,
|
(
|
||||||
htmlString,
|
{
|
||||||
as: asProp = 'div', // Rename for syntax highlighting
|
extraEmojis,
|
||||||
shallow,
|
htmlString,
|
||||||
className = '',
|
as: asProp = 'div', // Rename for syntax highlighting
|
||||||
...props
|
className = '',
|
||||||
}: EmojiHTMLProps<ElementType>) => {
|
onElement,
|
||||||
const contents = useMemo(
|
...props
|
||||||
() => htmlStringToComponents(htmlString, { onText: textToEmojis }),
|
},
|
||||||
[htmlString],
|
ref,
|
||||||
);
|
) => {
|
||||||
|
const contents = useMemo(
|
||||||
|
() =>
|
||||||
|
htmlStringToComponents(htmlString, { onText: textToEmojis, onElement }),
|
||||||
|
[htmlString, onElement],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CustomEmojiProvider emojis={extraEmojis}>
|
<CustomEmojiProvider emojis={extraEmojis}>
|
||||||
<AnimateEmojiProvider {...props} as={asProp} className={className}>
|
<AnimateEmojiProvider
|
||||||
{contents}
|
{...props}
|
||||||
</AnimateEmojiProvider>
|
as={asProp}
|
||||||
</CustomEmojiProvider>
|
className={className}
|
||||||
);
|
ref={ref}
|
||||||
};
|
>
|
||||||
|
{contents}
|
||||||
|
</AnimateEmojiProvider>
|
||||||
|
</CustomEmojiProvider>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ModernEmojiHTML.displayName = 'ModernEmojiHTML';
|
||||||
|
|
||||||
export const LegacyEmojiHTML = <Element extends ElementType>(
|
export const LegacyEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
|
||||||
props: EmojiHTMLProps<Element>,
|
(props, ref) => {
|
||||||
) => {
|
const {
|
||||||
const { as: asElement, htmlString, extraEmojis, className, ...rest } = props;
|
as: asElement,
|
||||||
const Wrapper = asElement ?? 'div';
|
htmlString,
|
||||||
return (
|
extraEmojis,
|
||||||
<Wrapper
|
className,
|
||||||
{...rest}
|
onElement,
|
||||||
dangerouslySetInnerHTML={{ __html: htmlString }}
|
...rest
|
||||||
className={classNames(className, 'animate-parent')}
|
} = props;
|
||||||
/>
|
const Wrapper = asElement ?? 'div';
|
||||||
);
|
return (
|
||||||
};
|
<Wrapper
|
||||||
|
{...rest}
|
||||||
|
ref={ref}
|
||||||
|
dangerouslySetInnerHTML={{ __html: htmlString }}
|
||||||
|
className={classNames(className, 'animate-parent')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
LegacyEmojiHTML.displayName = 'LegacyEmojiHTML';
|
||||||
|
|
||||||
export const EmojiHTML = isModernEmojiEnabled()
|
export const EmojiHTML = isModernEmojiEnabled()
|
||||||
? ModernEmojiHTML
|
? ModernEmojiHTML
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
|
||||||
|
import { HashtagMenuController } from '@/mastodon/features/ui/components/hashtag_menu_controller';
|
||||||
|
import { accountFactoryState } from '@/testing/factories';
|
||||||
|
|
||||||
|
import { HoverCardController } from '../hover_card_controller';
|
||||||
|
|
||||||
|
import type { HandledLinkProps } from './handled_link';
|
||||||
|
import { HandledLink } from './handled_link';
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Components/Status/HandledLink',
|
||||||
|
render(args) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HandledLink {...args} mentionAccountId='1' hashtagAccountId='1' />
|
||||||
|
<HashtagMenuController />
|
||||||
|
<HoverCardController />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
href: 'https://example.com/path/subpath?query=1#hash',
|
||||||
|
text: 'https://example.com',
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
state: {
|
||||||
|
accounts: {
|
||||||
|
'1': accountFactoryState(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Meta<Pick<HandledLinkProps, 'href' | 'text'>>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {};
|
||||||
|
|
||||||
|
export const Hashtag: Story = {
|
||||||
|
args: {
|
||||||
|
text: '#example',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Mention: Story = {
|
||||||
|
args: {
|
||||||
|
text: '@user',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InternalLink: Story = {
|
||||||
|
args: {
|
||||||
|
href: '/about',
|
||||||
|
text: 'About',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InvalidURL: Story = {
|
||||||
|
args: {
|
||||||
|
href: 'ht!tp://invalid-url',
|
||||||
|
text: 'ht!tp://invalid-url -- invalid!',
|
||||||
|
},
|
||||||
|
};
|
||||||
79
app/javascript/mastodon/components/status/handled_link.tsx
Normal file
79
app/javascript/mastodon/components/status/handled_link.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import type { ComponentProps, FC } from 'react';
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
export interface HandledLinkProps {
|
||||||
|
href: string;
|
||||||
|
text: string;
|
||||||
|
hashtagAccountId?: string;
|
||||||
|
mentionAccountId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
|
||||||
|
href,
|
||||||
|
text,
|
||||||
|
hashtagAccountId,
|
||||||
|
mentionAccountId,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
// Handle hashtags
|
||||||
|
if (text.startsWith('#')) {
|
||||||
|
const hashtag = text.slice(1).trim();
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
{...props}
|
||||||
|
className='mention hashtag'
|
||||||
|
to={`/tags/${hashtag}`}
|
||||||
|
rel='tag'
|
||||||
|
data-menu-hashtag={hashtagAccountId}
|
||||||
|
>
|
||||||
|
#<span>{hashtag}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
} else if (text.startsWith('@')) {
|
||||||
|
// Handle mentions
|
||||||
|
const mention = text.slice(1).trim();
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
{...props}
|
||||||
|
className='mention'
|
||||||
|
to={`/@${mention}`}
|
||||||
|
title={`@${mention}`}
|
||||||
|
data-hover-card-account={mentionAccountId}
|
||||||
|
>
|
||||||
|
@<span>{mention}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-absolute paths treated as internal links.
|
||||||
|
if (href.startsWith('/')) {
|
||||||
|
return (
|
||||||
|
<Link {...props} className='unhandled-link' to={href}>
|
||||||
|
{text}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(href);
|
||||||
|
const [first, ...rest] = url.pathname.split('/').slice(1); // Start at 1 to skip the leading slash.
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
{...props}
|
||||||
|
href={href}
|
||||||
|
title={href}
|
||||||
|
className='unhandled-link'
|
||||||
|
target='_blank'
|
||||||
|
rel='noreferrer noopener'
|
||||||
|
translate='no'
|
||||||
|
>
|
||||||
|
<span className='invisible'>{url.protocol + '//'}</span>
|
||||||
|
<span className='ellipsis'>{`${url.hostname}/${first ?? ''}`}</span>
|
||||||
|
<span className='invisible'>{'/' + rest.join('/')}</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -18,6 +18,7 @@ import { languages as preloadedLanguages } from 'mastodon/initial_state';
|
|||||||
import { isModernEmojiEnabled } from '../utils/environment';
|
import { isModernEmojiEnabled } from '../utils/environment';
|
||||||
|
|
||||||
import { EmojiHTML } from './emoji/html';
|
import { EmojiHTML } from './emoji/html';
|
||||||
|
import { HandledLink } from './status/handled_link';
|
||||||
|
|
||||||
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
|
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
|
||||||
|
|
||||||
@@ -99,6 +100,23 @@ class StatusContent extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { status, onCollapsedToggle } = this.props;
|
const { status, onCollapsedToggle } = this.props;
|
||||||
|
if (status.get('collapsed', null) === null && onCollapsedToggle) {
|
||||||
|
const { collapsible, onClick } = this.props;
|
||||||
|
|
||||||
|
const collapsed =
|
||||||
|
collapsible
|
||||||
|
&& onClick
|
||||||
|
&& node.clientHeight > MAX_HEIGHT
|
||||||
|
&& status.get('spoiler_text').length === 0;
|
||||||
|
|
||||||
|
onCollapsedToggle(collapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit if modern emoji is enabled, as it handles links using the HandledLink component.
|
||||||
|
if (isModernEmojiEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const links = node.querySelectorAll('a');
|
const links = node.querySelectorAll('a');
|
||||||
|
|
||||||
let link, mention;
|
let link, mention;
|
||||||
@@ -128,18 +146,6 @@ class StatusContent extends PureComponent {
|
|||||||
link.classList.add('unhandled-link');
|
link.classList.add('unhandled-link');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.get('collapsed', null) === null && onCollapsedToggle) {
|
|
||||||
const { collapsible, onClick } = this.props;
|
|
||||||
|
|
||||||
const collapsed =
|
|
||||||
collapsible
|
|
||||||
&& onClick
|
|
||||||
&& node.clientHeight > MAX_HEIGHT
|
|
||||||
&& status.get('spoiler_text').length === 0;
|
|
||||||
|
|
||||||
onCollapsedToggle(collapsed);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount () {
|
componentDidMount () {
|
||||||
@@ -201,6 +207,23 @@ class StatusContent extends PureComponent {
|
|||||||
this.node = c;
|
this.node = c;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleElement = (element, {key, ...props}) => {
|
||||||
|
if (element instanceof HTMLAnchorElement) {
|
||||||
|
const mention = this.props.status.get('mentions').find(item => element.href === item.get('url'));
|
||||||
|
return (
|
||||||
|
<HandledLink
|
||||||
|
{...props}
|
||||||
|
href={element.href}
|
||||||
|
text={element.innerText}
|
||||||
|
hashtagAccountId={this.props.status.getIn(['account', 'id'])}
|
||||||
|
mentionAccountId={mention?.get('id')}
|
||||||
|
key={key}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { status, intl, statusContent } = this.props;
|
const { status, intl, statusContent } = this.props;
|
||||||
|
|
||||||
@@ -245,6 +268,7 @@ class StatusContent extends PureComponent {
|
|||||||
lang={language}
|
lang={language}
|
||||||
htmlString={content}
|
htmlString={content}
|
||||||
extraEmojis={status.get('emojis')}
|
extraEmojis={status.get('emojis')}
|
||||||
|
onElement={this.handleElement.bind(this)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{poll}
|
{poll}
|
||||||
@@ -262,6 +286,7 @@ class StatusContent extends PureComponent {
|
|||||||
lang={language}
|
lang={language}
|
||||||
htmlString={content}
|
htmlString={content}
|
||||||
extraEmojis={status.get('emojis')}
|
extraEmojis={status.get('emojis')}
|
||||||
|
onElement={this.handleElement.bind(this)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{poll}
|
{poll}
|
||||||
|
|||||||
@@ -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']),
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user