Merge commit '2e9b2df570af19b67a5ccc754c7b581034ca79c6' into glitch-soc/merge-upstream

Conflicts:
- `app/javascript/styles/mastodon/components.scss`:
  Upstream removed some code that somehow had been modified in glitch-soc.
  Removed it as upstream did.
This commit is contained in:
Claire committed 2025-03-24 20:06:25 +01:00
commit e7498887a7
30 files changed
+808 -815

No files matched your search

@@ -12,6 +12,7 @@ import { debounce } from 'lodash';
import { AltTextBadge } from 'mastodon/components/alt_text_badge';
import { Blurhash } from 'mastodon/components/blurhash';
import { SpoilerButton } from 'mastodon/components/spoiler_button';
import { formatTime } from 'mastodon/features/video';
import { autoPlayGif, displayMedia, useBlurhash } from '../initial_state';
@@ -299,7 +300,7 @@ class MediaGallery extends PureComponent {
const { visible } = this.state;
const width = this.state.width || defaultWidth;
let children, spoilerButton;
let children;
const style = {};
@@ -318,35 +319,11 @@ class MediaGallery extends PureComponent {
children = media.map((attachment, i) => <Item key={attachment.get('id')} autoplay={autoplay} onClick={this.handleClick} attachment={attachment} index={i} lang={lang} size={size} displayWidth={width} visible={visible || uncached} />);
}
if (uncached) {
spoilerButton = (
<button type='button' disabled className='spoiler-button__overlay'>
<span className='spoiler-button__overlay__label'>
<FormattedMessage id='status.uncached_media_warning' defaultMessage='Preview not available' />
<span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.open' defaultMessage='Click to open' /></span>
</span>
</button>
);
} else if (!visible) {
spoilerButton = (
<button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
<span className='spoiler-button__overlay__label'>
{sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />}
<span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.show' defaultMessage='Click to show' /></span>
</span>
</button>
);
}
return (
<div className={`media-gallery media-gallery--layout-${size}`} style={style} ref={this.handleRef}>
{children}
{(!visible || uncached) && (
<div className={classNames('spoiler-button', { 'spoiler-button--click-thru': uncached })}>
{spoilerButton}
</div>
)}
{(!visible || uncached) && <SpoilerButton uncached={uncached} sensitive={sensitive} onClick={this.handleOpen} />}
{(visible && !uncached) && (
<div className='media-gallery__actions'>
@@ -0,0 +1,73 @@
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
interface Props {
hidden?: boolean;
sensitive: boolean;
uncached?: boolean;
onClick: React.MouseEventHandler<HTMLButtonElement>;
}
export const SpoilerButton: React.FC<Props> = ({
hidden = false,
sensitive,
uncached = false,
onClick,
}) => {
let warning;
let action;
if (uncached) {
warning = (
<FormattedMessage
id='status.uncached_media_warning'
defaultMessage='Preview not available'
/>
);
action = (
<FormattedMessage id='status.media.open' defaultMessage='Click to open' />
);
} else if (sensitive) {
warning = (
<FormattedMessage
id='status.sensitive_warning'
defaultMessage='Sensitive content'
/>
);
action = (
<FormattedMessage id='status.media.show' defaultMessage='Click to show' />
);
} else {
warning = (
<FormattedMessage
id='status.media_hidden'
defaultMessage='Media hidden'
/>
);
action = (
<FormattedMessage id='status.media.show' defaultMessage='Click to show' />
);
}
return (
<div
className={classNames('spoiler-button', {
'spoiler-button--hidden': hidden,
'spoiler-button--click-thru': uncached,
})}
>
<button
type='button'
className='spoiler-button__overlay'
onClick={onClick}
disabled={uncached}
>
<span className='spoiler-button__overlay__label'>
{warning}
<span className='spoiler-button__overlay__action'>{action}</span>
</span>
</button>
</div>
);
};
@@ -1,7 +1,7 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';
import classNames from 'classnames';
@@ -16,6 +16,7 @@ import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?reac
import VolumeOffIcon from '@/material-icons/400-24px/volume_off-fill.svg?react';
import VolumeUpIcon from '@/material-icons/400-24px/volume_up-fill.svg?react';
import { Icon } from 'mastodon/components/icon';
import { SpoilerButton } from 'mastodon/components/spoiler_button';
import { formatTime, getPointerPosition, fileNameFromURL } from 'mastodon/features/video';
import { Blurhash } from '../../components/blurhash';
@@ -476,14 +477,6 @@ class Audio extends PureComponent {
const progress = Math.min((currentTime / duration) * 100, 100);
const muted = this.state.muted || volume === 0;
let warning;
if (sensitive) {
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
} else {
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
}
return (
<div className={classNames('audio-player', { editable, inactive: !revealed })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), aspectRatio: '16 / 9' }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} tabIndex={0} onKeyDown={this.handleKeyDown}>
@@ -521,14 +514,7 @@ class Audio extends PureComponent {
lang={lang}
/>
<div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed || editable })}>
<button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
<span className='spoiler-button__overlay__label'>
{warning}
<span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.show' defaultMessage='Click to show' /></span>
</span>
</button>
</div>
<SpoilerButton hidden={revealed || editable} sensitive={sensitive} onClick={this.toggleReveal} />
{(revealed || editable) && <img
src={this.props.poster}
@@ -1,175 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import classNames from 'classnames';
import { LoadingBar } from 'react-redux-loading-bar';
import ZoomableImage from './zoomable_image';
export default class ImageLoader extends PureComponent {
static propTypes = {
alt: PropTypes.string,
lang: PropTypes.string,
src: PropTypes.string.isRequired,
previewSrc: PropTypes.string,
width: PropTypes.number,
height: PropTypes.number,
onClick: PropTypes.func,
zoomedIn: PropTypes.bool,
};
static defaultProps = {
alt: '',
lang: '',
width: null,
height: null,
};
state = {
loading: true,
error: false,
width: null,
};
removers = [];
canvas = null;
get canvasContext() {
if (!this.canvas) {
return null;
}
this._canvasContext = this._canvasContext || this.canvas.getContext('2d');
return this._canvasContext;
}
componentDidMount () {
this.loadImage(this.props);
}
UNSAFE_componentWillReceiveProps (nextProps) {
if (this.props.src !== nextProps.src) {
this.loadImage(nextProps);
}
}
componentWillUnmount () {
this.removeEventListeners();
}
loadImage (props) {
this.removeEventListeners();
this.setState({ loading: true, error: false });
Promise.all([
props.previewSrc && this.loadPreviewCanvas(props),
this.hasSize() && this.loadOriginalImage(props),
].filter(Boolean))
.then(() => {
this.setState({ loading: false, error: false });
this.clearPreviewCanvas();
})
.catch(() => this.setState({ loading: false, error: true }));
}
loadPreviewCanvas = ({ previewSrc, width, height }) => new Promise((resolve, reject) => {
const image = new Image();
const removeEventListeners = () => {
image.removeEventListener('error', handleError);
image.removeEventListener('load', handleLoad);
};
const handleError = () => {
removeEventListeners();
reject();
};
const handleLoad = () => {
removeEventListeners();
this.canvasContext.drawImage(image, 0, 0, width, height);
resolve();
};
image.addEventListener('error', handleError);
image.addEventListener('load', handleLoad);
image.src = previewSrc;
this.removers.push(removeEventListeners);
});
clearPreviewCanvas () {
const { width, height } = this.canvas;
this.canvasContext.clearRect(0, 0, width, height);
}
loadOriginalImage = ({ src }) => new Promise((resolve, reject) => {
const image = new Image();
const removeEventListeners = () => {
image.removeEventListener('error', handleError);
image.removeEventListener('load', handleLoad);
};
const handleError = () => {
removeEventListeners();
reject();
};
const handleLoad = () => {
removeEventListeners();
resolve();
};
image.addEventListener('error', handleError);
image.addEventListener('load', handleLoad);
image.src = src;
this.removers.push(removeEventListeners);
});
removeEventListeners () {
this.removers.forEach(listeners => listeners());
this.removers = [];
}
hasSize () {
const { width, height } = this.props;
return typeof width === 'number' && typeof height === 'number';
}
setCanvasRef = c => {
this.canvas = c;
if (c) this.setState({ width: c.offsetWidth });
};
render () {
const { alt, lang, src, width, height, onClick, zoomedIn } = this.props;
const { loading } = this.state;
const className = classNames('image-loader', {
'image-loader--loading': loading,
'image-loader--amorphous': !this.hasSize(),
});
return (
<div className={className}>
{loading ? (
<>
<div className='loading-bar__container' style={{ width: this.state.width || width }}>
<LoadingBar className='loading-bar' loading={1} />
</div>
<canvas
className='image-loader__preview-canvas'
ref={this.setCanvasRef}
width={width}
height={height}
/>
</>
) : (
<ZoomableImage
alt={alt}
lang={lang}
src={src}
onClick={onClick}
width={width}
height={height}
zoomedIn={zoomedIn}
/>
)}
</div>
);
}
}
@@ -1,65 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import classNames from 'classnames';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { IconButton } from 'mastodon/components/icon_button';
import ImageLoader from './image_loader';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
});
class ImageModal extends PureComponent {
static propTypes = {
src: PropTypes.string.isRequired,
alt: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
state = {
navigationHidden: false,
};
toggleNavigation = () => {
this.setState(prevState => ({
navigationHidden: !prevState.navigationHidden,
}));
};
render () {
const { intl, src, alt, onClose } = this.props;
const { navigationHidden } = this.state;
const navigationClassName = classNames('media-modal__navigation', {
'media-modal__navigation--hidden': navigationHidden,
});
return (
<div className='modal-root__modal media-modal'>
<div className='media-modal__closer' role='presentation' onClick={onClose} >
<ImageLoader
src={src}
width={400}
height={400}
alt={alt}
onClick={this.toggleNavigation}
/>
</div>
<div className={navigationClassName}>
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' iconComponent={CloseIcon} onClick={onClose} size={40} />
</div>
</div>
);
}
}
export default injectIntl(ImageModal);
@@ -0,0 +1,61 @@
import { useCallback, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { IconButton } from 'mastodon/components/icon_button';
import { ZoomableImage } from './zoomable_image';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
});
export const ImageModal: React.FC<{
src: string;
alt: string;
onClose: () => void;
}> = ({ src, alt, onClose }) => {
const intl = useIntl();
const [navigationHidden, setNavigationHidden] = useState(false);
const toggleNavigation = useCallback(() => {
setNavigationHidden((prevState) => !prevState);
}, [setNavigationHidden]);
const navigationClassName = classNames('media-modal__navigation', {
'media-modal__navigation--hidden': navigationHidden,
});
return (
<div className='modal-root__modal media-modal'>
<div
className='media-modal__closer'
role='presentation'
onClick={onClose}
>
<ZoomableImage
src={src}
width={400}
height={400}
alt={alt}
onClick={toggleNavigation}
/>
</div>
<div className={navigationClassName}>
<div className='media-modal__buttons'>
<IconButton
className='media-modal__close'
title={intl.formatMessage(messages.close)}
icon='times'
iconComponent={CloseIcon}
onClick={onClose}
/>
</div>
</div>
</div>
);
};
@@ -22,7 +22,7 @@ import Footer from 'mastodon/features/picture_in_picture/components/footer';
import Video from 'mastodon/features/video';
import { disableSwiping } from 'mastodon/initial_state';
import ImageLoader from './image_loader';
import { ZoomableImage } from './zoomable_image';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
@@ -59,6 +59,12 @@ class MediaModal extends ImmutablePureComponent {
}));
};
handleZoomChange = (zoomedIn) => {
this.setState({
zoomedIn,
});
};
handleSwipe = (index) => {
this.setState({
index: index % this.props.media.size,
@@ -165,23 +171,26 @@ class MediaModal extends ImmutablePureComponent {
const leftNav = media.size > 1 && <button tabIndex={0} className='media-modal__nav media-modal__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><Icon id='chevron-left' icon={ChevronLeftIcon} /></button>;
const rightNav = media.size > 1 && <button tabIndex={0} className='media-modal__nav media-modal__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><Icon id='chevron-right' icon={ChevronRightIcon} /></button>;
const content = media.map((image) => {
const content = media.map((image, idx) => {
const width = image.getIn(['meta', 'original', 'width']) || null;
const height = image.getIn(['meta', 'original', 'height']) || null;
const description = image.getIn(['translation', 'description']) || image.get('description');
if (image.get('type') === 'image') {
return (
<ImageLoader
previewSrc={image.get('preview_url')}
<ZoomableImage
src={image.get('url')}
blurhash={image.get('blurhash')}
width={width}
height={height}
alt={description}
lang={lang}
key={image.get('url')}
onClick={this.handleToggleNavigation}
zoomedIn={zoomedIn}
onDoubleClick={this.handleZoomClick}
onClose={onClose}
onZoomChange={this.handleZoomChange}
zoomedIn={zoomedIn && idx === index}
/>
);
} else if (image.get('type') === 'video') {
@@ -262,7 +271,7 @@ class MediaModal extends ImmutablePureComponent {
onChangeIndex={this.handleSwipe}
onTransitionEnd={this.handleTransitionEnd}
index={index}
disabled={disableSwiping}
disabled={disableSwiping || zoomedIn}
>
{content}
</ReactSwipeableViews>
@@ -39,7 +39,7 @@ import {
ConfirmFollowToListModal,
ConfirmMissingAltTextModal,
} from './confirmation_modals';
import ImageModal from './image_modal';
import { ImageModal } from './image_modal';
import MediaModal from './media_modal';
import { ModalPlaceholder } from './modal_placeholder';
import VideoModal from './video_modal';
@@ -1,402 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
const MIN_SCALE = 1;
const MAX_SCALE = 4;
const NAV_BAR_HEIGHT = 66;
const getMidpoint = (p1, p2) => ({
x: (p1.clientX + p2.clientX) / 2,
y: (p1.clientY + p2.clientY) / 2,
});
const getDistance = (p1, p2) =>
Math.sqrt(Math.pow(p1.clientX - p2.clientX, 2) + Math.pow(p1.clientY - p2.clientY, 2));
const clamp = (min, max, value) => Math.min(max, Math.max(min, value));
// Normalizing mousewheel speed across browsers
// copy from: https://github.com/facebookarchive/fixed-data-table/blob/master/src/vendor_upstream/dom/normalizeWheel.js
const normalizeWheel = event => {
// Reasonable defaults
const PIXEL_STEP = 10;
const LINE_HEIGHT = 40;
const PAGE_HEIGHT = 800;
let sX = 0,
sY = 0, // spinX, spinY
pX = 0,
pY = 0; // pixelX, pixelY
// Legacy
if ('detail' in event) {
sY = event.detail;
}
if ('wheelDelta' in event) {
sY = -event.wheelDelta / 120;
}
if ('wheelDeltaY' in event) {
sY = -event.wheelDeltaY / 120;
}
if ('wheelDeltaX' in event) {
sX = -event.wheelDeltaX / 120;
}
// side scrolling on FF with DOMMouseScroll
if ('axis' in event && event.axis === event.HORIZONTAL_AXIS) {
sX = sY;
sY = 0;
}
pX = sX * PIXEL_STEP;
pY = sY * PIXEL_STEP;
if ('deltaY' in event) {
pY = event.deltaY;
}
if ('deltaX' in event) {
pX = event.deltaX;
}
if ((pX || pY) && event.deltaMode) {
if (event.deltaMode === 1) { // delta in LINE units
pX *= LINE_HEIGHT;
pY *= LINE_HEIGHT;
} else { // delta in PAGE units
pX *= PAGE_HEIGHT;
pY *= PAGE_HEIGHT;
}
}
// Fall-back if spin cannot be determined
if (pX && !sX) {
sX = (pX < 1) ? -1 : 1;
}
if (pY && !sY) {
sY = (pY < 1) ? -1 : 1;
}
return {
spinX: sX,
spinY: sY,
pixelX: pX,
pixelY: pY,
};
};
class ZoomableImage extends PureComponent {
static propTypes = {
alt: PropTypes.string,
lang: PropTypes.string,
src: PropTypes.string.isRequired,
width: PropTypes.number,
height: PropTypes.number,
onClick: PropTypes.func,
zoomedIn: PropTypes.bool,
};
static defaultProps = {
alt: '',
lang: '',
width: null,
height: null,
};
state = {
scale: MIN_SCALE,
zoomMatrix: {
type: null, // 'width' 'height'
fullScreen: null, // bool
rate: null, // full screen scale rate
clientWidth: null,
clientHeight: null,
offsetWidth: null,
offsetHeight: null,
clientHeightFixed: null,
scrollTop: null,
scrollLeft: null,
translateX: null,
translateY: null,
},
dragPosition: { top: 0, left: 0, x: 0, y: 0 },
dragged: false,
lockScroll: { x: 0, y: 0 },
lockTranslate: { x: 0, y: 0 },
};
removers = [];
container = null;
image = null;
lastTouchEndTime = 0;
lastDistance = 0;
componentDidMount () {
let handler = this.handleTouchStart;
this.container.addEventListener('touchstart', handler);
this.removers.push(() => this.container.removeEventListener('touchstart', handler));
handler = this.handleTouchMove;
// on Chrome 56+, touch event listeners will default to passive
// https://www.chromestatus.com/features/5093566007214080
this.container.addEventListener('touchmove', handler, { passive: false });
this.removers.push(() => this.container.removeEventListener('touchend', handler));
handler = this.mouseDownHandler;
this.container.addEventListener('mousedown', handler);
this.removers.push(() => this.container.removeEventListener('mousedown', handler));
handler = this.mouseWheelHandler;
this.container.addEventListener('wheel', handler);
this.removers.push(() => this.container.removeEventListener('wheel', handler));
// Old Chrome
this.container.addEventListener('mousewheel', handler);
this.removers.push(() => this.container.removeEventListener('mousewheel', handler));
// Old Firefox
this.container.addEventListener('DOMMouseScroll', handler);
this.removers.push(() => this.container.removeEventListener('DOMMouseScroll', handler));
this._initZoomMatrix();
}
componentWillUnmount () {
this._removeEventListeners();
}
componentDidUpdate (prevProps) {
if (prevProps.zoomedIn !== this.props.zoomedIn) {
this._toggleZoom();
}
}
_removeEventListeners () {
this.removers.forEach(listeners => listeners());
this.removers = [];
}
mouseWheelHandler = e => {
e.preventDefault();
const event = normalizeWheel(e);
if (this.state.zoomMatrix.type === 'width') {
// full width, scroll vertical
this.container.scrollTop = Math.max(this.container.scrollTop + event.pixelY, this.state.lockScroll.y);
} else {
// full height, scroll horizontal
this.container.scrollLeft = Math.max(this.container.scrollLeft + event.pixelY, this.state.lockScroll.x);
}
// lock horizontal scroll
this.container.scrollLeft = Math.max(this.container.scrollLeft + event.pixelX, this.state.lockScroll.x);
};
mouseDownHandler = e => {
this.setState({ dragPosition: {
left: this.container.scrollLeft,
top: this.container.scrollTop,
// Get the current mouse position
x: e.clientX,
y: e.clientY,
} });
this.image.addEventListener('mousemove', this.mouseMoveHandler);
this.image.addEventListener('mouseup', this.mouseUpHandler);
};
mouseMoveHandler = e => {
const dx = e.clientX - this.state.dragPosition.x;
const dy = e.clientY - this.state.dragPosition.y;
this.container.scrollLeft = Math.max(this.state.dragPosition.left - dx, this.state.lockScroll.x);
this.container.scrollTop = Math.max(this.state.dragPosition.top - dy, this.state.lockScroll.y);
this.setState({ dragged: true });
};
mouseUpHandler = () => {
this.image.removeEventListener('mousemove', this.mouseMoveHandler);
this.image.removeEventListener('mouseup', this.mouseUpHandler);
};
handleTouchStart = e => {
if (e.touches.length !== 2) return;
this.lastDistance = getDistance(...e.touches);
};
handleTouchMove = e => {
const { scrollTop, scrollHeight, clientHeight } = this.container;
if (e.touches.length === 1 && scrollTop !== scrollHeight - clientHeight) {
// prevent propagating event to MediaModal
e.stopPropagation();
return;
}
if (e.touches.length !== 2) return;
e.preventDefault();
e.stopPropagation();
const distance = getDistance(...e.touches);
const midpoint = getMidpoint(...e.touches);
const _MAX_SCALE = Math.max(MAX_SCALE, this.state.zoomMatrix.rate);
const scale = clamp(MIN_SCALE, _MAX_SCALE, this.state.scale * distance / this.lastDistance);
this._zoom(scale, midpoint);
this.lastMidpoint = midpoint;
this.lastDistance = distance;
};
_zoom(nextScale, midpoint) {
const { scale, zoomMatrix } = this.state;
const { scrollLeft, scrollTop } = this.container;
// math memo:
// x = (scrollLeft + midpoint.x) / scrollWidth
// x' = (nextScrollLeft + midpoint.x) / nextScrollWidth
// scrollWidth = clientWidth * scale
// scrollWidth' = clientWidth * nextScale
// Solve x = x' for nextScrollLeft
const nextScrollLeft = (scrollLeft + midpoint.x) * nextScale / scale - midpoint.x;
const nextScrollTop = (scrollTop + midpoint.y) * nextScale / scale - midpoint.y;
this.setState({ scale: nextScale }, () => {
this.container.scrollLeft = nextScrollLeft;
this.container.scrollTop = nextScrollTop;
// reset the translateX/Y constantly
if (nextScale < zoomMatrix.rate) {
this.setState({
lockTranslate: {
x: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateX * ((nextScale - MIN_SCALE) / (zoomMatrix.rate - MIN_SCALE)),
y: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateY * ((nextScale - MIN_SCALE) / (zoomMatrix.rate - MIN_SCALE)),
},
});
}
});
}
handleClick = e => {
// don't propagate event to MediaModal
e.stopPropagation();
const dragged = this.state.dragged;
this.setState({ dragged: false });
if (dragged) return;
const handler = this.props.onClick;
if (handler) handler();
};
handleMouseDown = e => {
e.preventDefault();
};
_initZoomMatrix = () => {
const { width, height } = this.props;
const { clientWidth, clientHeight } = this.container;
const { offsetWidth, offsetHeight } = this.image;
const clientHeightFixed = clientHeight - NAV_BAR_HEIGHT;
const type = width / height < clientWidth / clientHeightFixed ? 'width' : 'height';
const fullScreen = type === 'width' ? width > clientWidth : height > clientHeightFixed;
const rate = type === 'width' ? Math.min(clientWidth, width) / offsetWidth : Math.min(clientHeightFixed, height) / offsetHeight;
const scrollTop = type === 'width' ? (clientHeight - offsetHeight) / 2 - NAV_BAR_HEIGHT : (clientHeightFixed - offsetHeight) / 2;
const scrollLeft = (clientWidth - offsetWidth) / 2;
const translateX = type === 'width' ? (width - offsetWidth) / (2 * rate) : 0;
const translateY = type === 'height' ? (height - offsetHeight) / (2 * rate) : 0;
this.setState({
zoomMatrix: {
type: type,
fullScreen: fullScreen,
rate: rate,
clientWidth: clientWidth,
clientHeight: clientHeight,
offsetWidth: offsetWidth,
offsetHeight: offsetHeight,
clientHeightFixed: clientHeightFixed,
scrollTop: scrollTop,
scrollLeft: scrollLeft,
translateX: translateX,
translateY: translateY,
},
});
};
_toggleZoom () {
const { scale, zoomMatrix } = this.state;
if ( scale >= zoomMatrix.rate ) {
this.setState({
scale: MIN_SCALE,
lockScroll: {
x: 0,
y: 0,
},
lockTranslate: {
x: 0,
y: 0,
},
}, () => {
this.container.scrollLeft = 0;
this.container.scrollTop = 0;
});
} else {
this.setState({
scale: zoomMatrix.rate,
lockScroll: {
x: zoomMatrix.scrollLeft,
y: zoomMatrix.scrollTop,
},
lockTranslate: {
x: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateX,
y: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateY,
},
}, () => {
this.container.scrollLeft = zoomMatrix.scrollLeft;
this.container.scrollTop = zoomMatrix.scrollTop;
});
}
}
setContainerRef = c => {
this.container = c;
};
setImageRef = c => {
this.image = c;
};
render () {
const { alt, lang, src, width, height } = this.props;
const { scale, lockTranslate, dragged } = this.state;
const overflow = scale === MIN_SCALE ? 'hidden' : 'scroll';
const cursor = scale === MIN_SCALE ? null : (dragged ? 'grabbing' : 'grab');
return (
<div
className='zoomable-image'
ref={this.setContainerRef}
style={{ overflow, cursor, userSelect: 'none' }}
>
<img
role='presentation'
ref={this.setImageRef}
alt={alt}
title={alt}
lang={lang}
src={src}
width={width}
height={height}
style={{
transform: `scale(${scale}) translate(-${lockTranslate.x}px, -${lockTranslate.y}px)`,
transformOrigin: '0 0',
}}
draggable={false}
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
/>
</div>
);
}
}
export default ZoomableImage;
@@ -0,0 +1,319 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import classNames from 'classnames';
import { useSpring, animated, config } from '@react-spring/web';
import { createUseGesture, dragAction, pinchAction } from '@use-gesture/react';
import { Blurhash } from 'mastodon/components/blurhash';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
const MIN_SCALE = 1;
const MAX_SCALE = 4;
const DOUBLE_CLICK_THRESHOLD = 250;
interface ZoomMatrix {
containerWidth: number;
containerHeight: number;
imageWidth: number;
imageHeight: number;
initialScale: number;
}
const createZoomMatrix = (
container: HTMLElement,
image: HTMLImageElement,
fullWidth: number,
fullHeight: number,
): ZoomMatrix => {
const { clientWidth, clientHeight } = container;
const { offsetWidth, offsetHeight } = image;
const type =
fullWidth / fullHeight < clientWidth / clientHeight ? 'width' : 'height';
const initialScale =
type === 'width'
? Math.min(clientWidth, fullWidth) / offsetWidth
: Math.min(clientHeight, fullHeight) / offsetHeight;
return {
containerWidth: clientWidth,
containerHeight: clientHeight,
imageWidth: offsetWidth,
imageHeight: offsetHeight,
initialScale,
};
};
const useGesture = createUseGesture([dragAction, pinchAction]);
const getBounds = (zoomMatrix: ZoomMatrix | null, scale: number) => {
if (!zoomMatrix || scale === MIN_SCALE) {
return {
left: -Infinity,
right: Infinity,
top: -Infinity,
bottom: Infinity,
};
}
const { containerWidth, containerHeight, imageWidth, imageHeight } =
zoomMatrix;
const bounds = {
left: -Math.max(imageWidth * scale - containerWidth, 0) / 2,
right: Math.max(imageWidth * scale - containerWidth, 0) / 2,
top: -Math.max(imageHeight * scale - containerHeight, 0) / 2,
bottom: Math.max(imageHeight * scale - containerHeight, 0) / 2,
};
return bounds;
};
interface ZoomableImageProps {
alt?: string;
lang?: string;
src: string;
width: number;
height: number;
onClick?: () => void;
onDoubleClick?: () => void;
onClose?: () => void;
onZoomChange?: (zoomedIn: boolean) => void;
zoomedIn?: boolean;
blurhash?: string;
}
export const ZoomableImage: React.FC<ZoomableImageProps> = ({
alt = '',
lang = '',
src,
width,
height,
onClick,
onDoubleClick,
onClose,
onZoomChange,
zoomedIn,
blurhash,
}) => {
useEffect(() => {
const handler = (e: Event) => {
e.preventDefault();
};
document.addEventListener('gesturestart', handler);
document.addEventListener('gesturechange', handler);
document.addEventListener('gestureend', handler);
return () => {
document.removeEventListener('gesturestart', handler);
document.removeEventListener('gesturechange', handler);
document.removeEventListener('gestureend', handler);
};
}, []);
const [dragging, setDragging] = useState(false);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const doubleClickTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>();
const zoomMatrixRef = useRef<ZoomMatrix | null>(null);
const [style, api] = useSpring(() => ({
x: 0,
y: 0,
scale: 1,
onRest: {
scale({ value }) {
if (!onZoomChange) {
return;
}
if (value === MIN_SCALE) {
onZoomChange(false);
} else {
onZoomChange(true);
}
},
},
}));
useGesture(
{
onDrag({
pinching,
cancel,
active,
last,
offset: [x, y],
velocity: [, vy],
direction: [, dy],
tap,
}) {
if (tap) {
if (!doubleClickTimeoutRef.current) {
doubleClickTimeoutRef.current = setTimeout(() => {
onClick?.();
doubleClickTimeoutRef.current = null;
}, DOUBLE_CLICK_THRESHOLD);
} else {
clearTimeout(doubleClickTimeoutRef.current);
doubleClickTimeoutRef.current = null;
onDoubleClick?.();
}
return;
}
if (!zoomedIn) {
// Swipe up/down to dismiss parent
if (last) {
if ((vy > 0.5 && dy !== 0) || Math.abs(y) > 150) {
onClose?.();
}
void api.start({ y: 0, config: config.wobbly });
return;
} else if (dy !== 0) {
void api.start({ y, immediate: true });
return;
}
cancel();
return;
}
if (pinching) {
cancel();
return;
}
if (active) {
setDragging(true);
} else {
setDragging(false);
}
void api.start({ x, y });
},
onPinch({ origin: [ox, oy], first, movement: [ms], offset: [s], memo }) {
if (!imageRef.current) {
return;
}
if (first) {
const { width, height, x, y } =
imageRef.current.getBoundingClientRect();
const tx = ox - (x + width / 2);
const ty = oy - (y + height / 2);
memo = [style.x.get(), style.y.get(), tx, ty];
}
const x = memo[0] - (ms - 1) * memo[2]; // eslint-disable-line @typescript-eslint/no-unsafe-member-access
const y = memo[1] - (ms - 1) * memo[3]; // eslint-disable-line @typescript-eslint/no-unsafe-member-access
void api.start({ scale: s, x, y });
return memo as [number, number, number, number];
},
},
{
target: imageRef,
drag: {
from: () => [style.x.get(), style.y.get()],
filterTaps: true,
bounds: () => getBounds(zoomMatrixRef.current, style.scale.get()),
rubberband: true,
},
pinch: {
scaleBounds: {
min: MIN_SCALE,
max: MAX_SCALE,
},
rubberband: true,
},
},
);
useEffect(() => {
if (!loaded || !containerRef.current || !imageRef.current) {
return;
}
zoomMatrixRef.current = createZoomMatrix(
containerRef.current,
imageRef.current,
width,
height,
);
if (!zoomedIn) {
void api.start({ scale: MIN_SCALE, x: 0, y: 0 });
} else if (style.scale.get() === MIN_SCALE) {
void api.start({ scale: zoomMatrixRef.current.initialScale, x: 0, y: 0 });
}
}, [api, style.scale, zoomedIn, width, height, loaded]);
const handleClick = useCallback((e: React.MouseEvent) => {
// This handler exists to cancel the onClick handler on the media modal which would
// otherwise close the modal. It cannot be used for actual click handling because
// we don't know if the user is about to pan the image or not.
e.preventDefault();
e.stopPropagation();
}, []);
const handleLoad = useCallback(() => {
setLoaded(true);
}, [setLoaded]);
const handleError = useCallback(() => {
setError(true);
}, [setError]);
return (
<div
className={classNames('zoomable-image', {
'zoomable-image--zoomed-in': zoomedIn,
'zoomable-image--error': error,
'zoomable-image--dragging': dragging,
})}
ref={containerRef}
>
{!loaded && blurhash && (
<div
className='zoomable-image__preview'
style={{
aspectRatio: `${width}/${height}`,
height: `min(${height}px, 100%)`,
}}
>
<Blurhash hash={blurhash} />
</div>
)}
<animated.img
style={style}
role='presentation'
ref={imageRef}
alt={alt}
title={alt}
lang={lang}
src={src}
width={width}
height={height}
draggable={false}
onLoad={handleLoad}
onError={handleError}
onClickCapture={handleClick}
/>
{!loaded && !error && <LoadingIndicator />}
</div>
);
};
@@ -1,7 +1,7 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';
import classNames from 'classnames';
@@ -19,6 +19,7 @@ import VolumeOffIcon from '@/material-icons/400-24px/volume_off-fill.svg?react';
import VolumeUpIcon from '@/material-icons/400-24px/volume_up-fill.svg?react';
import { Blurhash } from 'mastodon/components/blurhash';
import { Icon } from 'mastodon/components/icon';
import { SpoilerButton } from 'mastodon/components/spoiler_button';
import { playerSettings } from 'mastodon/settings';
import { displayMedia, useBlurhash } from '../../initial_state';
@@ -549,14 +550,6 @@ class Video extends PureComponent {
preload = 'none';
}
let warning;
if (sensitive) {
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
} else {
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
}
// The outer wrapper is necessary to avoid reflowing the layout when going into full screen
return (
<div style={{ aspectRatio }}>
@@ -599,14 +592,7 @@ class Video extends PureComponent {
style={{ width: '100%' }}
/>}
<div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed || editable })}>
<button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
<span className='spoiler-button__overlay__label'>
{warning}
<span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.show' defaultMessage='Click to show' /></span>
</span>
</button>
</div>
<SpoilerButton hidden={revealed || editable} sensitive={sensitive} onClick={this.toggleReveal} />
<div className={classNames('video-player__controls', { active: paused || hovered })}>
<div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
+50
View File
@@ -358,6 +358,7 @@
"follow_suggestions.hints.friends_of_friends": "Tsit ê個人資料tī lí跟tuè ê lâng之間真流行。",
"follow_suggestions.hints.most_followed": "Tsit ê個人資料是 {domain} 內,有足tsē跟tuè者ê其中tsit ê。",
"follow_suggestions.hints.most_interactions": "Tsit ê個人資料tsi̍t-tsām-á佇 {domain} 有得著真tsē關注。",
"follow_suggestions.hints.similar_to_recently_followed": "Tsit ê個人資料kap lí最近跟tuè ê口座相siâng。",
"follow_suggestions.personalized_suggestion": "個人化ê推薦",
"follow_suggestions.popular_suggestion": "流行ê推薦",
"follow_suggestions.popular_suggestion_longer": "佇{domain} 足有lâng緣",
@@ -391,6 +392,9 @@
"hashtag.follow": "跟tuè hashtag",
"hashtag.unfollow": "取消跟tuè hashtag",
"hashtags.and_other": "……kap 其他 {count, plural, other {# ê}}",
"hints.profiles.followers_may_be_missing": "Tsit ê個人資料ê跟tuè者資訊可能有落勾ê。",
"hints.profiles.follows_may_be_missing": "Tsit ê口座所跟tuè ê ê資訊可能有落勾ê。",
"hints.profiles.posts_may_be_missing": "Tsit ê口座ê tsi̍t kuá PO文可能有落勾ê。",
"hints.profiles.see_more_followers": "佇 {domain} 看koh khah tsē跟tuè lí ê",
"hints.profiles.see_more_follows": "佇 {domain} 看koh khah tsē lí跟tuè ê",
"hints.profiles.see_more_posts": "佇 {domain} 看koh khah tsē ê PO文",
@@ -403,6 +407,24 @@
"home.pending_critical_update.link": "看更新內容",
"home.pending_critical_update.title": "有重要ê安全更新!",
"home.show_announcements": "顯示公告",
"ignore_notifications_modal.disclaimer": "Lí所忽略in ê通知ê用者,Mastodonbē當kā lí通知。忽略通知bē當阻擋訊息ê寄送。",
"ignore_notifications_modal.filter_instead": "改做過濾",
"ignore_notifications_modal.filter_to_act_users": "Lí猶原ē當接受、拒絕猶是檢舉用者",
"ignore_notifications_modal.filter_to_avoid_confusion": "過濾ē當避免可能ê bē分明。",
"ignore_notifications_modal.filter_to_review_separately": "Lí ē當個別檢視所過濾ê通知",
"ignore_notifications_modal.ignore": "Kā通知忽略",
"ignore_notifications_modal.limited_accounts_title": "Kám beh忽略受限制ê口座送來ê通知?",
"ignore_notifications_modal.new_accounts_title": "Kám beh忽略新口座送來ê通知?",
"ignore_notifications_modal.not_followers_title": "Kám beh忽略無跟tuè lí ê口座送來ê通知?",
"ignore_notifications_modal.not_following_title": "Kám beh忽略lí 無跟tuè ê口座送來ê通知?",
"ignore_notifications_modal.private_mentions_title": "忽略ka-kī主動送ê私人提起ê通知?",
"info_button.label": "幫tsān",
"info_button.what_is_alt_text": "<h1>Siánn物是替代文字?</h1> <p>替代文字kā視覺有障礙、網路速度khah慢,á是beh tshuē頂下文ê lâng,提供圖ê敘述。</p> <p>Lí ē當通過寫明白、簡單kap客觀ê替代文字,替逐家改善容易使用性kap幫tsān理解。</p> <ul> <li>掌握重要ê因素</li> <li>替圖寫摘要ê文字</li> <li>用規則ê語句結構</li> <li>避免重複ê資訊</li> <li>專注佇趨勢kap佇複雜視覺(比如圖表á是地圖)內底tshuē關鍵</li> </ul>",
"interaction_modal.action.favourite": "Nā beh繼續,lí tio̍h用你ê口座收藏。",
"interaction_modal.action.follow": "Nā beh繼續,lí tio̍h用你ê口座跟tuè。",
"interaction_modal.action.reblog": "Nā beh繼續,lí tio̍h用你ê口座轉送。",
"interaction_modal.action.reply": "Nā beh繼續,lí tio̍h用你ê口座回應。",
"interaction_modal.action.vote": "Nā beh繼續,lí tio̍h用你ê口座投票。",
"interaction_modal.go": "行",
"interaction_modal.no_account_yet": "Tsit-má iáu bô口座?",
"interaction_modal.on_another_server": "佇無kâng ê服侍器",
@@ -418,6 +440,34 @@
"intervals.full.minutes": "{number, plural, other {# 分鐘}}",
"keyboard_shortcuts.back": "Tńg去",
"keyboard_shortcuts.blocked": "開封鎖ê用者ê列單",
"keyboard_shortcuts.boost": "轉送PO文",
"keyboard_shortcuts.column": "揀tsit ê欄",
"keyboard_shortcuts.compose": "揀寫文字ê框仔",
"keyboard_shortcuts.description": "說明",
"keyboard_shortcuts.direct": "phah開私人提起ê欄",
"keyboard_shortcuts.down": "佇列單內kā suá khah 下kha",
"keyboard_shortcuts.enter": "Phah開PO文",
"keyboard_shortcuts.favourite": "收藏PO文",
"keyboard_shortcuts.favourites": "Phah開收藏ê列單",
"keyboard_shortcuts.federated": "Phah開聯邦ê時間線",
"keyboard_shortcuts.heading": "鍵盤ê快速key",
"keyboard_shortcuts.home": "Phah開tshù ê時間線",
"keyboard_shortcuts.hotkey": "快速key",
"keyboard_shortcuts.legend": "顯示tsit篇說明",
"keyboard_shortcuts.local": "Phah開本站ê時間線",
"keyboard_shortcuts.mention": "提起作者",
"keyboard_shortcuts.muted": "Phah開消音ê用者列單",
"keyboard_shortcuts.my_profile": "Phah開lí ê個人資料",
"keyboard_shortcuts.notifications": "Phah開通知欄",
"keyboard_shortcuts.open_media": "Phah開媒體",
"keyboard_shortcuts.pinned": "Phah開釘起來ê PO文列單",
"keyboard_shortcuts.profile": "Phah開作者ê個人資料",
"keyboard_shortcuts.reply": "回應PO文",
"keyboard_shortcuts.requests": "Phah開跟tuè請求ê列單",
"keyboard_shortcuts.search": "揀tshiau-tshuē條á",
"keyboard_shortcuts.spoilers": "顯示/隱藏內容警告",
"keyboard_shortcuts.start": "Phah開「開始用」欄",
"keyboard_shortcuts.toggle_hidden": "顯示/隱藏內容警告後壁ê PO文",
"notification.favourite_pm": "{name} kah意lí ê私人提起",
"notification.favourite_pm.name_and_others_with_link": "{name} kap<a>{count, plural, other {另外 # ê lâng}}</a>kah意lí ê私人提起",
"search_popout.language_code": "ISO語言代碼",
@@ -872,7 +872,9 @@
"subscribed_languages.target": "更改 {target} 的订阅语言",
"tabs_bar.home": "主页",
"tabs_bar.notifications": "通知",
"terms_of_service.effective_as_of": "自 {date} 起生效",
"terms_of_service.title": "服务条款",
"terms_of_service.upcoming_changes_on": "将于 {date} 进行变更",
"time_remaining.days": "剩余 {number, plural, other {# 天}}",
"time_remaining.hours": "剩余 {number, plural, other {# 小时}}",
"time_remaining.minutes": "剩余 {number, plural, other {# 分钟}}",
@@ -63,6 +63,7 @@ body {
&.with-modals--active {
overflow-y: hidden;
overscroll-behavior: none;
}
}
+51 -45
View File
@@ -2448,49 +2448,6 @@ a.account__display-name {
}
}
.image-loader {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE 10+ */
* {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE 10+ */
}
&::-webkit-scrollbar,
*::-webkit-scrollbar {
width: 0;
height: 0;
background: transparent; /* Chrome/Safari/Webkit */
}
.image-loader__preview-canvas {
max-width: $media-modal-media-max-width;
max-height: $media-modal-media-max-height;
background: url('~images/void.png') repeat;
object-fit: contain;
}
.loading-bar__container {
position: relative;
}
.loading-bar {
position: absolute;
}
&.image-loader--amorphous .image-loader__preview-canvas {
display: none;
}
}
.zoomable-image {
position: relative;
width: 100%;
@@ -2498,13 +2455,61 @@ a.account__display-name {
display: flex;
align-items: center;
justify-content: center;
scrollbar-width: none;
overflow: hidden;
user-select: none;
img {
max-width: $media-modal-media-max-width;
max-height: $media-modal-media-max-height;
width: auto;
height: auto;
object-fit: contain;
outline: 1px solid var(--media-outline-color);
outline-offset: -1px;
border-radius: 8px;
touch-action: none;
}
&--zoomed-in {
z-index: 9999;
cursor: grab;
img {
outline: none;
border-radius: 0;
}
}
&--dragging {
cursor: grabbing;
}
&--error img {
visibility: hidden;
}
&__preview {
max-width: $media-modal-media-max-width;
max-height: $media-modal-media-max-height;
position: absolute;
z-index: 1;
outline: 1px solid var(--media-outline-color);
outline-offset: -1px;
border-radius: 8px;
overflow: hidden;
canvas {
position: absolute;
width: 100%;
height: 100%;
object-fit: cover;
z-index: -1;
}
}
.loading-indicator {
z-index: 2;
mix-blend-mode: luminosity;
}
}
@@ -5576,6 +5581,7 @@ a.status-card {
z-index: 9999;
pointer-events: none;
user-select: none;
overscroll-behavior: none;
}
.modal-root__modal {
@@ -5709,7 +5715,7 @@ a.status-card {
.picture-in-picture__footer {
border-radius: 0;
background: transparent;
padding: 20px 0;
padding: 16px;
.icon-button {
color: $white;