sforkowany z mirror/soapbox
Porównaj commity
13 Commity
develop
...
media-gall
Autor | SHA1 | Data |
---|---|---|
Alex Gleason | 49b996a901 | |
Alex Gleason | fd2bb2e16f | |
Alex Gleason | bbb29d0388 | |
Alex Gleason | c8509627a1 | |
Alex Gleason | ffc8ade279 | |
Alex Gleason | 0354e8e96f | |
Alex Gleason | 07323c19b0 | |
Alex Gleason | 4e74ca3c55 | |
Alex Gleason | 215c857648 | |
Alex Gleason | 6bbd00c658 | |
Alex Gleason | ca4fa5e5c5 | |
Alex Gleason | 4167a1de05 | |
Alex Gleason | c5cf252668 |
|
@ -0,0 +1,17 @@
|
|||
import React from 'react';
|
||||
|
||||
interface IExtensionBadge {
|
||||
/** File extension. */
|
||||
ext: string,
|
||||
}
|
||||
|
||||
/** Badge displaying a file extension. */
|
||||
const ExtensionBadge: React.FC<IExtensionBadge> = ({ ext }) => {
|
||||
return (
|
||||
<div className='inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-sm font-medium text-gray-900 dark:bg-gray-800 dark:text-gray-100'>
|
||||
{ext}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExtensionBadge;
|
|
@ -1,265 +1,14 @@
|
|||
import clsx from 'clsx';
|
||||
import React, { useState, useRef, useLayoutEffect } from 'react';
|
||||
|
||||
import Blurhash from 'soapbox/components/blurhash';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import StillImage from 'soapbox/components/still-image';
|
||||
import { MIMETYPE_ICONS } from 'soapbox/components/upload';
|
||||
import { useSettings, useSoapboxConfig } from 'soapbox/hooks';
|
||||
import { Attachment } from 'soapbox/types/entities';
|
||||
import { truncateFilename } from 'soapbox/utils/media';
|
||||
|
||||
import { isIOS } from '../is-mobile';
|
||||
import { isPanoramic, isPortrait, isNonConformingRatio, minimumAspectRatio, maximumAspectRatio } from '../utils/media-aspect-ratio';
|
||||
import { ATTACHMENT_LIMIT } from './media-gallery/constants';
|
||||
import MediaItem from './media-gallery/media-item';
|
||||
import { useMediaSizeData } from './media-gallery/useMediaSizeData';
|
||||
|
||||
import type { Property } from 'csstype';
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
|
||||
const ATTACHMENT_LIMIT = 4;
|
||||
const MAX_FILENAME_LENGTH = 45;
|
||||
|
||||
interface Dimensions {
|
||||
w: Property.Width | number,
|
||||
h: Property.Height | number,
|
||||
t?: Property.Top,
|
||||
r?: Property.Right,
|
||||
b?: Property.Bottom,
|
||||
l?: Property.Left,
|
||||
float?: Property.Float,
|
||||
pos?: Property.Position,
|
||||
}
|
||||
|
||||
interface SizeData {
|
||||
style: React.CSSProperties,
|
||||
itemsDimensions: Dimensions[],
|
||||
size: number,
|
||||
width: number,
|
||||
}
|
||||
|
||||
const withinLimits = (aspectRatio: number) => {
|
||||
return aspectRatio >= minimumAspectRatio && aspectRatio <= maximumAspectRatio;
|
||||
};
|
||||
|
||||
const shouldLetterbox = (attachment: Attachment): boolean => {
|
||||
const aspectRatio = attachment.getIn(['meta', 'original', 'aspect']) as number | undefined;
|
||||
if (!aspectRatio) return true;
|
||||
|
||||
return !withinLimits(aspectRatio);
|
||||
};
|
||||
|
||||
interface IItem {
|
||||
attachment: Attachment,
|
||||
standalone?: boolean,
|
||||
index: number,
|
||||
size: number,
|
||||
onClick: (index: number) => void,
|
||||
displayWidth?: number,
|
||||
visible: boolean,
|
||||
dimensions: Dimensions,
|
||||
last?: boolean,
|
||||
total: number,
|
||||
}
|
||||
|
||||
const Item: React.FC<IItem> = ({
|
||||
attachment,
|
||||
index,
|
||||
onClick,
|
||||
standalone = false,
|
||||
visible,
|
||||
dimensions,
|
||||
last,
|
||||
total,
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
const autoPlayGif = settings.get('autoPlayGif') === true;
|
||||
const { mediaPreview } = useSoapboxConfig();
|
||||
|
||||
const handleMouseEnter: React.MouseEventHandler<HTMLVideoElement> = ({ currentTarget: video }) => {
|
||||
if (hoverToPlay()) {
|
||||
video.play();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave: React.MouseEventHandler<HTMLVideoElement> = ({ currentTarget: video }) => {
|
||||
if (hoverToPlay()) {
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const hoverToPlay = () => {
|
||||
return !autoPlayGif && attachment.type === 'gifv';
|
||||
};
|
||||
|
||||
// FIXME: wtf?
|
||||
const handleClick: React.MouseEventHandler = (e: any) => {
|
||||
if (isIOS() && !e.target.autoPlay) {
|
||||
e.target.autoPlay = true;
|
||||
e.preventDefault();
|
||||
} else {
|
||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
if (hoverToPlay()) {
|
||||
e.target.pause();
|
||||
e.target.currentTime = 0;
|
||||
}
|
||||
e.preventDefault();
|
||||
onClick(index);
|
||||
}
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleVideoHover: React.MouseEventHandler<HTMLVideoElement> = ({ currentTarget: video }) => {
|
||||
video.playbackRate = 3.0;
|
||||
video.play();
|
||||
};
|
||||
|
||||
const handleVideoLeave: React.MouseEventHandler<HTMLVideoElement> = ({ currentTarget: video }) => {
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
};
|
||||
|
||||
let width: Dimensions['w'] = 100;
|
||||
let height: Dimensions['h'] = '100%';
|
||||
let top: Dimensions['t'] = 'auto';
|
||||
let left: Dimensions['l'] = 'auto';
|
||||
let bottom: Dimensions['b'] = 'auto';
|
||||
let right: Dimensions['r'] = 'auto';
|
||||
let float: Dimensions['float'] = 'left';
|
||||
let position: Dimensions['pos'] = 'relative';
|
||||
|
||||
if (dimensions) {
|
||||
width = dimensions.w;
|
||||
height = dimensions.h;
|
||||
top = dimensions.t || 'auto';
|
||||
right = dimensions.r || 'auto';
|
||||
bottom = dimensions.b || 'auto';
|
||||
left = dimensions.l || 'auto';
|
||||
float = dimensions.float || 'left';
|
||||
position = dimensions.pos || 'relative';
|
||||
}
|
||||
|
||||
let thumbnail: React.ReactNode = '';
|
||||
|
||||
if (attachment.type === 'unknown') {
|
||||
const filename = truncateFilename(attachment.url, MAX_FILENAME_LENGTH);
|
||||
const attachmentIcon = (
|
||||
<Icon
|
||||
className='h-16 w-16 text-gray-800 dark:text-gray-200'
|
||||
src={MIMETYPE_ICONS[attachment.getIn(['pleroma', 'mime_type']) as string] || require('@tabler/icons/paperclip.svg')}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={clsx('media-gallery__item', { standalone })} key={attachment.id} style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}>
|
||||
<a className='media-gallery__item-thumbnail' href={attachment.url} target='_blank' style={{ cursor: 'pointer' }}>
|
||||
<Blurhash hash={attachment.blurhash} className='media-gallery__preview' />
|
||||
<span className='media-gallery__item__icons'>{attachmentIcon}</span>
|
||||
<span className='media-gallery__filename__label'>{filename}</span>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
} else if (attachment.type === 'image') {
|
||||
const letterboxed = total === 1 && shouldLetterbox(attachment);
|
||||
|
||||
thumbnail = (
|
||||
<a
|
||||
className='media-gallery__item-thumbnail'
|
||||
href={attachment.url}
|
||||
onClick={handleClick}
|
||||
target='_blank'
|
||||
>
|
||||
<StillImage
|
||||
className='h-full w-full'
|
||||
src={mediaPreview ? attachment.preview_url : attachment.url}
|
||||
alt={attachment.description}
|
||||
letterboxed={letterboxed}
|
||||
showExt
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
} else if (attachment.type === 'gifv') {
|
||||
const conditionalAttributes: React.VideoHTMLAttributes<HTMLVideoElement> = {};
|
||||
if (isIOS()) {
|
||||
conditionalAttributes.playsInline = true;
|
||||
}
|
||||
if (autoPlayGif) {
|
||||
conditionalAttributes.autoPlay = true;
|
||||
}
|
||||
|
||||
thumbnail = (
|
||||
<div className={clsx('media-gallery__gifv', { autoplay: autoPlayGif })}>
|
||||
<video
|
||||
className='media-gallery__item-gifv-thumbnail'
|
||||
aria-label={attachment.description}
|
||||
title={attachment.description}
|
||||
role='application'
|
||||
src={attachment.url}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
loop
|
||||
muted
|
||||
{...conditionalAttributes}
|
||||
/>
|
||||
|
||||
<span className='media-gallery__gifv__label'>GIF</span>
|
||||
</div>
|
||||
);
|
||||
} else if (attachment.type === 'audio') {
|
||||
const ext = attachment.url.split('.').pop()?.toUpperCase();
|
||||
thumbnail = (
|
||||
<a
|
||||
className={clsx('media-gallery__item-thumbnail')}
|
||||
href={attachment.url}
|
||||
onClick={handleClick}
|
||||
target='_blank'
|
||||
title={attachment.description}
|
||||
>
|
||||
<span className='media-gallery__item__icons'><Icon src={require('@tabler/icons/volume.svg')} /></span>
|
||||
<span className='media-gallery__file-extension__label'>{ext}</span>
|
||||
</a>
|
||||
);
|
||||
} else if (attachment.type === 'video') {
|
||||
const ext = attachment.url.split('.').pop()?.toUpperCase();
|
||||
thumbnail = (
|
||||
<a
|
||||
className={clsx('media-gallery__item-thumbnail')}
|
||||
href={attachment.url}
|
||||
onClick={handleClick}
|
||||
target='_blank'
|
||||
title={attachment.description}
|
||||
>
|
||||
<video
|
||||
muted
|
||||
loop
|
||||
onMouseOver={handleVideoHover}
|
||||
onMouseOut={handleVideoLeave}
|
||||
>
|
||||
<source src={attachment.url} />
|
||||
</video>
|
||||
<span className='media-gallery__file-extension__label'>{ext}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx('media-gallery__item', `media-gallery__item--${attachment.type}`, { standalone })} key={attachment.id} style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}>
|
||||
{last && total > ATTACHMENT_LIMIT && (
|
||||
<div className='media-gallery__item-overflow'>
|
||||
+{total - ATTACHMENT_LIMIT + 1}
|
||||
</div>
|
||||
)}
|
||||
<Blurhash
|
||||
hash={attachment.blurhash}
|
||||
className='media-gallery__preview'
|
||||
/>
|
||||
{visible && thumbnail}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface IMediaGallery {
|
||||
sensitive?: boolean,
|
||||
media: ImmutableList<Attachment>,
|
||||
|
@ -285,241 +34,14 @@ const MediaGallery: React.FC<IMediaGallery> = (props) => {
|
|||
const [width, setWidth] = useState<number>(defaultWidth);
|
||||
|
||||
const node = useRef<HTMLDivElement>(null);
|
||||
const sizeData = useMediaSizeData({ media, width, height, defaultWidth });
|
||||
|
||||
const handleClick = (index: number) => {
|
||||
onOpenMedia(media, index);
|
||||
};
|
||||
|
||||
const getSizeDataSingle = (): SizeData => {
|
||||
const w = width || defaultWidth;
|
||||
const aspectRatio = media.getIn([0, 'meta', 'original', 'aspect']) as number | undefined;
|
||||
|
||||
const getHeight = () => {
|
||||
if (!aspectRatio) return w * 9 / 16;
|
||||
if (isPanoramic(aspectRatio)) return Math.floor(w / maximumAspectRatio);
|
||||
if (isPortrait(aspectRatio)) return Math.floor(w / minimumAspectRatio);
|
||||
return Math.floor(w / aspectRatio);
|
||||
};
|
||||
|
||||
return {
|
||||
style: { height: getHeight() },
|
||||
itemsDimensions: [],
|
||||
size: 1,
|
||||
width,
|
||||
};
|
||||
};
|
||||
|
||||
const getSizeDataMultiple = (size: number): SizeData => {
|
||||
const w = width || defaultWidth;
|
||||
const panoSize = Math.floor(w / maximumAspectRatio);
|
||||
const panoSize_px = `${Math.floor(w / maximumAspectRatio)}px`;
|
||||
|
||||
const style: React.CSSProperties = {};
|
||||
let itemsDimensions: Dimensions[] = [];
|
||||
|
||||
const ratios = Array(size).fill(null).map((_, i) =>
|
||||
media.getIn([i, 'meta', 'original', 'aspect']) as number,
|
||||
);
|
||||
|
||||
const [ar1, ar2, ar3, ar4] = ratios;
|
||||
|
||||
if (size === 2) {
|
||||
if (isPortrait(ar1) && isPortrait(ar2)) {
|
||||
style.height = w - (w / maximumAspectRatio);
|
||||
} else if (isPanoramic(ar1) && isPanoramic(ar2)) {
|
||||
style.height = panoSize * 2;
|
||||
} else if (
|
||||
(isPanoramic(ar1) && isPortrait(ar2)) ||
|
||||
(isPortrait(ar1) && isPanoramic(ar2)) ||
|
||||
(isPanoramic(ar1) && isNonConformingRatio(ar2)) ||
|
||||
(isNonConformingRatio(ar1) && isPanoramic(ar2))
|
||||
) {
|
||||
style.height = (w * 0.6) + (w / maximumAspectRatio);
|
||||
} else {
|
||||
style.height = w / 2;
|
||||
}
|
||||
|
||||
if (isPortrait(ar1) && isPortrait(ar2)) {
|
||||
itemsDimensions = [
|
||||
{ w: 50, h: '100%', r: '2px' },
|
||||
{ w: 50, h: '100%', l: '2px' },
|
||||
];
|
||||
} else if (isPanoramic(ar1) && isPanoramic(ar2)) {
|
||||
itemsDimensions = [
|
||||
{ w: 100, h: panoSize_px, b: '2px' },
|
||||
{ w: 100, h: panoSize_px, t: '2px' },
|
||||
];
|
||||
} else if (
|
||||
(isPanoramic(ar1) && isPortrait(ar2)) ||
|
||||
(isPanoramic(ar1) && isNonConformingRatio(ar2))
|
||||
) {
|
||||
itemsDimensions = [
|
||||
{ w: 100, h: `${(w / maximumAspectRatio)}px`, b: '2px' },
|
||||
{ w: 100, h: `${(w * 0.6)}px`, t: '2px' },
|
||||
];
|
||||
} else if (
|
||||
(isPortrait(ar1) && isPanoramic(ar2)) ||
|
||||
(isNonConformingRatio(ar1) && isPanoramic(ar2))
|
||||
) {
|
||||
itemsDimensions = [
|
||||
{ w: 100, h: `${(w * 0.6)}px`, b: '2px' },
|
||||
{ w: 100, h: `${(w / maximumAspectRatio)}px`, t: '2px' },
|
||||
];
|
||||
} else {
|
||||
itemsDimensions = [
|
||||
{ w: 50, h: '100%', r: '2px' },
|
||||
{ w: 50, h: '100%', l: '2px' },
|
||||
];
|
||||
}
|
||||
} else if (size === 3) {
|
||||
if (isPanoramic(ar1) && isPanoramic(ar2) && isPanoramic(ar3)) {
|
||||
style.height = panoSize * 3;
|
||||
} else if (isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3)) {
|
||||
style.height = Math.floor(w / minimumAspectRatio);
|
||||
} else {
|
||||
style.height = w;
|
||||
}
|
||||
|
||||
if (isPanoramic(ar1) && isNonConformingRatio(ar2) && isNonConformingRatio(ar3)) {
|
||||
itemsDimensions = [
|
||||
{ w: 100, h: '50%', b: '2px' },
|
||||
{ w: 50, h: '50%', t: '2px', r: '2px' },
|
||||
{ w: 50, h: '50%', t: '2px', l: '2px' },
|
||||
];
|
||||
} else if (isPanoramic(ar1) && isPanoramic(ar2) && isPanoramic(ar3)) {
|
||||
itemsDimensions = [
|
||||
{ w: 100, h: panoSize_px, b: '4px' },
|
||||
{ w: 100, h: panoSize_px },
|
||||
{ w: 100, h: panoSize_px, t: '4px' },
|
||||
];
|
||||
} else if (isPortrait(ar1) && isNonConformingRatio(ar2) && isNonConformingRatio(ar3)) {
|
||||
itemsDimensions = [
|
||||
{ w: 50, h: '100%', r: '2px' },
|
||||
{ w: 50, h: '50%', b: '2px', l: '2px' },
|
||||
{ w: 50, h: '50%', t: '2px', l: '2px' },
|
||||
];
|
||||
} else if (isNonConformingRatio(ar1) && isNonConformingRatio(ar2) && isPortrait(ar3)) {
|
||||
itemsDimensions = [
|
||||
{ w: 50, h: '50%', b: '2px', r: '2px' },
|
||||
{ w: 50, h: '50%', l: '-2px', b: '-2px', pos: 'absolute', float: 'none' },
|
||||
{ w: 50, h: '100%', r: '-2px', t: '0px', b: '0px', pos: 'absolute', float: 'none' },
|
||||
];
|
||||
} else if (
|
||||
(isNonConformingRatio(ar1) && isPortrait(ar2) && isNonConformingRatio(ar3)) ||
|
||||
(isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3))
|
||||
) {
|
||||
itemsDimensions = [
|
||||
{ w: 50, h: '50%', b: '2px', r: '2px' },
|
||||
{ w: 50, h: '100%', l: '2px', float: 'right' },
|
||||
{ w: 50, h: '50%', t: '2px', r: '2px' },
|
||||
];
|
||||
} else if (
|
||||
(isPanoramic(ar1) && isPanoramic(ar2) && isNonConformingRatio(ar3)) ||
|
||||
(isPanoramic(ar1) && isPanoramic(ar2) && isPortrait(ar3))
|
||||
) {
|
||||
itemsDimensions = [
|
||||
{ w: 50, h: panoSize_px, b: '2px', r: '2px' },
|
||||
{ w: 50, h: panoSize_px, b: '2px', l: '2px' },
|
||||
{ w: 100, h: `${w - panoSize}px`, t: '2px' },
|
||||
];
|
||||
} else if (
|
||||
(isNonConformingRatio(ar1) && isPanoramic(ar2) && isPanoramic(ar3)) ||
|
||||
(isPortrait(ar1) && isPanoramic(ar2) && isPanoramic(ar3))
|
||||
) {
|
||||
itemsDimensions = [
|
||||
{ w: 100, h: `${w - panoSize}px`, b: '2px' },
|
||||
{ w: 50, h: panoSize_px, t: '2px', r: '2px' },
|
||||
{ w: 50, h: panoSize_px, t: '2px', l: '2px' },
|
||||
];
|
||||
} else {
|
||||
itemsDimensions = [
|
||||
{ w: 50, h: '50%', b: '2px', r: '2px' },
|
||||
{ w: 50, h: '50%', b: '2px', l: '2px' },
|
||||
{ w: 100, h: '50%', t: '2px' },
|
||||
];
|
||||
}
|
||||
} else if (size >= 4) {
|
||||
if (
|
||||
(isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3) && isPortrait(ar4)) ||
|
||||
(isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3) && isNonConformingRatio(ar4)) ||
|
||||
(isPortrait(ar1) && isPortrait(ar2) && isNonConformingRatio(ar3) && isPortrait(ar4)) ||
|
||||
(isPortrait(ar1) && isNonConformingRatio(ar2) && isPortrait(ar3) && isPortrait(ar4)) ||
|
||||
(isNonConformingRatio(ar1) && isPortrait(ar2) && isPortrait(ar3) && isPortrait(ar4))
|
||||
) {
|
||||
style.height = Math.floor(w / minimumAspectRatio);
|
||||
} else if (isPanoramic(ar1) && isPanoramic(ar2) && isPanoramic(ar3) && isPanoramic(ar4)) {
|
||||
style.height = panoSize * 2;
|
||||
} else if (
|
||||
(isPanoramic(ar1) && isPanoramic(ar2) && isNonConformingRatio(ar3) && isNonConformingRatio(ar4)) ||
|
||||
(isNonConformingRatio(ar1) && isNonConformingRatio(ar2) && isPanoramic(ar3) && isPanoramic(ar4))
|
||||
) {
|
||||
style.height = panoSize + (w / 2);
|
||||
} else {
|
||||
style.height = w;
|
||||
}
|
||||
|
||||
if (isPanoramic(ar1) && isPanoramic(ar2) && isNonConformingRatio(ar3) && isNonConformingRatio(ar4)) {
|
||||
itemsDimensions = [
|
||||
{ w: 50, h: panoSize_px, b: '2px', r: '2px' },
|
||||
{ w: 50, h: panoSize_px, b: '2px', l: '2px' },
|
||||
{ w: 50, h: `${(w / 2)}px`, t: '2px', r: '2px' },
|
||||
{ w: 50, h: `${(w / 2)}px`, t: '2px', l: '2px' },
|
||||
];
|
||||
} else if (isNonConformingRatio(ar1) && isNonConformingRatio(ar2) && isPanoramic(ar3) && isPanoramic(ar4)) {
|
||||
itemsDimensions = [
|
||||
{ w: 50, h: `${(w / 2)}px`, b: '2px', r: '2px' },
|
||||
{ w: 50, h: `${(w / 2)}px`, b: '2px', l: '2px' },
|
||||
{ w: 50, h: panoSize_px, t: '2px', r: '2px' },
|
||||
{ w: 50, h: panoSize_px, t: '2px', l: '2px' },
|
||||
];
|
||||
} else if (
|
||||
(isPortrait(ar1) && isNonConformingRatio(ar2) && isNonConformingRatio(ar3) && isNonConformingRatio(ar4)) ||
|
||||
(isPortrait(ar1) && isPanoramic(ar2) && isPanoramic(ar3) && isPanoramic(ar4))
|
||||
) {
|
||||
itemsDimensions = [
|
||||
{ w: 67, h: '100%', r: '2px' },
|
||||
{ w: 33, h: '33%', b: '4px', l: '2px' },
|
||||
{ w: 33, h: '33%', l: '2px' },
|
||||
{ w: 33, h: '33%', t: '4px', l: '2px' },
|
||||
];
|
||||
} else {
|
||||
itemsDimensions = [
|
||||
{ w: 50, h: '50%', b: '2px', r: '2px' },
|
||||
{ w: 50, h: '50%', b: '2px', l: '2px' },
|
||||
{ w: 50, h: '50%', t: '2px', r: '2px' },
|
||||
{ w: 50, h: '50%', t: '2px', l: '2px' },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
style,
|
||||
itemsDimensions,
|
||||
size,
|
||||
width: w,
|
||||
};
|
||||
};
|
||||
|
||||
const getSizeData = (size: number): Readonly<SizeData> => {
|
||||
const w = width || defaultWidth;
|
||||
|
||||
if (w) {
|
||||
if (size === 1) return getSizeDataSingle();
|
||||
if (size > 1) return getSizeDataMultiple(size);
|
||||
}
|
||||
|
||||
return {
|
||||
style: { height },
|
||||
itemsDimensions: [],
|
||||
size,
|
||||
width: w,
|
||||
};
|
||||
};
|
||||
|
||||
const sizeData: SizeData = getSizeData(media.size);
|
||||
|
||||
const children = media.take(ATTACHMENT_LIMIT).map((attachment, i) => (
|
||||
<Item
|
||||
<MediaItem
|
||||
key={attachment.id}
|
||||
onClick={handleClick}
|
||||
attachment={attachment}
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
export const ATTACHMENT_LIMIT = 4;
|
||||
export const MAX_FILENAME_LENGTH = 45;
|
|
@ -0,0 +1,91 @@
|
|||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import Blurhash from 'soapbox/components/blurhash';
|
||||
|
||||
import MediaPreview from '../media/media-preview';
|
||||
|
||||
import { ATTACHMENT_LIMIT } from './constants';
|
||||
import MoreMediaOverlay from './more-media-overlay';
|
||||
|
||||
import type { Dimensions } from './types';
|
||||
import type { Attachment } from 'soapbox/types/entities';
|
||||
|
||||
interface IMediaItem {
|
||||
attachment: Attachment,
|
||||
standalone?: boolean,
|
||||
index: number,
|
||||
size: number,
|
||||
onClick: (index: number) => void,
|
||||
displayWidth?: number,
|
||||
visible: boolean,
|
||||
dimensions: Dimensions,
|
||||
last?: boolean,
|
||||
total: number,
|
||||
}
|
||||
|
||||
const MediaItem: React.FC<IMediaItem> = ({
|
||||
attachment,
|
||||
index,
|
||||
onClick,
|
||||
standalone = false,
|
||||
visible,
|
||||
dimensions,
|
||||
last,
|
||||
total,
|
||||
}) => {
|
||||
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
onClick(index);
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
let width: Dimensions['w'] = 100;
|
||||
let height: Dimensions['h'] = '100%';
|
||||
let top: Dimensions['t'] = 'auto';
|
||||
let left: Dimensions['l'] = 'auto';
|
||||
let bottom: Dimensions['b'] = 'auto';
|
||||
let right: Dimensions['r'] = 'auto';
|
||||
let float: Dimensions['float'] = 'left';
|
||||
let position: Dimensions['pos'] = 'relative';
|
||||
|
||||
if (dimensions) {
|
||||
width = dimensions.w;
|
||||
height = dimensions.h;
|
||||
top = dimensions.t || 'auto';
|
||||
right = dimensions.r || 'auto';
|
||||
bottom = dimensions.b || 'auto';
|
||||
left = dimensions.l || 'auto';
|
||||
float = dimensions.float || 'left';
|
||||
position = dimensions.pos || 'relative';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className={clsx('media-gallery__item', `media-gallery__item--${attachment.type}`, { standalone })}
|
||||
style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}
|
||||
>
|
||||
{last && total > ATTACHMENT_LIMIT && (
|
||||
<MoreMediaOverlay
|
||||
count={total - ATTACHMENT_LIMIT + 1}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Blurhash
|
||||
hash={attachment.blurhash}
|
||||
className='pointer-events-none absolute inset-0 -z-10 h-full w-full'
|
||||
/>
|
||||
|
||||
{visible && (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className='h-full w-full cursor-zoom-in'
|
||||
>
|
||||
<MediaPreview attachment={attachment} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaItem;
|
|
@ -0,0 +1,16 @@
|
|||
import React from 'react';
|
||||
|
||||
interface IMoreMediaOverlay {
|
||||
count: number
|
||||
}
|
||||
|
||||
/** Overlay on the final image in a gallery to indicate more images can be seen by clicking into the modal. */
|
||||
const MoreMediaOverlay: React.FC<IMoreMediaOverlay> = ({ count }) => {
|
||||
return (
|
||||
<div className='pointer-events-none absolute inset-0 z-20 flex h-full w-full items-center justify-center bg-white/75 text-center text-5xl font-bold text-gray-900'>
|
||||
<span>+{count}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MoreMediaOverlay;
|
|
@ -0,0 +1,24 @@
|
|||
import type { Property } from 'csstype';
|
||||
|
||||
interface Dimensions {
|
||||
w: Property.Width | number,
|
||||
h: Property.Height | number,
|
||||
t?: Property.Top,
|
||||
r?: Property.Right,
|
||||
b?: Property.Bottom,
|
||||
l?: Property.Left,
|
||||
float?: Property.Float,
|
||||
pos?: Property.Position,
|
||||
}
|
||||
|
||||
interface SizeData {
|
||||
style: React.CSSProperties,
|
||||
itemsDimensions: Dimensions[],
|
||||
size: number,
|
||||
width: number,
|
||||
}
|
||||
|
||||
export type {
|
||||
Dimensions,
|
||||
SizeData,
|
||||
};
|
|
@ -0,0 +1,242 @@
|
|||
import { containAspectRatio, isNonConformingRatio, isPanoramic, isPortrait, maximumAspectRatio, minimumAspectRatio } from 'soapbox/utils/media-aspect-ratio';
|
||||
|
||||
import type { Dimensions, SizeData } from './types';
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
import type { Attachment } from 'soapbox/types/entities';
|
||||
|
||||
interface UseMediaSizeDataOpts {
|
||||
width: number
|
||||
height?: number
|
||||
defaultWidth: number
|
||||
media: ImmutableList<Attachment>
|
||||
}
|
||||
|
||||
const useMediaSizeData = ({ width, height, defaultWidth, media }: UseMediaSizeDataOpts) => {
|
||||
const getSizeDataSingle = (): SizeData => {
|
||||
const w = width || defaultWidth;
|
||||
const aspectRatio = media.getIn([0, 'meta', 'original', 'aspect']) as number | undefined;
|
||||
|
||||
const getHeight = () => {
|
||||
if (!aspectRatio) return w * 9 / 16;
|
||||
return Math.floor(w / containAspectRatio(aspectRatio));
|
||||
};
|
||||
|
||||
return {
|
||||
style: { height: getHeight() },
|
||||
itemsDimensions: [],
|
||||
size: 1,
|
||||
width,
|
||||
};
|
||||
};
|
||||
|
||||
const getSizeDataMultiple = (size: number): SizeData => {
|
||||
const w = width || defaultWidth;
|
||||
const panoSize = Math.floor(w / maximumAspectRatio);
|
||||
const panoSize_px = `${Math.floor(w / maximumAspectRatio)}px`;
|
||||
|
||||
const style: React.CSSProperties = {};
|
||||
let itemsDimensions: Dimensions[] = [];
|
||||
|
||||
const ratios = Array(size).fill(null).map((_, i) =>
|
||||
media.getIn([i, 'meta', 'original', 'aspect']) as number,
|
||||
);
|
||||
|
||||
const [ar1, ar2, ar3, ar4] = ratios;
|
||||
|
||||
if (size === 2) {
|
||||
if (isPortrait(ar1) && isPortrait(ar2)) {
|
||||
style.height = w - (w / maximumAspectRatio);
|
||||
} else if (isPanoramic(ar1) && isPanoramic(ar2)) {
|
||||
style.height = panoSize * 2;
|
||||
} else if (
|
||||
(isPanoramic(ar1) && isPortrait(ar2)) ||
|
||||
(isPortrait(ar1) && isPanoramic(ar2)) ||
|
||||
(isPanoramic(ar1) && isNonConformingRatio(ar2)) ||
|
||||
(isNonConformingRatio(ar1) && isPanoramic(ar2))
|
||||
) {
|
||||
style.height = (w * 0.6) + (w / maximumAspectRatio);
|
||||
} else {
|
||||
style.height = w / 2;
|
||||
}
|
||||
|
||||
if (isPortrait(ar1) && isPortrait(ar2)) {
|
||||
itemsDimensions = [
|
||||
{ w: 50, h: '100%', r: '2px' },
|
||||
{ w: 50, h: '100%', l: '2px' },
|
||||
];
|
||||
} else if (isPanoramic(ar1) && isPanoramic(ar2)) {
|
||||
itemsDimensions = [
|
||||
{ w: 100, h: panoSize_px, b: '2px' },
|
||||
{ w: 100, h: panoSize_px, t: '2px' },
|
||||
];
|
||||
} else if (
|
||||
(isPanoramic(ar1) && isPortrait(ar2)) ||
|
||||
(isPanoramic(ar1) && isNonConformingRatio(ar2))
|
||||
) {
|
||||
itemsDimensions = [
|
||||
{ w: 100, h: `${(w / maximumAspectRatio)}px`, b: '2px' },
|
||||
{ w: 100, h: `${(w * 0.6)}px`, t: '2px' },
|
||||
];
|
||||
} else if (
|
||||
(isPortrait(ar1) && isPanoramic(ar2)) ||
|
||||
(isNonConformingRatio(ar1) && isPanoramic(ar2))
|
||||
) {
|
||||
itemsDimensions = [
|
||||
{ w: 100, h: `${(w * 0.6)}px`, b: '2px' },
|
||||
{ w: 100, h: `${(w / maximumAspectRatio)}px`, t: '2px' },
|
||||
];
|
||||
} else {
|
||||
itemsDimensions = [
|
||||
{ w: 50, h: '100%', r: '2px' },
|
||||
{ w: 50, h: '100%', l: '2px' },
|
||||
];
|
||||
}
|
||||
} else if (size === 3) {
|
||||
if (isPanoramic(ar1) && isPanoramic(ar2) && isPanoramic(ar3)) {
|
||||
style.height = panoSize * 3;
|
||||
} else if (isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3)) {
|
||||
style.height = Math.floor(w / minimumAspectRatio);
|
||||
} else {
|
||||
style.height = w;
|
||||
}
|
||||
|
||||
if (isPanoramic(ar1) && isNonConformingRatio(ar2) && isNonConformingRatio(ar3)) {
|
||||
itemsDimensions = [
|
||||
{ w: 100, h: '50%', b: '2px' },
|
||||
{ w: 50, h: '50%', t: '2px', r: '2px' },
|
||||
{ w: 50, h: '50%', t: '2px', l: '2px' },
|
||||
];
|
||||
} else if (isPanoramic(ar1) && isPanoramic(ar2) && isPanoramic(ar3)) {
|
||||
itemsDimensions = [
|
||||
{ w: 100, h: panoSize_px, b: '4px' },
|
||||
{ w: 100, h: panoSize_px },
|
||||
{ w: 100, h: panoSize_px, t: '4px' },
|
||||
];
|
||||
} else if (isPortrait(ar1) && isNonConformingRatio(ar2) && isNonConformingRatio(ar3)) {
|
||||
itemsDimensions = [
|
||||
{ w: 50, h: '100%', r: '2px' },
|
||||
{ w: 50, h: '50%', b: '2px', l: '2px' },
|
||||
{ w: 50, h: '50%', t: '2px', l: '2px' },
|
||||
];
|
||||
} else if (isNonConformingRatio(ar1) && isNonConformingRatio(ar2) && isPortrait(ar3)) {
|
||||
itemsDimensions = [
|
||||
{ w: 50, h: '50%', b: '2px', r: '2px' },
|
||||
{ w: 50, h: '50%', l: '-2px', b: '-2px', pos: 'absolute', float: 'none' },
|
||||
{ w: 50, h: '100%', r: '-2px', t: '0px', b: '0px', pos: 'absolute', float: 'none' },
|
||||
];
|
||||
} else if (
|
||||
(isNonConformingRatio(ar1) && isPortrait(ar2) && isNonConformingRatio(ar3)) ||
|
||||
(isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3))
|
||||
) {
|
||||
itemsDimensions = [
|
||||
{ w: 50, h: '50%', b: '2px', r: '2px' },
|
||||
{ w: 50, h: '100%', l: '2px', float: 'right' },
|
||||
{ w: 50, h: '50%', t: '2px', r: '2px' },
|
||||
];
|
||||
} else if (
|
||||
(isPanoramic(ar1) && isPanoramic(ar2) && isNonConformingRatio(ar3)) ||
|
||||
(isPanoramic(ar1) && isPanoramic(ar2) && isPortrait(ar3))
|
||||
) {
|
||||
itemsDimensions = [
|
||||
{ w: 50, h: panoSize_px, b: '2px', r: '2px' },
|
||||
{ w: 50, h: panoSize_px, b: '2px', l: '2px' },
|
||||
{ w: 100, h: `${w - panoSize}px`, t: '2px' },
|
||||
];
|
||||
} else if (
|
||||
(isNonConformingRatio(ar1) && isPanoramic(ar2) && isPanoramic(ar3)) ||
|
||||
(isPortrait(ar1) && isPanoramic(ar2) && isPanoramic(ar3))
|
||||
) {
|
||||
itemsDimensions = [
|
||||
{ w: 100, h: `${w - panoSize}px`, b: '2px' },
|
||||
{ w: 50, h: panoSize_px, t: '2px', r: '2px' },
|
||||
{ w: 50, h: panoSize_px, t: '2px', l: '2px' },
|
||||
];
|
||||
} else {
|
||||
itemsDimensions = [
|
||||
{ w: 50, h: '50%', b: '2px', r: '2px' },
|
||||
{ w: 50, h: '50%', b: '2px', l: '2px' },
|
||||
{ w: 100, h: '50%', t: '2px' },
|
||||
];
|
||||
}
|
||||
} else if (size >= 4) {
|
||||
if (
|
||||
(isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3) && isPortrait(ar4)) ||
|
||||
(isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3) && isNonConformingRatio(ar4)) ||
|
||||
(isPortrait(ar1) && isPortrait(ar2) && isNonConformingRatio(ar3) && isPortrait(ar4)) ||
|
||||
(isPortrait(ar1) && isNonConformingRatio(ar2) && isPortrait(ar3) && isPortrait(ar4)) ||
|
||||
(isNonConformingRatio(ar1) && isPortrait(ar2) && isPortrait(ar3) && isPortrait(ar4))
|
||||
) {
|
||||
style.height = Math.floor(w / minimumAspectRatio);
|
||||
} else if (isPanoramic(ar1) && isPanoramic(ar2) && isPanoramic(ar3) && isPanoramic(ar4)) {
|
||||
style.height = panoSize * 2;
|
||||
} else if (
|
||||
(isPanoramic(ar1) && isPanoramic(ar2) && isNonConformingRatio(ar3) && isNonConformingRatio(ar4)) ||
|
||||
(isNonConformingRatio(ar1) && isNonConformingRatio(ar2) && isPanoramic(ar3) && isPanoramic(ar4))
|
||||
) {
|
||||
style.height = panoSize + (w / 2);
|
||||
} else {
|
||||
style.height = w;
|
||||
}
|
||||
|
||||
if (isPanoramic(ar1) && isPanoramic(ar2) && isNonConformingRatio(ar3) && isNonConformingRatio(ar4)) {
|
||||
itemsDimensions = [
|
||||
{ w: 50, h: panoSize_px, b: '2px', r: '2px' },
|
||||
{ w: 50, h: panoSize_px, b: '2px', l: '2px' },
|
||||
{ w: 50, h: `${(w / 2)}px`, t: '2px', r: '2px' },
|
||||
{ w: 50, h: `${(w / 2)}px`, t: '2px', l: '2px' },
|
||||
];
|
||||
} else if (isNonConformingRatio(ar1) && isNonConformingRatio(ar2) && isPanoramic(ar3) && isPanoramic(ar4)) {
|
||||
itemsDimensions = [
|
||||
{ w: 50, h: `${(w / 2)}px`, b: '2px', r: '2px' },
|
||||
{ w: 50, h: `${(w / 2)}px`, b: '2px', l: '2px' },
|
||||
{ w: 50, h: panoSize_px, t: '2px', r: '2px' },
|
||||
{ w: 50, h: panoSize_px, t: '2px', l: '2px' },
|
||||
];
|
||||
} else if (
|
||||
(isPortrait(ar1) && isNonConformingRatio(ar2) && isNonConformingRatio(ar3) && isNonConformingRatio(ar4)) ||
|
||||
(isPortrait(ar1) && isPanoramic(ar2) && isPanoramic(ar3) && isPanoramic(ar4))
|
||||
) {
|
||||
itemsDimensions = [
|
||||
{ w: 67, h: '100%', r: '2px' },
|
||||
{ w: 33, h: '33%', b: '4px', l: '2px' },
|
||||
{ w: 33, h: '33%', l: '2px' },
|
||||
{ w: 33, h: '33%', t: '4px', l: '2px' },
|
||||
];
|
||||
} else {
|
||||
itemsDimensions = [
|
||||
{ w: 50, h: '50%', b: '2px', r: '2px' },
|
||||
{ w: 50, h: '50%', b: '2px', l: '2px' },
|
||||
{ w: 50, h: '50%', t: '2px', r: '2px' },
|
||||
{ w: 50, h: '50%', t: '2px', l: '2px' },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
style,
|
||||
itemsDimensions,
|
||||
size,
|
||||
width: w,
|
||||
};
|
||||
};
|
||||
|
||||
const getSizeData = (size: number): Readonly<SizeData> => {
|
||||
const w = width || defaultWidth;
|
||||
|
||||
if (w) {
|
||||
if (size === 1) return getSizeDataSingle();
|
||||
if (size > 1) return getSizeDataMultiple(size);
|
||||
}
|
||||
|
||||
return {
|
||||
style: { height },
|
||||
itemsDimensions: [],
|
||||
size,
|
||||
width: w,
|
||||
};
|
||||
};
|
||||
|
||||
return getSizeData(media.size);
|
||||
};
|
||||
|
||||
export { useMediaSizeData };
|
|
@ -1,5 +1,6 @@
|
|||
import React from 'react';
|
||||
|
||||
import StillVideo from 'soapbox/components/still-video';
|
||||
import { Icon } from 'soapbox/components/ui';
|
||||
import { MIMETYPE_ICONS } from 'soapbox/components/upload';
|
||||
|
||||
|
@ -7,20 +8,21 @@ import type { Attachment } from 'soapbox/types/entities';
|
|||
|
||||
const defaultIcon = require('@tabler/icons/paperclip.svg');
|
||||
|
||||
interface IChatUploadPreview {
|
||||
className?: string
|
||||
interface IMediaPreview {
|
||||
attachment: Attachment
|
||||
withExt?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a generic preview for an upload depending on its media type.
|
||||
* Displays a generic preview for an attachment depending on its media type.
|
||||
* It fills its container and is expected to be sized by its parent.
|
||||
*/
|
||||
const ChatUploadPreview: React.FC<IChatUploadPreview> = ({ className, attachment }) => {
|
||||
const MediaPreview: React.FC<IMediaPreview> = ({ attachment, withExt }) => {
|
||||
const mimeType = attachment.pleroma.get('mime_type') as string | undefined;
|
||||
|
||||
switch (attachment.type) {
|
||||
case 'image':
|
||||
case 'gifv':
|
||||
return (
|
||||
<img
|
||||
className='pointer-events-none h-full w-full object-cover'
|
||||
|
@ -30,14 +32,10 @@ const ChatUploadPreview: React.FC<IChatUploadPreview> = ({ className, attachment
|
|||
);
|
||||
case 'video':
|
||||
return (
|
||||
<video
|
||||
className='pointer-events-none h-full w-full object-cover'
|
||||
src={attachment.preview_url}
|
||||
autoPlay
|
||||
playsInline
|
||||
controls={false}
|
||||
muted
|
||||
loop
|
||||
<StillVideo
|
||||
className='h-full w-full object-cover'
|
||||
src={attachment.url}
|
||||
withExt={withExt}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
|
@ -52,4 +50,4 @@ const ChatUploadPreview: React.FC<IChatUploadPreview> = ({ className, attachment
|
|||
}
|
||||
};
|
||||
|
||||
export default ChatUploadPreview;
|
||||
export default MediaPreview;
|
|
@ -3,6 +3,8 @@ import React, { useRef } from 'react';
|
|||
|
||||
import { useSettings } from 'soapbox/hooks';
|
||||
|
||||
import ExtensionBadge from './extension-badge';
|
||||
|
||||
interface IStillImage {
|
||||
/** Image alt text. */
|
||||
alt?: string,
|
||||
|
@ -78,18 +80,4 @@ const StillImage: React.FC<IStillImage> = ({ alt, className, src, style, letterb
|
|||
);
|
||||
};
|
||||
|
||||
interface IExtensionBadge {
|
||||
/** File extension. */
|
||||
ext: string,
|
||||
}
|
||||
|
||||
/** Badge displaying a file extension. */
|
||||
const ExtensionBadge: React.FC<IExtensionBadge> = ({ ext }) => {
|
||||
return (
|
||||
<div className='inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-sm font-medium text-gray-900 dark:bg-gray-800 dark:text-gray-100'>
|
||||
{ext}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StillImage;
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import ExtensionBadge from './extension-badge';
|
||||
|
||||
interface IStillVideo {
|
||||
src: string
|
||||
className?: string
|
||||
hoverToPlay?: boolean
|
||||
playbackRate?: number
|
||||
withExt?: boolean
|
||||
}
|
||||
|
||||
/** Displays a frozen frame of a video unless hovered. */
|
||||
const StillVideo: React.FC<IStillVideo> = ({
|
||||
src,
|
||||
className,
|
||||
hoverToPlay = true,
|
||||
playbackRate = 3,
|
||||
withExt = true,
|
||||
}) => {
|
||||
// https://stackoverflow.com/a/4695156
|
||||
const ext = src.split('.').pop()?.toUpperCase();
|
||||
|
||||
const handleMouseEnter: React.MouseEventHandler<HTMLVideoElement> = ({ currentTarget: video }) => {
|
||||
if (hoverToPlay) {
|
||||
video.playbackRate = playbackRate;
|
||||
video.play();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave: React.MouseEventHandler<HTMLVideoElement> = ({ currentTarget: video }) => {
|
||||
if (hoverToPlay) {
|
||||
video.pause();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick: React.MouseEventHandler<HTMLVideoElement> = (e) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx(className, 'relative isolate overflow-hidden')}>
|
||||
<video
|
||||
className='h-full w-full object-cover'
|
||||
src={src}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={handleClick}
|
||||
playsInline
|
||||
controls={false}
|
||||
muted
|
||||
loop
|
||||
/>
|
||||
|
||||
{(withExt && ext) && (
|
||||
<div className='pointer-events-none absolute left-2 bottom-2 opacity-90'>
|
||||
<ExtensionBadge ext={ext} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StillVideo;
|
|
@ -4,11 +4,10 @@ import React from 'react';
|
|||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import Blurhash from 'soapbox/components/blurhash';
|
||||
import MediaPreview from 'soapbox/components/media/media-preview';
|
||||
import { Icon } from 'soapbox/components/ui';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
|
||||
import ChatUploadPreview from './chat-upload-preview';
|
||||
|
||||
import type { Attachment } from 'soapbox/types/entities';
|
||||
|
||||
interface IChatUpload {
|
||||
|
@ -29,7 +28,7 @@ const ChatUpload: React.FC<IChatUpload> = ({ attachment, onDelete }) => {
|
|||
<div className='relative isolate inline-block h-24 w-24 overflow-hidden rounded-lg bg-gray-200 dark:bg-primary-900'>
|
||||
<Blurhash hash={attachment.blurhash} className='absolute inset-0 -z-10 h-full w-full' />
|
||||
|
||||
<div className='absolute right-[6px] top-[6px]'>
|
||||
<div className='absolute right-[6px] top-[6px] z-10'>
|
||||
<RemoveButton onClick={onDelete} />
|
||||
</div>
|
||||
|
||||
|
@ -37,7 +36,7 @@ const ChatUpload: React.FC<IChatUpload> = ({ attachment, onDelete }) => {
|
|||
onClick={clickable ? handleOpenModal : undefined}
|
||||
className={clsx('h-full w-full', { 'cursor-zoom-in': clickable, 'cursor-default': !clickable })}
|
||||
>
|
||||
<ChatUploadPreview attachment={attachment} />
|
||||
<MediaPreview attachment={attachment} withExt={false} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -6,7 +6,7 @@ import { defineMessages, useIntl } from 'react-intl';
|
|||
|
||||
import Blurhash from 'soapbox/components/blurhash';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { isPanoramic, isPortrait, minimumAspectRatio, maximumAspectRatio } from 'soapbox/utils/media-aspect-ratio';
|
||||
import { containAspectRatio } from 'soapbox/utils/media-aspect-ratio';
|
||||
|
||||
import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
|
||||
|
||||
|
@ -424,15 +424,7 @@ const Video: React.FC<IVideo> = ({
|
|||
|
||||
if (inline && containerWidth) {
|
||||
width = containerWidth;
|
||||
const minSize = containerWidth / (16 / 9);
|
||||
|
||||
if (isPanoramic(aspectRatio)) {
|
||||
height = Math.max(Math.floor(containerWidth / maximumAspectRatio), minSize);
|
||||
} else if (isPortrait(aspectRatio)) {
|
||||
height = Math.max(Math.floor(containerWidth / minimumAspectRatio), minSize);
|
||||
} else {
|
||||
height = Math.floor(containerWidth / aspectRatio);
|
||||
}
|
||||
height = Math.floor(containerWidth / containAspectRatio(aspectRatio));
|
||||
|
||||
playerStyle.height = height || DEFAULT_HEIGHT;
|
||||
}
|
||||
|
|
|
@ -1,17 +1,38 @@
|
|||
export const minimumAspectRatio = 9 / 16; // Portrait phone
|
||||
export const maximumAspectRatio = 10; // Generous min-height
|
||||
/** Minimum aspect ratio for layout/display. */
|
||||
export const minimumAspectRatio = 3 / 4;
|
||||
/** Maximum aspect ratio for layout/display. */
|
||||
export const maximumAspectRatio = 4 / 3;
|
||||
/** Minimum aspect ratio for letterboxing (portrait phone). */
|
||||
export const letterboxMinRatio = 9 / 16;
|
||||
/** Maximum aspect ratio for letterboxing (generous min-height). */
|
||||
export const letterboxMaxRatio = 10;
|
||||
|
||||
export const isPanoramic = (ar: number) => {
|
||||
if (isNaN(ar)) return false;
|
||||
return ar >= maximumAspectRatio;
|
||||
/** The media is significantly horizontal. */
|
||||
export const isPanoramic = (aspectRatio: number) => {
|
||||
if (isNaN(aspectRatio)) return false;
|
||||
return aspectRatio >= maximumAspectRatio;
|
||||
};
|
||||
|
||||
export const isPortrait = (ar: number) => {
|
||||
if (isNaN(ar)) return false;
|
||||
return ar <= minimumAspectRatio;
|
||||
/** The media is significantly vertical. */
|
||||
export const isPortrait = (aspectRatio: number) => {
|
||||
if (isNaN(aspectRatio)) return false;
|
||||
return aspectRatio <= minimumAspectRatio;
|
||||
};
|
||||
|
||||
export const isNonConformingRatio = (ar: number) => {
|
||||
if (isNaN(ar)) return false;
|
||||
return !isPanoramic(ar) && !isPortrait(ar);
|
||||
/** The media is mostly square. */
|
||||
export const isNonConformingRatio = (aspectRatio: number) => {
|
||||
if (isNaN(aspectRatio)) return false;
|
||||
return !isPanoramic(aspectRatio) && !isPortrait(aspectRatio);
|
||||
};
|
||||
|
||||
/** Whether to letterbox the media. */
|
||||
export const shouldLetterbox = (aspectRatio?: number): boolean => {
|
||||
if (!aspectRatio) return true;
|
||||
const withinLimits = aspectRatio >= letterboxMinRatio && aspectRatio <= letterboxMaxRatio;
|
||||
return !withinLimits;
|
||||
};
|
||||
|
||||
/** Force aspect ratio into letterbox limits. */
|
||||
export const containAspectRatio = (aspectRatio: number): number => {
|
||||
return Math.max(Math.min(aspectRatio, letterboxMaxRatio), letterboxMinRatio);
|
||||
};
|
|
@ -1,137 +1,60 @@
|
|||
.media-gallery {
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
isolation: isolate;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background-color: var(--brand-color--faint);
|
||||
@apply box-border overflow-hidden rounded-xl isolate relative w-full h-auto;
|
||||
}
|
||||
|
||||
.media-gallery__item {
|
||||
border: 0;
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
float: left;
|
||||
position: relative;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
@apply border-0 box-border block float-left relative rounded-xl overflow-hidden;
|
||||
|
||||
&__icons {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
@apply absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2;
|
||||
|
||||
.svg-icon {
|
||||
@apply h-24 w-24;
|
||||
}
|
||||
}
|
||||
|
||||
&-overflow {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
z-index: 2;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
font-size: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.media-gallery__item-thumbnail {
|
||||
@apply text-gray-400;
|
||||
cursor: zoom-in;
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
@apply block relative text-gray-400 cursor-zoom-in z-10 w-full h-full;
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
@apply w-full h-full object-cover;
|
||||
}
|
||||
}
|
||||
|
||||
.media-gallery__preview {
|
||||
@apply bg-gray-200 dark:bg-gray-900 rounded-lg;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
@apply bg-gray-200 dark:bg-gray-900 rounded-lg w-full h-full object-cover absolute top-0 left-0 z-0;
|
||||
|
||||
&--hidden {
|
||||
display: none;
|
||||
@apply hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.media-gallery__gifv {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@apply w-full h-full overflow-hidden relative;
|
||||
}
|
||||
|
||||
.media-gallery__item-gifv-thumbnail {
|
||||
@apply rounded-md;
|
||||
cursor: zoom-in;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
transform: none;
|
||||
top: 0;
|
||||
@apply rounded-md cursor-zoom-in h-full w-full top-0 z-10 object-cover relative transform-none;
|
||||
}
|
||||
|
||||
.media-gallery__gifv__label,
|
||||
.media-gallery__filename__label,
|
||||
.media-gallery__file-extension__label {
|
||||
display: block;
|
||||
position: absolute;
|
||||
color: #fff;
|
||||
@apply block absolute bg-white leading-4 opacity-90 z-10 font-semibold text-xs rounded-sm bottom-1 left-1 px-0.5 py-1 pointer-events-none transition-opacity;
|
||||
background: rgba($base-overlay-background, 0.5);
|
||||
bottom: 6px;
|
||||
left: 6px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 2px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
opacity: 0.9;
|
||||
transition: opacity 0.1s ease;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.media-gallery__gifv {
|
||||
&.autoplay {
|
||||
.media-gallery__gifv__label {
|
||||
display: none;
|
||||
@apply hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.media-gallery__gifv__label {
|
||||
opacity: 1;
|
||||
@apply opacity-100;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -139,18 +62,18 @@
|
|||
$media-compact-size: 50px;
|
||||
|
||||
.media-gallery--compact {
|
||||
@apply bg-transparent;
|
||||
height: $media-compact-size !important;
|
||||
background: transparent;
|
||||
|
||||
.media-gallery__item {
|
||||
@apply mr-1;
|
||||
width: $media-compact-size !important;
|
||||
height: $media-compact-size !important;
|
||||
inset: auto !important;
|
||||
float: left !important;
|
||||
margin-right: 5px;
|
||||
|
||||
&-overflow {
|
||||
font-size: 20px;
|
||||
@apply text-xl;
|
||||
}
|
||||
|
||||
&__icons .svg-icon {
|
||||
|
@ -159,6 +82,6 @@ $media-compact-size: 50px;
|
|||
}
|
||||
|
||||
.media-gallery__file-extension__label {
|
||||
display: none;
|
||||
@apply hidden;
|
||||
}
|
||||
}
|
||||
|
|
Ładowanie…
Reference in New Issue