Merge branch 'preview-card' into 'main'
RTL fixes for PreviewCard, Chats, Error page See merge request soapbox-pub/soapbox!2792
This commit is contained in:
commit
c3ac8c365b
|
@ -7,7 +7,7 @@ import AutosuggestEmoji from 'soapbox/components/autosuggest-emoji';
|
||||||
import Icon from 'soapbox/components/icon';
|
import Icon from 'soapbox/components/icon';
|
||||||
import { Input, Portal } from 'soapbox/components/ui';
|
import { Input, Portal } from 'soapbox/components/ui';
|
||||||
import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest-account';
|
import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest-account';
|
||||||
import { isRtl } from 'soapbox/rtl';
|
import { isRtl } from 'soapbox/utils/rtl';
|
||||||
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
|
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
|
||||||
|
|
||||||
import type { Menu, MenuItem } from 'soapbox/components/dropdown-menu';
|
import type { Menu, MenuItem } from 'soapbox/components/dropdown-menu';
|
||||||
|
|
|
@ -5,7 +5,7 @@ import Textarea from 'react-textarea-autosize';
|
||||||
|
|
||||||
import { Portal } from 'soapbox/components/ui';
|
import { Portal } from 'soapbox/components/ui';
|
||||||
import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest-account';
|
import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest-account';
|
||||||
import { isRtl } from 'soapbox/rtl';
|
import { isRtl } from 'soapbox/utils/rtl';
|
||||||
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
|
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
|
||||||
|
|
||||||
import AutosuggestEmoji from './autosuggest-emoji';
|
import AutosuggestEmoji from './autosuggest-emoji';
|
||||||
|
|
|
@ -14,18 +14,6 @@ import SiteLogo from './site-logo';
|
||||||
|
|
||||||
import type { RootState } from 'soapbox/store';
|
import type { RootState } from 'soapbox/store';
|
||||||
|
|
||||||
const goHome = () => location.href = '/';
|
|
||||||
|
|
||||||
const mapStateToProps = (state: RootState) => {
|
|
||||||
const { links, logo } = getSoapboxConfig(state);
|
|
||||||
|
|
||||||
return {
|
|
||||||
siteTitle: state.instance.title,
|
|
||||||
logo,
|
|
||||||
links,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Props extends ReturnType<typeof mapStateToProps> {
|
interface Props extends ReturnType<typeof mapStateToProps> {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
@ -152,7 +140,8 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
|
||||||
<div className='mt-10'>
|
<div className='mt-10'>
|
||||||
<a href='/' className='text-base font-medium text-primary-600 hover:underline dark:text-accent-blue'>
|
<a href='/' className='text-base font-medium text-primary-600 hover:underline dark:text-accent-blue'>
|
||||||
<FormattedMessage id='alert.unexpected.return_home' defaultMessage='Return Home' />
|
<FormattedMessage id='alert.unexpected.return_home' defaultMessage='Return Home' />
|
||||||
<span aria-hidden='true'> →</span>
|
{' '}
|
||||||
|
<span className='inline-block rtl:rotate-180' aria-hidden='true'>→</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -165,6 +154,7 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
|
||||||
className='block h-48 w-full rounded-md border-gray-300 bg-gray-100 p-4 font-mono text-gray-900 shadow-sm focus:border-primary-500 focus:ring-2 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 sm:text-sm'
|
className='block h-48 w-full rounded-md border-gray-300 bg-gray-100 p-4 font-mono text-gray-900 shadow-sm focus:border-primary-500 focus:ring-2 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 sm:text-sm'
|
||||||
value={errorText}
|
value={errorText}
|
||||||
onClick={this.handleCopy}
|
onClick={this.handleCopy}
|
||||||
|
dir='ltr'
|
||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -215,4 +205,18 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goHome() {
|
||||||
|
location.href = '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapStateToProps(state: RootState) {
|
||||||
|
const { links, logo } = getSoapboxConfig(state);
|
||||||
|
|
||||||
|
return {
|
||||||
|
siteTitle: state.instance.title,
|
||||||
|
logo,
|
||||||
|
links,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default connect(mapStateToProps)(ErrorBoundary);
|
export default connect(mapStateToProps)(ErrorBoundary);
|
||||||
|
|
|
@ -3,24 +3,15 @@ import { List as ImmutableList } from 'immutable';
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
import Blurhash from 'soapbox/components/blurhash';
|
import Blurhash from 'soapbox/components/blurhash';
|
||||||
import Icon from 'soapbox/components/icon';
|
import { HStack, Stack, Text, Icon } from 'soapbox/components/ui';
|
||||||
import { HStack, Stack, Text } from 'soapbox/components/ui';
|
|
||||||
import { normalizeAttachment } from 'soapbox/normalizers';
|
import { normalizeAttachment } from 'soapbox/normalizers';
|
||||||
import { addAutoPlay } from 'soapbox/utils/media';
|
import { addAutoPlay } from 'soapbox/utils/media';
|
||||||
|
import { getTextDirection } from 'soapbox/utils/rtl';
|
||||||
|
|
||||||
import type { Card as CardEntity, Attachment } from 'soapbox/types/entities';
|
import type { Card as CardEntity, Attachment } from 'soapbox/types/entities';
|
||||||
|
|
||||||
const trim = (text: string, len: number): string => {
|
/** Props for `PreviewCard`. */
|
||||||
const cut = text.indexOf(' ', len);
|
interface IPreviewCard {
|
||||||
|
|
||||||
if (cut === -1) {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
return text.substring(0, cut) + (text.length > len ? '…' : '');
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ICard {
|
|
||||||
card: CardEntity;
|
card: CardEntity;
|
||||||
maxTitle?: number;
|
maxTitle?: number;
|
||||||
maxDescription?: number;
|
maxDescription?: number;
|
||||||
|
@ -31,7 +22,8 @@ interface ICard {
|
||||||
horizontal?: boolean;
|
horizontal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Card: React.FC<ICard> = ({
|
/** Displays a Mastodon link preview. Similar to OEmbed. */
|
||||||
|
const PreviewCard: React.FC<IPreviewCard> = ({
|
||||||
card,
|
card,
|
||||||
defaultWidth = 467,
|
defaultWidth = 467,
|
||||||
maxTitle = 120,
|
maxTitle = 120,
|
||||||
|
@ -48,6 +40,8 @@ const Card: React.FC<ICard> = ({
|
||||||
setEmbedded(false);
|
setEmbedded(false);
|
||||||
}, [card.url]);
|
}, [card.url]);
|
||||||
|
|
||||||
|
const direction = getTextDirection(card.title + card.description);
|
||||||
|
|
||||||
const trimmedTitle = trim(card.title, maxTitle);
|
const trimmedTitle = trim(card.title, maxTitle);
|
||||||
const trimmedDescription = trim(card.description, maxDescription);
|
const trimmedDescription = trim(card.description, maxDescription);
|
||||||
|
|
||||||
|
@ -123,26 +117,27 @@ const Card: React.FC<ICard> = ({
|
||||||
title={trimmedTitle}
|
title={trimmedTitle}
|
||||||
rel='noopener'
|
rel='noopener'
|
||||||
target='_blank'
|
target='_blank'
|
||||||
|
dir={direction}
|
||||||
>
|
>
|
||||||
<span>{trimmedTitle}</span>
|
<span dir={direction}>{trimmedTitle}</span>
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<span title={trimmedTitle}>{trimmedTitle}</span>
|
<span title={trimmedTitle} dir={direction}>{trimmedTitle}</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
const description = (
|
const description = (
|
||||||
<Stack space={2} className='flex-1 overflow-hidden p-4'>
|
<Stack space={2} className='flex-1 overflow-hidden p-4'>
|
||||||
{trimmedTitle && (
|
{trimmedTitle && (
|
||||||
<Text weight='bold'>{title}</Text>
|
<Text weight='bold' direction={direction}>{title}</Text>
|
||||||
)}
|
)}
|
||||||
{trimmedDescription && (
|
{trimmedDescription && (
|
||||||
<Text>{trimmedDescription}</Text>
|
<Text direction={direction}>{trimmedDescription}</Text>
|
||||||
)}
|
)}
|
||||||
<HStack space={1} alignItems='center'>
|
<HStack space={1} alignItems='center'>
|
||||||
<Text tag='span' theme='muted'>
|
<Text tag='span' theme='muted'>
|
||||||
<Icon src={require('@tabler/icons/link.svg')} />
|
<Icon src={require('@tabler/icons/link.svg')} />
|
||||||
</Text>
|
</Text>
|
||||||
<Text tag='span' theme='muted' size='sm'>
|
<Text tag='span' theme='muted' size='sm' direction={direction}>
|
||||||
{card.provider_name}
|
{card.provider_name}
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
@ -253,4 +248,15 @@ const Card: React.FC<ICard> = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Card;
|
/** Trim the text, adding ellipses if it's too long. */
|
||||||
|
function trim(text: string, len: number): string {
|
||||||
|
const cut = text.indexOf(' ', len);
|
||||||
|
|
||||||
|
if (cut === -1) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return text.substring(0, cut) + (text.length > len ? '…' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PreviewCard;
|
|
@ -6,7 +6,7 @@ import { useHistory } from 'react-router-dom';
|
||||||
import Icon from 'soapbox/components/icon';
|
import Icon from 'soapbox/components/icon';
|
||||||
import { onlyEmoji as isOnlyEmoji } from 'soapbox/utils/rich-content';
|
import { onlyEmoji as isOnlyEmoji } from 'soapbox/utils/rich-content';
|
||||||
|
|
||||||
import { isRtl } from '../rtl';
|
import { getTextDirection } from '../utils/rtl';
|
||||||
|
|
||||||
import Markup from './markup';
|
import Markup from './markup';
|
||||||
import Poll from './polls/poll';
|
import Poll from './polls/poll';
|
||||||
|
@ -142,7 +142,7 @@ const StatusContent: React.FC<IStatusContent> = ({
|
||||||
const baseClassName = 'text-gray-900 dark:text-gray-100 break-words text-ellipsis overflow-hidden relative focus:outline-none';
|
const baseClassName = 'text-gray-900 dark:text-gray-100 break-words text-ellipsis overflow-hidden relative focus:outline-none';
|
||||||
|
|
||||||
const content = { __html: parsedHtml };
|
const content = { __html: parsedHtml };
|
||||||
const direction = isRtl(status.search_index) ? 'rtl' : 'ltr';
|
const direction = getTextDirection(status.search_index);
|
||||||
const className = clsx(baseClassName, {
|
const className = clsx(baseClassName, {
|
||||||
'cursor-pointer': onClick,
|
'cursor-pointer': onClick,
|
||||||
'whitespace-normal': withSpoiler,
|
'whitespace-normal': withSpoiler,
|
||||||
|
|
|
@ -2,9 +2,9 @@ import React, { Suspense } from 'react';
|
||||||
|
|
||||||
import { openModal } from 'soapbox/actions/modals';
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
import AttachmentThumbs from 'soapbox/components/attachment-thumbs';
|
import AttachmentThumbs from 'soapbox/components/attachment-thumbs';
|
||||||
|
import PreviewCard from 'soapbox/components/preview-card';
|
||||||
import { GroupLinkPreview } from 'soapbox/features/groups/components/group-link-preview';
|
import { GroupLinkPreview } from 'soapbox/features/groups/components/group-link-preview';
|
||||||
import PlaceholderCard from 'soapbox/features/placeholder/components/placeholder-card';
|
import PlaceholderCard from 'soapbox/features/placeholder/components/placeholder-card';
|
||||||
import Card from 'soapbox/features/status/components/card';
|
|
||||||
import { MediaGallery, Video, Audio } from 'soapbox/features/ui/util/async-components';
|
import { MediaGallery, Video, Audio } from 'soapbox/features/ui/util/async-components';
|
||||||
import { useAppDispatch } from 'soapbox/hooks';
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
@ -118,7 +118,7 @@ const StatusMedia: React.FC<IStatusMedia> = ({
|
||||||
);
|
);
|
||||||
} else if (status.spoiler_text.length === 0 && !status.quote && status.card) {
|
} else if (status.spoiler_text.length === 0 && !status.quote && status.card) {
|
||||||
media = (
|
media = (
|
||||||
<Card
|
<PreviewCard
|
||||||
onOpenMedia={openMedia}
|
onOpenMedia={openMedia}
|
||||||
card={status.card}
|
card={status.card}
|
||||||
compact
|
compact
|
||||||
|
|
|
@ -121,7 +121,7 @@ const ChatPageMain = () => {
|
||||||
<HStack alignItems='center'>
|
<HStack alignItems='center'>
|
||||||
<IconButton
|
<IconButton
|
||||||
src={require('@tabler/icons/arrow-left.svg')}
|
src={require('@tabler/icons/arrow-left.svg')}
|
||||||
className='mr-2 h-7 w-7 sm:mr-0 sm:hidden'
|
className='mr-2 h-7 w-7 rtl:rotate-180 sm:mr-0 sm:hidden'
|
||||||
onClick={() => history.push('/chats')}
|
onClick={() => history.push('/chats')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
@ -24,7 +24,7 @@ const ChatPageNew: React.FC<IChatPageNew> = () => {
|
||||||
<HStack alignItems='center'>
|
<HStack alignItems='center'>
|
||||||
<IconButton
|
<IconButton
|
||||||
src={require('@tabler/icons/arrow-left.svg')}
|
src={require('@tabler/icons/arrow-left.svg')}
|
||||||
className='mr-2 h-7 w-7 sm:mr-0 sm:hidden'
|
className='mr-2 h-7 w-7 rtl:rotate-180 sm:mr-0 sm:hidden'
|
||||||
onClick={() => history.push('/chats')}
|
onClick={() => history.push('/chats')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
@ -51,7 +51,7 @@ const ChatPageSettings = () => {
|
||||||
<HStack alignItems='center'>
|
<HStack alignItems='center'>
|
||||||
<IconButton
|
<IconButton
|
||||||
src={require('@tabler/icons/arrow-left.svg')}
|
src={require('@tabler/icons/arrow-left.svg')}
|
||||||
className='mr-2 h-7 w-7 sm:mr-0 sm:hidden'
|
className='mr-2 h-7 w-7 rtl:rotate-180 sm:mr-0 sm:hidden'
|
||||||
onClick={() => history.push('/chats')}
|
onClick={() => history.push('/chats')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
@ -96,7 +96,7 @@ const ChatSettings = () => {
|
||||||
<button onClick={closeSettings}>
|
<button onClick={closeSettings}>
|
||||||
<Icon
|
<Icon
|
||||||
src={require('@tabler/icons/arrow-left.svg')}
|
src={require('@tabler/icons/arrow-left.svg')}
|
||||||
className='h-6 w-6 text-gray-600 dark:text-gray-400'
|
className='h-6 w-6 text-gray-600 rtl:rotate-180 dark:text-gray-400'
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
|
@ -73,7 +73,7 @@ const ChatWindow = () => {
|
||||||
<button onClick={closeChat}>
|
<button onClick={closeChat}>
|
||||||
<Icon
|
<Icon
|
||||||
src={require('@tabler/icons/arrow-left.svg')}
|
src={require('@tabler/icons/arrow-left.svg')}
|
||||||
className='h-6 w-6 text-gray-600 dark:text-gray-400'
|
className='h-6 w-6 text-gray-600 rtl:rotate-180 dark:text-gray-400'
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -27,7 +27,7 @@ const ChatSearchHeader = () => {
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
src={require('@tabler/icons/arrow-left.svg')}
|
src={require('@tabler/icons/arrow-left.svg')}
|
||||||
className='h-6 w-6 text-gray-600 dark:text-gray-400'
|
className='h-6 w-6 text-gray-600 rtl:rotate-180 dark:text-gray-400'
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ import AttachmentThumbs from 'soapbox/components/attachment-thumbs';
|
||||||
import Markup from 'soapbox/components/markup';
|
import Markup from 'soapbox/components/markup';
|
||||||
import { Stack } from 'soapbox/components/ui';
|
import { Stack } from 'soapbox/components/ui';
|
||||||
import AccountContainer from 'soapbox/containers/account-container';
|
import AccountContainer from 'soapbox/containers/account-container';
|
||||||
import { isRtl } from 'soapbox/rtl';
|
import { getTextDirection } from 'soapbox/utils/rtl';
|
||||||
|
|
||||||
import type { Status } from 'soapbox/types/entities';
|
import type { Status } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
@ -50,7 +50,7 @@ const ReplyIndicator: React.FC<IReplyIndicator> = ({ className, status, hideActi
|
||||||
className='break-words'
|
className='break-words'
|
||||||
size='sm'
|
size='sm'
|
||||||
dangerouslySetInnerHTML={{ __html: status.contentHtml }}
|
dangerouslySetInnerHTML={{ __html: status.contentHtml }}
|
||||||
direction={isRtl(status.search_index) ? 'rtl' : 'ltr'}
|
direction={getTextDirection(status.search_index)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{status.media_attachments.size > 0 && (
|
{status.media_attachments.size > 0 && (
|
||||||
|
|
|
@ -37,7 +37,7 @@ const Discover: React.FC = () => {
|
||||||
{isSearching ? (
|
{isSearching ? (
|
||||||
<IconButton
|
<IconButton
|
||||||
src={require('@tabler/icons/arrow-left.svg')}
|
src={require('@tabler/icons/arrow-left.svg')}
|
||||||
iconClassName='mr-2 h-5 w-5 fill-current text-gray-600'
|
iconClassName='mr-2 h-5 w-5 fill-current text-gray-600 rtl:rotate-180'
|
||||||
onClick={cancelSearch}
|
onClick={cancelSearch}
|
||||||
data-testid='group-search-icon'
|
data-testid='group-search-icon'
|
||||||
/>
|
/>
|
||||||
|
|
38
src/rtl.ts
38
src/rtl.ts
|
@ -1,38 +0,0 @@
|
||||||
// U+0590 to U+05FF - Hebrew
|
|
||||||
// U+0600 to U+06FF - Arabic
|
|
||||||
// U+0700 to U+074F - Syriac
|
|
||||||
// U+0750 to U+077F - Arabic Supplement
|
|
||||||
// U+0780 to U+07BF - Thaana
|
|
||||||
// U+07C0 to U+07FF - N'Ko
|
|
||||||
// U+0800 to U+083F - Samaritan
|
|
||||||
// U+08A0 to U+08FF - Arabic Extended-A
|
|
||||||
// U+FB1D to U+FB4F - Hebrew presentation forms
|
|
||||||
// U+FB50 to U+FDFF - Arabic presentation forms A
|
|
||||||
// U+FE70 to U+FEFF - Arabic presentation forms B
|
|
||||||
|
|
||||||
const rtlChars = /[\u0590-\u083F]|[\u08A0-\u08FF]|[\uFB1D-\uFDFF]|[\uFE70-\uFEFF]/mg;
|
|
||||||
|
|
||||||
/** Check if text is right-to-left (eg Arabic). */
|
|
||||||
export function isRtl(text: string): boolean {
|
|
||||||
if (text.length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Remove http(s), (s)ftp, ws(s), blob and smtp(s) links
|
|
||||||
text = text.replace(/(?:https?|ftp|sftp|ws|wss|blob|smtp|smtps):\/\/[\S]+/g, '');
|
|
||||||
// Remove email address links
|
|
||||||
text = text.replace(/(mailto:)([^\s@]+@[^\s@]+\.[^\s@]+)/g, '');
|
|
||||||
// Remove Phone numbe links
|
|
||||||
text = text.replace(/(tel:)([+\d\s()-]+)/g, '');
|
|
||||||
text = text.replace(/(?:^|[^/\w])@([a-z0-9_]+(@[a-z0-9.-]+)?)/ig, '');
|
|
||||||
text = text.replace(/(?:^|[^/\w])#([\S]+)/ig, '');
|
|
||||||
text = text.replace(/\s+/g, '');
|
|
||||||
|
|
||||||
const matches = text.match(rtlChars);
|
|
||||||
|
|
||||||
if (!matches) {
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return matches.length / text.length > 0.3;
|
|
||||||
}
|
|
|
@ -0,0 +1,59 @@
|
||||||
|
/** Unicode character ranges for RTL characters. */
|
||||||
|
const rtlChars = /[\u0590-\u083F]|[\u08A0-\u08FF]|[\uFB1D-\uFDFF]|[\uFE70-\uFEFF]/mg;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if text is right-to-left (eg Arabic).
|
||||||
|
*
|
||||||
|
* - U+0590 to U+05FF - Hebrew
|
||||||
|
* - U+0600 to U+06FF - Arabic
|
||||||
|
* - U+0700 to U+074F - Syriac
|
||||||
|
* - U+0750 to U+077F - Arabic Supplement
|
||||||
|
* - U+0780 to U+07BF - Thaana
|
||||||
|
* - U+07C0 to U+07FF - N'Ko
|
||||||
|
* - U+0800 to U+083F - Samaritan
|
||||||
|
* - U+08A0 to U+08FF - Arabic Extended-A
|
||||||
|
* - U+FB1D to U+FB4F - Hebrew presentation forms
|
||||||
|
* - U+FB50 to U+FDFF - Arabic presentation forms A
|
||||||
|
* - U+FE70 to U+FEFF - Arabic presentation forms B
|
||||||
|
*/
|
||||||
|
function isRtl(text: string, confidence = 0.3): boolean {
|
||||||
|
if (text.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove http(s), (s)ftp, ws(s), blob and smtp(s) links
|
||||||
|
text = text.replace(/(?:https?|ftp|sftp|ws|wss|blob|smtp|smtps):\/\/[\S]+/g, '');
|
||||||
|
// Remove email address links
|
||||||
|
text = text.replace(/(mailto:)([^\s@]+@[^\s@]+\.[^\s@]+)/g, '');
|
||||||
|
// Remove phone number links
|
||||||
|
text = text.replace(/(tel:)([+\d\s()-]+)/g, '');
|
||||||
|
// Remove mentions
|
||||||
|
text = text.replace(/(?:^|[^/\w])@([a-z0-9_]+(@[a-z0-9.-]+)?)/ig, '');
|
||||||
|
// Remove hashtags
|
||||||
|
text = text.replace(/(?:^|[^/\w])#([\S]+)/ig, '');
|
||||||
|
// Remove all non-word characters
|
||||||
|
text = text.replace(/\s+/g, '');
|
||||||
|
|
||||||
|
const matches = text.match(rtlChars);
|
||||||
|
|
||||||
|
if (!matches) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches.length / text.length > confidence;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetTextDirectionOpts {
|
||||||
|
/** The default direction to return if the text is empty. */
|
||||||
|
fallback?: 'ltr' | 'rtl';
|
||||||
|
/** The confidence threshold (0-1) to use when determining the direction. */
|
||||||
|
confidence?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the direction of the text. */
|
||||||
|
function getTextDirection(text: string, { fallback = 'ltr', confidence }: GetTextDirectionOpts = {}): 'ltr' | 'rtl' {
|
||||||
|
if (!text) return fallback;
|
||||||
|
return isRtl(text, confidence) ? 'rtl' : 'ltr';
|
||||||
|
}
|
||||||
|
|
||||||
|
export { getTextDirection, isRtl };
|
Loading…
Reference in New Issue