Merge remote-tracking branch 'origin/develop' into sensitive-video-fix
This commit is contained in:
@ -1,77 +1,77 @@
|
||||
.status-content p {
|
||||
[data-markup] p {
|
||||
@apply mb-4 whitespace-pre-wrap;
|
||||
}
|
||||
|
||||
.status-content p:last-child {
|
||||
[data-markup] p:last-child {
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
.status-content a {
|
||||
[data-markup] a {
|
||||
@apply text-primary-600 dark:text-accent-blue hover:underline;
|
||||
}
|
||||
|
||||
.status-content strong {
|
||||
[data-markup] strong {
|
||||
@apply font-bold;
|
||||
}
|
||||
|
||||
.status-content em {
|
||||
[data-markup] em {
|
||||
@apply italic;
|
||||
}
|
||||
|
||||
.status-content ul,
|
||||
.status-content ol {
|
||||
[data-markup] ul,
|
||||
[data-markup] ol {
|
||||
@apply pl-10 mb-4;
|
||||
}
|
||||
|
||||
.status-content ul {
|
||||
[data-markup] ul {
|
||||
@apply list-disc list-outside;
|
||||
}
|
||||
|
||||
.status-content ol {
|
||||
[data-markup] ol {
|
||||
@apply list-decimal list-outside;
|
||||
}
|
||||
|
||||
.status-content blockquote {
|
||||
[data-markup] blockquote {
|
||||
@apply py-1 pl-4 mb-4 border-l-4 border-solid border-gray-400 text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.status-content code {
|
||||
[data-markup] code {
|
||||
@apply cursor-text font-mono;
|
||||
}
|
||||
|
||||
.status-content p > code,
|
||||
.status-content pre {
|
||||
[data-markup] p > code,
|
||||
[data-markup] pre {
|
||||
@apply bg-gray-100 dark:bg-primary-800;
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
.status-content p > code {
|
||||
[data-markup] p > code {
|
||||
@apply py-0.5 px-1 rounded-sm;
|
||||
}
|
||||
|
||||
/* Code block */
|
||||
.status-content pre {
|
||||
[data-markup] pre {
|
||||
@apply py-2 px-3 mb-4 leading-6 overflow-x-auto rounded-md break-all;
|
||||
}
|
||||
|
||||
.status-content pre:last-child {
|
||||
[data-markup] pre:last-child {
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
/* Markdown images */
|
||||
.status-content img:not(.emojione):not([width][height]) {
|
||||
[data-markup] img:not(.emojione):not([width][height]) {
|
||||
@apply w-full h-72 object-contain rounded-lg overflow-hidden my-4 block;
|
||||
}
|
||||
|
||||
/* User setting to underline links */
|
||||
body.underline-links .status-content a {
|
||||
body.underline-links [data-markup] a {
|
||||
@apply underline;
|
||||
}
|
||||
|
||||
.status-content .big-emoji img.emojione {
|
||||
[data-markup] .big-emoji img.emojione {
|
||||
@apply inline w-9 h-9 p-1;
|
||||
}
|
||||
|
||||
.status-content .status-link {
|
||||
[data-markup] .status-link {
|
||||
@apply hover:underline text-primary-600 dark:text-accent-blue hover:text-primary-800 dark:hover:text-accent-blue;
|
||||
}
|
||||
16
app/soapbox/components/markup.tsx
Normal file
16
app/soapbox/components/markup.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
|
||||
import Text, { IText } from './ui/text/text';
|
||||
import './markup.css';
|
||||
|
||||
interface IMarkup extends IText {
|
||||
}
|
||||
|
||||
/** Styles HTML markup returned by the API, such as in account bios and statuses. */
|
||||
const Markup = React.forwardRef<any, IMarkup>((props, ref) => {
|
||||
return (
|
||||
<Text ref={ref} {...props} data-markup />
|
||||
);
|
||||
});
|
||||
|
||||
export default Markup;
|
||||
@ -160,16 +160,22 @@ const Item: React.FC<IItem> = ({
|
||||
</div>
|
||||
);
|
||||
} else if (attachment.type === 'image') {
|
||||
const letterboxed = shouldLetterbox(attachment);
|
||||
const letterboxed = total === 1 && shouldLetterbox(attachment);
|
||||
|
||||
thumbnail = (
|
||||
<a
|
||||
className={classNames('media-gallery__item-thumbnail', { letterboxed })}
|
||||
className='media-gallery__item-thumbnail'
|
||||
href={attachment.url}
|
||||
onClick={handleClick}
|
||||
target='_blank'
|
||||
>
|
||||
<StillImage src={attachment.url} alt={attachment.description} />
|
||||
<StillImage
|
||||
className='w-full h-full'
|
||||
src={attachment.url}
|
||||
alt={attachment.description}
|
||||
letterboxed={letterboxed}
|
||||
showExt
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
} else if (attachment.type === 'gifv') {
|
||||
|
||||
@ -136,7 +136,7 @@ export const ProfileHoverCard: React.FC<IProfileHoverCard> = ({ visible = true }
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
{account.source.get('note', '').length > 0 && (
|
||||
{account.note.length > 0 && (
|
||||
<Text size='sm' dangerouslySetInnerHTML={accountBio} />
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import classNames from 'clsx';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import React from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
@ -16,6 +15,7 @@ import { initReport } from 'soapbox/actions/reports';
|
||||
import { deleteStatus, editStatus, toggleMuteStatus } from 'soapbox/actions/statuses';
|
||||
import EmojiButtonWrapper from 'soapbox/components/emoji-button-wrapper';
|
||||
import StatusActionButton from 'soapbox/components/status-action-button';
|
||||
import { HStack } from 'soapbox/components/ui';
|
||||
import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container';
|
||||
import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount, useSettings, useSoapboxConfig } from 'soapbox/hooks';
|
||||
import { isLocal } from 'soapbox/utils/accounts';
|
||||
@ -127,8 +127,6 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
||||
} else {
|
||||
onOpenUnauthorizedModal('REPLY');
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleShareClick = () => {
|
||||
@ -146,18 +144,13 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
||||
} else {
|
||||
onOpenUnauthorizedModal('FAVOURITE');
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleBookmarkClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
e.stopPropagation();
|
||||
dispatch(toggleBookmark(status));
|
||||
};
|
||||
|
||||
const handleReblogClick: React.EventHandler<React.MouseEvent> = e => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (me) {
|
||||
const modalReblog = () => dispatch(toggleReblog(status));
|
||||
const boostModal = settings.get('boostModal');
|
||||
@ -172,8 +165,6 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
||||
};
|
||||
|
||||
const handleQuoteClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (me) {
|
||||
dispatch(quoteCompose(status));
|
||||
} else {
|
||||
@ -199,12 +190,10 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
||||
};
|
||||
|
||||
const handleDeleteClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
e.stopPropagation();
|
||||
doDeleteStatus();
|
||||
};
|
||||
|
||||
const handleRedraftClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
e.stopPropagation();
|
||||
doDeleteStatus(true);
|
||||
};
|
||||
|
||||
@ -213,35 +202,29 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
||||
};
|
||||
|
||||
const handlePinClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
e.stopPropagation();
|
||||
dispatch(togglePin(status));
|
||||
};
|
||||
|
||||
const handleMentionClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
e.stopPropagation();
|
||||
dispatch(mentionCompose(status.account as Account));
|
||||
};
|
||||
|
||||
const handleDirectClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
e.stopPropagation();
|
||||
dispatch(directCompose(status.account as Account));
|
||||
};
|
||||
|
||||
const handleChatClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
e.stopPropagation();
|
||||
const account = status.account as Account;
|
||||
dispatch(launchChat(account.id, history));
|
||||
};
|
||||
|
||||
const handleMuteClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
e.stopPropagation();
|
||||
dispatch(initMuteModal(status.account as Account));
|
||||
};
|
||||
|
||||
const handleBlockClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const account = status.get('account') as Account;
|
||||
|
||||
dispatch(openModal('CONFIRM', {
|
||||
icon: require('@tabler/icons/ban.svg'),
|
||||
heading: <FormattedMessage id='confirmations.block.heading' defaultMessage='Block @{name}' values={{ name: account.get('acct') }} />,
|
||||
@ -257,7 +240,6 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
||||
};
|
||||
|
||||
const handleOpen: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
e.stopPropagation();
|
||||
history.push(`/@${status.getIn(['account', 'acct'])}/posts/${status.id}`);
|
||||
};
|
||||
|
||||
@ -269,12 +251,10 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
||||
};
|
||||
|
||||
const handleReport: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
e.stopPropagation();
|
||||
dispatch(initReport(status.account as Account, status));
|
||||
};
|
||||
|
||||
const handleConversationMuteClick: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
e.stopPropagation();
|
||||
dispatch(toggleMuteStatus(status));
|
||||
};
|
||||
|
||||
@ -282,8 +262,6 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
||||
const { uri } = status;
|
||||
const textarea = document.createElement('textarea');
|
||||
|
||||
e.stopPropagation();
|
||||
|
||||
textarea.textContent = uri;
|
||||
textarea.style.position = 'fixed';
|
||||
|
||||
@ -300,18 +278,15 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
||||
};
|
||||
|
||||
const onModerate: React.MouseEventHandler = (e) => {
|
||||
e.stopPropagation();
|
||||
const account = status.account as Account;
|
||||
dispatch(openModal('ACCOUNT_MODERATION', { accountId: account.id }));
|
||||
};
|
||||
|
||||
const handleDeleteStatus: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
e.stopPropagation();
|
||||
dispatch(deleteStatusModal(intl, status.id));
|
||||
};
|
||||
|
||||
const handleToggleStatusSensitivity: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
e.stopPropagation();
|
||||
dispatch(toggleStatusSensitivityModal(intl, status.id, status.sensitive));
|
||||
};
|
||||
|
||||
@ -550,74 +525,75 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
||||
const canShare = ('share' in navigator) && status.visibility === 'public';
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid='status-action-bar'
|
||||
className={classNames('flex flex-row', {
|
||||
'justify-between': space === 'expand',
|
||||
'space-x-2': space === 'compact',
|
||||
})}
|
||||
>
|
||||
<StatusActionButton
|
||||
title={replyTitle}
|
||||
icon={require('@tabler/icons/message-circle-2.svg')}
|
||||
onClick={handleReplyClick}
|
||||
count={replyCount}
|
||||
text={withLabels ? intl.formatMessage(messages.reply) : undefined}
|
||||
/>
|
||||
<HStack data-testid='status-action-bar'>
|
||||
<HStack
|
||||
justifyContent={space === 'expand' ? 'between' : undefined}
|
||||
space={space === 'compact' ? 2 : undefined}
|
||||
grow={space === 'expand'}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<StatusActionButton
|
||||
title={replyTitle}
|
||||
icon={require('@tabler/icons/message-circle-2.svg')}
|
||||
onClick={handleReplyClick}
|
||||
count={replyCount}
|
||||
text={withLabels ? intl.formatMessage(messages.reply) : undefined}
|
||||
/>
|
||||
|
||||
{(features.quotePosts && me) ? (
|
||||
<DropdownMenuContainer
|
||||
items={reblogMenu}
|
||||
disabled={!publicStatus}
|
||||
onShiftClick={handleReblogClick}
|
||||
>
|
||||
{reblogButton}
|
||||
</DropdownMenuContainer>
|
||||
) : (
|
||||
reblogButton
|
||||
)}
|
||||
{(features.quotePosts && me) ? (
|
||||
<DropdownMenuContainer
|
||||
items={reblogMenu}
|
||||
disabled={!publicStatus}
|
||||
onShiftClick={handleReblogClick}
|
||||
>
|
||||
{reblogButton}
|
||||
</DropdownMenuContainer>
|
||||
) : (
|
||||
reblogButton
|
||||
)}
|
||||
|
||||
{features.emojiReacts ? (
|
||||
<EmojiButtonWrapper statusId={status.id}>
|
||||
{features.emojiReacts ? (
|
||||
<EmojiButtonWrapper statusId={status.id}>
|
||||
<StatusActionButton
|
||||
title={meEmojiTitle}
|
||||
icon={require('@tabler/icons/heart.svg')}
|
||||
filled
|
||||
color='accent'
|
||||
active={Boolean(meEmojiReact)}
|
||||
count={emojiReactCount}
|
||||
emoji={meEmojiReact}
|
||||
text={withLabels ? meEmojiTitle : undefined}
|
||||
/>
|
||||
</EmojiButtonWrapper>
|
||||
) : (
|
||||
<StatusActionButton
|
||||
title={meEmojiTitle}
|
||||
title={intl.formatMessage(messages.favourite)}
|
||||
icon={require('@tabler/icons/heart.svg')}
|
||||
filled
|
||||
color='accent'
|
||||
filled
|
||||
onClick={handleFavouriteClick}
|
||||
active={Boolean(meEmojiReact)}
|
||||
count={emojiReactCount}
|
||||
emoji={meEmojiReact}
|
||||
count={favouriteCount}
|
||||
text={withLabels ? meEmojiTitle : undefined}
|
||||
/>
|
||||
</EmojiButtonWrapper>
|
||||
) : (
|
||||
<StatusActionButton
|
||||
title={intl.formatMessage(messages.favourite)}
|
||||
icon={require('@tabler/icons/heart.svg')}
|
||||
color='accent'
|
||||
filled
|
||||
onClick={handleFavouriteClick}
|
||||
active={Boolean(meEmojiReact)}
|
||||
count={favouriteCount}
|
||||
text={withLabels ? meEmojiTitle : undefined}
|
||||
/>
|
||||
)}
|
||||
)}
|
||||
|
||||
{canShare && (
|
||||
<StatusActionButton
|
||||
title={intl.formatMessage(messages.share)}
|
||||
icon={require('@tabler/icons/upload.svg')}
|
||||
onClick={handleShareClick}
|
||||
/>
|
||||
)}
|
||||
{canShare && (
|
||||
<StatusActionButton
|
||||
title={intl.formatMessage(messages.share)}
|
||||
icon={require('@tabler/icons/upload.svg')}
|
||||
onClick={handleShareClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DropdownMenuContainer items={menu} status={status}>
|
||||
<StatusActionButton
|
||||
title={intl.formatMessage(messages.more)}
|
||||
icon={require('@tabler/icons/dots.svg')}
|
||||
/>
|
||||
</DropdownMenuContainer>
|
||||
</div>
|
||||
<DropdownMenuContainer items={menu} status={status}>
|
||||
<StatusActionButton
|
||||
title={intl.formatMessage(messages.more)}
|
||||
icon={require('@tabler/icons/dots.svg')}
|
||||
/>
|
||||
</DropdownMenuContainer>
|
||||
</HStack>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -10,19 +10,14 @@ import { onlyEmoji as isOnlyEmoji } from 'soapbox/utils/rich-content';
|
||||
|
||||
import { isRtl } from '../rtl';
|
||||
|
||||
import Markup from './markup';
|
||||
import Poll from './polls/poll';
|
||||
import './status-content.css';
|
||||
|
||||
import type { Status, Mention } from 'soapbox/types/entities';
|
||||
|
||||
const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top)
|
||||
const BIG_EMOJI_LIMIT = 10;
|
||||
|
||||
type Point = [
|
||||
x: number,
|
||||
y: number,
|
||||
]
|
||||
|
||||
interface IReadMoreButton {
|
||||
onClick: React.MouseEventHandler,
|
||||
}
|
||||
@ -49,7 +44,6 @@ const StatusContent: React.FC<IStatusContent> = ({ status, onClick, collapsable
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [onlyEmoji, setOnlyEmoji] = useState(false);
|
||||
|
||||
const startXY = useRef<Point>();
|
||||
const node = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { greentext } = useSoapboxConfig();
|
||||
@ -131,29 +125,6 @@ const StatusContent: React.FC<IStatusContent> = ({ status, onClick, collapsable
|
||||
updateStatusLinks();
|
||||
});
|
||||
|
||||
const handleMouseDown: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
startXY.current = [e.clientX, e.clientY];
|
||||
};
|
||||
|
||||
const handleMouseUp: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
if (!startXY.current) return;
|
||||
const target = e.target as HTMLElement;
|
||||
const parentNode = target.parentNode as HTMLElement;
|
||||
|
||||
const [startX, startY] = startXY.current;
|
||||
const [deltaX, deltaY] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
|
||||
|
||||
if (target.localName === 'button' || target.localName === 'a' || (parentNode && (parentNode.localName === 'button' || parentNode.localName === 'a'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (deltaX + deltaY < 5 && e.button === 0 && !(e.ctrlKey || e.metaKey) && onClick) {
|
||||
onClick();
|
||||
}
|
||||
|
||||
startXY.current = undefined;
|
||||
};
|
||||
|
||||
const parsedHtml = useMemo((): string => {
|
||||
const html = translatable && status.translation ? status.translation.get('content')! : status.contentHtml;
|
||||
|
||||
@ -173,30 +144,24 @@ const StatusContent: React.FC<IStatusContent> = ({ status, onClick, collapsable
|
||||
const baseClassName = 'text-gray-900 dark:text-gray-100 break-words text-ellipsis overflow-hidden relative focus:outline-none';
|
||||
|
||||
const content = { __html: parsedHtml };
|
||||
const directionStyle: React.CSSProperties = { direction: 'ltr' };
|
||||
const className = classNames(baseClassName, 'status-content', {
|
||||
const direction = isRtl(status.search_index) ? 'rtl' : 'ltr';
|
||||
const className = classNames(baseClassName, {
|
||||
'cursor-pointer': onClick,
|
||||
'whitespace-normal': withSpoiler,
|
||||
'max-h-[300px]': collapsed,
|
||||
'leading-normal big-emoji': onlyEmoji,
|
||||
});
|
||||
|
||||
if (isRtl(status.search_index)) {
|
||||
directionStyle.direction = 'rtl';
|
||||
}
|
||||
|
||||
if (onClick) {
|
||||
const output = [
|
||||
<div
|
||||
<Markup
|
||||
ref={node}
|
||||
tabIndex={0}
|
||||
key='content'
|
||||
className={className}
|
||||
style={directionStyle}
|
||||
direction={direction}
|
||||
dangerouslySetInnerHTML={content}
|
||||
lang={status.language || undefined}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
/>,
|
||||
];
|
||||
|
||||
@ -212,14 +177,14 @@ const StatusContent: React.FC<IStatusContent> = ({ status, onClick, collapsable
|
||||
return <div className={classNames({ 'bg-gray-100 dark:bg-primary-800 rounded-md p-4': hasPoll })}>{output}</div>;
|
||||
} else {
|
||||
const output = [
|
||||
<div
|
||||
<Markup
|
||||
ref={node}
|
||||
tabIndex={0}
|
||||
key='content'
|
||||
className={classNames(baseClassName, 'status-content', {
|
||||
className={classNames(baseClassName, {
|
||||
'leading-normal big-emoji': onlyEmoji,
|
||||
})}
|
||||
style={directionStyle}
|
||||
direction={direction}
|
||||
dangerouslySetInnerHTML={content}
|
||||
lang={status.language || undefined}
|
||||
/>,
|
||||
|
||||
@ -167,7 +167,16 @@ const StatusMedia: React.FC<IStatusMedia> = ({
|
||||
);
|
||||
}
|
||||
|
||||
return media;
|
||||
if (media) {
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<div onClick={e => e.stopPropagation()}>
|
||||
{media}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default StatusMedia;
|
||||
|
||||
@ -110,6 +110,11 @@ const Status: React.FC<IStatus> = (props) => {
|
||||
const handleClick = (e?: React.MouseEvent): void => {
|
||||
e?.stopPropagation();
|
||||
|
||||
// If the user is selecting text, don't focus the status.
|
||||
if (getSelection()?.toString().length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e || !(e.ctrlKey || e.metaKey)) {
|
||||
if (onClick) {
|
||||
onClick();
|
||||
|
||||
@ -12,10 +12,14 @@ interface IStillImage {
|
||||
src: string,
|
||||
/** Extra CSS styles on the outer <div> element. */
|
||||
style?: React.CSSProperties,
|
||||
/** Whether to display the image contained vs filled in its container. */
|
||||
letterboxed?: boolean,
|
||||
/** Whether to show the file extension in the corner. */
|
||||
showExt?: boolean,
|
||||
}
|
||||
|
||||
/** Renders images on a canvas, only playing GIFs if autoPlayGif is enabled. */
|
||||
const StillImage: React.FC<IStillImage> = ({ alt, className, src, style }) => {
|
||||
const StillImage: React.FC<IStillImage> = ({ alt, className, src, style, letterboxed = false, showExt = false }) => {
|
||||
const settings = useSettings();
|
||||
const autoPlayGif = settings.get('autoPlayGif');
|
||||
|
||||
@ -34,10 +38,56 @@ const StillImage: React.FC<IStillImage> = ({ alt, className, src, style }) => {
|
||||
}
|
||||
};
|
||||
|
||||
/** ClassNames shared between the `<img>` and `<canvas>` elements. */
|
||||
const baseClassName = classNames('w-full h-full block', {
|
||||
'object-contain': letterboxed,
|
||||
'object-cover': !letterboxed,
|
||||
});
|
||||
|
||||
return (
|
||||
<div data-testid='still-image-container' className={classNames(className, 'still-image', { 'still-image--play-on-hover': hoverToPlay })} style={style}>
|
||||
<img src={src} alt={alt} ref={img} onLoad={handleImageLoad} />
|
||||
{hoverToPlay && <canvas ref={canvas} />}
|
||||
<div
|
||||
data-testid='still-image-container'
|
||||
className={classNames(className, 'relative group overflow-hidden isolate')}
|
||||
style={style}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
ref={img}
|
||||
onLoad={handleImageLoad}
|
||||
className={classNames(baseClassName, {
|
||||
'invisible group-hover:visible': hoverToPlay,
|
||||
})}
|
||||
/>
|
||||
|
||||
{hoverToPlay && (
|
||||
<canvas
|
||||
ref={canvas}
|
||||
className={classNames(baseClassName, {
|
||||
'group-hover:invisible': hoverToPlay,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(hoverToPlay && showExt) && (
|
||||
<div className='group-hover:hidden absolute opacity-90 left-2 bottom-2 pointer-events-none'>
|
||||
<ExtensionBadge ext='GIF' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface IExtensionBadge {
|
||||
/** File extension. */
|
||||
ext: string,
|
||||
}
|
||||
|
||||
/** Badge displaying a file extension. */
|
||||
const ExtensionBadge: React.FC<IExtensionBadge> = ({ ext }) => {
|
||||
return (
|
||||
<div className='inline-flex items-center px-2 py-0.5 rounded text-sm font-medium bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100'>
|
||||
{ext}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -33,7 +33,7 @@ const Card = React.forwardRef<HTMLDivElement, ICard>(({ children, variant = 'def
|
||||
ref={ref}
|
||||
{...filteredProps}
|
||||
className={classNames({
|
||||
'bg-white dark:bg-primary-900 text-gray-900 dark:text-gray-100 shadow-lg dark:shadow-none overflow-hidden': variant === 'rounded',
|
||||
'bg-white dark:bg-primary-900 text-gray-900 dark:text-gray-100 shadow-lg dark:shadow-none overflow-hidden isolate': variant === 'rounded',
|
||||
[sizes[size]]: variant === 'rounded',
|
||||
}, className)}
|
||||
>
|
||||
|
||||
@ -27,7 +27,7 @@ const spaces = {
|
||||
8: 'space-x-8',
|
||||
};
|
||||
|
||||
interface IHStack {
|
||||
interface IHStack extends Pick<React.HTMLAttributes<HTMLDivElement>, 'onClick'> {
|
||||
/** Vertical alignment of children. */
|
||||
alignItems?: keyof typeof alignItemsOptions
|
||||
/** Extra class names on the <div> element. */
|
||||
|
||||
@ -54,7 +54,9 @@ export type Sizes = keyof typeof sizes
|
||||
type Tags = 'abbr' | 'p' | 'span' | 'pre' | 'time' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'label'
|
||||
type Directions = 'ltr' | 'rtl'
|
||||
|
||||
interface IText extends Pick<React.HTMLAttributes<HTMLParagraphElement>, 'dangerouslySetInnerHTML'> {
|
||||
interface IText extends Pick<React.HTMLAttributes<HTMLParagraphElement>, 'dangerouslySetInnerHTML' | 'tabIndex' | 'lang'> {
|
||||
/** Text content. */
|
||||
children?: React.ReactNode,
|
||||
/** How to align the text. */
|
||||
align?: keyof typeof alignments,
|
||||
/** Extra class names for the outer element. */
|
||||
@ -84,8 +86,8 @@ interface IText extends Pick<React.HTMLAttributes<HTMLParagraphElement>, 'danger
|
||||
}
|
||||
|
||||
/** UI-friendly text container with dark mode support. */
|
||||
const Text: React.FC<IText> = React.forwardRef(
|
||||
(props: IText, ref: React.LegacyRef<any>) => {
|
||||
const Text = React.forwardRef<any, IText>(
|
||||
(props, ref) => {
|
||||
const {
|
||||
align,
|
||||
className,
|
||||
|
||||
Reference in New Issue
Block a user