sforkowany z mirror/soapbox
Porównaj commity
13 Commity
develop
...
media-gall
Autor | SHA1 | Data |
---|---|---|
![]() |
49b996a901 | |
![]() |
fd2bb2e16f | |
![]() |
bbb29d0388 | |
![]() |
c8509627a1 | |
![]() |
ffc8ade279 | |
![]() |
0354e8e96f | |
![]() |
07323c19b0 | |
![]() |
4e74ca3c55 | |
![]() |
215c857648 | |
![]() |
6bbd00c658 | |
![]() |
ca4fa5e5c5 | |
![]() |
4167a1de05 | |
![]() |
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 clsx from 'clsx';
|
||||||
import React, { useState, useRef, useLayoutEffect } from 'react';
|
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 { Attachment } from 'soapbox/types/entities';
|
||||||
import { truncateFilename } from 'soapbox/utils/media';
|
|
||||||
|
|
||||||
import { isIOS } from '../is-mobile';
|
import { ATTACHMENT_LIMIT } from './media-gallery/constants';
|
||||||
import { isPanoramic, isPortrait, isNonConformingRatio, minimumAspectRatio, maximumAspectRatio } from '../utils/media-aspect-ratio';
|
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';
|
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 {
|
interface IMediaGallery {
|
||||||
sensitive?: boolean,
|
sensitive?: boolean,
|
||||||
media: ImmutableList<Attachment>,
|
media: ImmutableList<Attachment>,
|
||||||
|
@ -285,241 +34,14 @@ const MediaGallery: React.FC<IMediaGallery> = (props) => {
|
||||||
const [width, setWidth] = useState<number>(defaultWidth);
|
const [width, setWidth] = useState<number>(defaultWidth);
|
||||||
|
|
||||||
const node = useRef<HTMLDivElement>(null);
|
const node = useRef<HTMLDivElement>(null);
|
||||||
|
const sizeData = useMediaSizeData({ media, width, height, defaultWidth });
|
||||||
|
|
||||||
const handleClick = (index: number) => {
|
const handleClick = (index: number) => {
|
||||||
onOpenMedia(media, index);
|
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) => (
|
const children = media.take(ATTACHMENT_LIMIT).map((attachment, i) => (
|
||||||
<Item
|
<MediaItem
|
||||||
key={attachment.id}
|
key={attachment.id}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
attachment={attachment}
|
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 React from 'react';
|
||||||
|
|
||||||
|
import StillVideo from 'soapbox/components/still-video';
|
||||||
import { Icon } from 'soapbox/components/ui';
|
import { Icon } from 'soapbox/components/ui';
|
||||||
import { MIMETYPE_ICONS } from 'soapbox/components/upload';
|
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');
|
const defaultIcon = require('@tabler/icons/paperclip.svg');
|
||||||
|
|
||||||
interface IChatUploadPreview {
|
interface IMediaPreview {
|
||||||
className?: string
|
|
||||||
attachment: Attachment
|
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.
|
* 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;
|
const mimeType = attachment.pleroma.get('mime_type') as string | undefined;
|
||||||
|
|
||||||
switch (attachment.type) {
|
switch (attachment.type) {
|
||||||
case 'image':
|
case 'image':
|
||||||
|
case 'gifv':
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
className='pointer-events-none h-full w-full object-cover'
|
className='pointer-events-none h-full w-full object-cover'
|
||||||
|
@ -30,14 +32,10 @@ const ChatUploadPreview: React.FC<IChatUploadPreview> = ({ className, attachment
|
||||||
);
|
);
|
||||||
case 'video':
|
case 'video':
|
||||||
return (
|
return (
|
||||||
<video
|
<StillVideo
|
||||||
className='pointer-events-none h-full w-full object-cover'
|
className='h-full w-full object-cover'
|
||||||
src={attachment.preview_url}
|
src={attachment.url}
|
||||||
autoPlay
|
withExt={withExt}
|
||||||
playsInline
|
|
||||||
controls={false}
|
|
||||||
muted
|
|
||||||
loop
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
default:
|
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 { useSettings } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import ExtensionBadge from './extension-badge';
|
||||||
|
|
||||||
interface IStillImage {
|
interface IStillImage {
|
||||||
/** Image alt text. */
|
/** Image alt text. */
|
||||||
alt?: string,
|
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;
|
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 { openModal } from 'soapbox/actions/modals';
|
||||||
import Blurhash from 'soapbox/components/blurhash';
|
import Blurhash from 'soapbox/components/blurhash';
|
||||||
|
import MediaPreview from 'soapbox/components/media/media-preview';
|
||||||
import { Icon } from 'soapbox/components/ui';
|
import { Icon } from 'soapbox/components/ui';
|
||||||
import { useAppDispatch } from 'soapbox/hooks';
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
|
|
||||||
import ChatUploadPreview from './chat-upload-preview';
|
|
||||||
|
|
||||||
import type { Attachment } from 'soapbox/types/entities';
|
import type { Attachment } from 'soapbox/types/entities';
|
||||||
|
|
||||||
interface IChatUpload {
|
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'>
|
<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' />
|
<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} />
|
<RemoveButton onClick={onDelete} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -37,7 +36,7 @@ const ChatUpload: React.FC<IChatUpload> = ({ attachment, onDelete }) => {
|
||||||
onClick={clickable ? handleOpenModal : undefined}
|
onClick={clickable ? handleOpenModal : undefined}
|
||||||
className={clsx('h-full w-full', { 'cursor-zoom-in': clickable, 'cursor-default': !clickable })}
|
className={clsx('h-full w-full', { 'cursor-zoom-in': clickable, 'cursor-default': !clickable })}
|
||||||
>
|
>
|
||||||
<ChatUploadPreview attachment={attachment} />
|
<MediaPreview attachment={attachment} withExt={false} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -6,7 +6,7 @@ import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import Blurhash from 'soapbox/components/blurhash';
|
import Blurhash from 'soapbox/components/blurhash';
|
||||||
import Icon from 'soapbox/components/icon';
|
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';
|
import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
|
||||||
|
|
||||||
|
@ -424,15 +424,7 @@ const Video: React.FC<IVideo> = ({
|
||||||
|
|
||||||
if (inline && containerWidth) {
|
if (inline && containerWidth) {
|
||||||
width = containerWidth;
|
width = containerWidth;
|
||||||
const minSize = containerWidth / (16 / 9);
|
height = Math.floor(containerWidth / containAspectRatio(aspectRatio));
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
playerStyle.height = height || DEFAULT_HEIGHT;
|
playerStyle.height = height || DEFAULT_HEIGHT;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,17 +1,38 @@
|
||||||
export const minimumAspectRatio = 9 / 16; // Portrait phone
|
/** Minimum aspect ratio for layout/display. */
|
||||||
export const maximumAspectRatio = 10; // Generous min-height
|
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) => {
|
/** The media is significantly horizontal. */
|
||||||
if (isNaN(ar)) return false;
|
export const isPanoramic = (aspectRatio: number) => {
|
||||||
return ar >= maximumAspectRatio;
|
if (isNaN(aspectRatio)) return false;
|
||||||
|
return aspectRatio >= maximumAspectRatio;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isPortrait = (ar: number) => {
|
/** The media is significantly vertical. */
|
||||||
if (isNaN(ar)) return false;
|
export const isPortrait = (aspectRatio: number) => {
|
||||||
return ar <= minimumAspectRatio;
|
if (isNaN(aspectRatio)) return false;
|
||||||
|
return aspectRatio <= minimumAspectRatio;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isNonConformingRatio = (ar: number) => {
|
/** The media is mostly square. */
|
||||||
if (isNaN(ar)) return false;
|
export const isNonConformingRatio = (aspectRatio: number) => {
|
||||||
return !isPanoramic(ar) && !isPortrait(ar);
|
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 {
|
.media-gallery {
|
||||||
box-sizing: border-box;
|
@apply box-border overflow-hidden rounded-xl isolate relative w-full h-auto;
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 10px;
|
|
||||||
isolation: isolate;
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
background-color: var(--brand-color--faint);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-gallery__item {
|
.media-gallery__item {
|
||||||
border: 0;
|
@apply border-0 box-border block float-left relative rounded-xl overflow-hidden;
|
||||||
box-sizing: border-box;
|
|
||||||
display: block;
|
|
||||||
float: left;
|
|
||||||
position: relative;
|
|
||||||
border-radius: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&__icons {
|
&__icons {
|
||||||
position: absolute;
|
@apply absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2;
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
|
|
||||||
.svg-icon {
|
.svg-icon {
|
||||||
@apply h-24 w-24;
|
@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 {
|
.media-gallery__item-thumbnail {
|
||||||
@apply text-gray-400;
|
@apply block relative text-gray-400 cursor-zoom-in z-10 w-full h-full;
|
||||||
cursor: zoom-in;
|
|
||||||
display: block;
|
|
||||||
text-decoration: none;
|
|
||||||
line-height: 0;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
video {
|
video {
|
||||||
width: 100%;
|
@apply w-full h-full object-cover;
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-gallery__preview {
|
.media-gallery__preview {
|
||||||
@apply bg-gray-200 dark:bg-gray-900 rounded-lg;
|
@apply bg-gray-200 dark:bg-gray-900 rounded-lg w-full h-full object-cover absolute top-0 left-0 z-0;
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: 0;
|
|
||||||
|
|
||||||
&--hidden {
|
&--hidden {
|
||||||
display: none;
|
@apply hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-gallery__gifv {
|
.media-gallery__gifv {
|
||||||
height: 100%;
|
@apply w-full h-full overflow-hidden relative;
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-gallery__item-gifv-thumbnail {
|
.media-gallery__item-gifv-thumbnail {
|
||||||
@apply rounded-md;
|
@apply rounded-md cursor-zoom-in h-full w-full top-0 z-10 object-cover relative transform-none;
|
||||||
cursor: zoom-in;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
z-index: 1;
|
|
||||||
transform: none;
|
|
||||||
top: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-gallery__gifv__label,
|
.media-gallery__gifv__label,
|
||||||
.media-gallery__filename__label,
|
.media-gallery__filename__label,
|
||||||
.media-gallery__file-extension__label {
|
.media-gallery__file-extension__label {
|
||||||
display: block;
|
@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;
|
||||||
position: absolute;
|
|
||||||
color: #fff;
|
|
||||||
background: rgba($base-overlay-background, 0.5);
|
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 {
|
.media-gallery__gifv {
|
||||||
&.autoplay {
|
&.autoplay {
|
||||||
.media-gallery__gifv__label {
|
.media-gallery__gifv__label {
|
||||||
display: none;
|
@apply hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
.media-gallery__gifv__label {
|
.media-gallery__gifv__label {
|
||||||
opacity: 1;
|
@apply opacity-100;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -139,18 +62,18 @@
|
||||||
$media-compact-size: 50px;
|
$media-compact-size: 50px;
|
||||||
|
|
||||||
.media-gallery--compact {
|
.media-gallery--compact {
|
||||||
|
@apply bg-transparent;
|
||||||
height: $media-compact-size !important;
|
height: $media-compact-size !important;
|
||||||
background: transparent;
|
|
||||||
|
|
||||||
.media-gallery__item {
|
.media-gallery__item {
|
||||||
|
@apply mr-1;
|
||||||
width: $media-compact-size !important;
|
width: $media-compact-size !important;
|
||||||
height: $media-compact-size !important;
|
height: $media-compact-size !important;
|
||||||
inset: auto !important;
|
inset: auto !important;
|
||||||
float: left !important;
|
float: left !important;
|
||||||
margin-right: 5px;
|
|
||||||
|
|
||||||
&-overflow {
|
&-overflow {
|
||||||
font-size: 20px;
|
@apply text-xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__icons .svg-icon {
|
&__icons .svg-icon {
|
||||||
|
@ -159,6 +82,6 @@ $media-compact-size: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-gallery__file-extension__label {
|
.media-gallery__file-extension__label {
|
||||||
display: none;
|
@apply hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Ładowanie…
Reference in New Issue