This commit is contained in:
Vitaly Barakov 2026-05-23 03:03:48 +02:00 committed by GitHub
commit 76340d8195
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 521 additions and 31 deletions

View file

@ -132,12 +132,15 @@ exports[`components/FilePreviewModal should match snapshot, loaded 1`] = `
</div>
</div>
<div
class="file-preview-modal__content"
class="file-preview-modal__content file-preview-modal__content-with-image-controls"
>
<div>
Image Preview
</div>
</div>
<div>
Image Controls Bar
</div>
</div>
</div>
</div>
@ -161,12 +164,15 @@ exports[`components/FilePreviewModal should match snapshot, loaded and showing f
</div>
</div>
<div
class="file-preview-modal__content"
class="file-preview-modal__content file-preview-modal__content-with-image-controls"
>
<div>
Image Preview
</div>
</div>
<div>
Image Controls Bar
</div>
</div>
</div>
</div>
@ -277,12 +283,15 @@ exports[`components/FilePreviewModal should match snapshot, loaded with footer 1
</div>
</div>
<div
class="file-preview-modal__content"
class="file-preview-modal__content file-preview-modal__content-with-image-controls"
>
<div>
Image Preview
</div>
</div>
<div>
Image Controls Bar
</div>
</div>
</div>
</div>
@ -306,12 +315,15 @@ exports[`components/FilePreviewModal should match snapshot, loaded with image 1`
</div>
</div>
<div
class="file-preview-modal__content"
class="file-preview-modal__content file-preview-modal__content-with-image-controls"
>
<div>
Image Preview
</div>
</div>
<div>
Image Controls Bar
</div>
</div>
</div>
</div>

View file

@ -2,24 +2,26 @@
exports[`components/view_image/ImagePreview should match snapshot, with preview 1`] = `
<div>
<a
<div
class="image_preview"
href="#"
draggable="false"
>
<img
alt="preview url image"
class="image_preview__image"
data-testid="imagePreview"
draggable="false"
loading="lazy"
src="/api/v4/files/file_id_1/preview"
/>
</a>
</div>
</div>
`;
exports[`components/view_image/ImagePreview should match snapshot, with preview, cannot download 1`] = `
<div>
<img
draggable="false"
src="/api/v4/files/file_id_1/preview"
/>
</div>
@ -27,24 +29,26 @@ exports[`components/view_image/ImagePreview should match snapshot, with preview,
exports[`components/view_image/ImagePreview should match snapshot, without preview 1`] = `
<div>
<a
<div
class="image_preview"
href="#"
draggable="false"
>
<img
alt="preview url image"
class="image_preview__image"
data-testid="imagePreview"
draggable="false"
loading="lazy"
src="/api/v4/files/file_id?download=1"
/>
</a>
</div>
</div>
`;
exports[`components/view_image/ImagePreview should match snapshot, without preview, cannot download 1`] = `
<div>
<img
draggable="false"
src="/api/v4/files/file_id?download=1"
/>
</div>

View file

@ -32,6 +32,20 @@
}
}
&__content-with-image-controls {
height: calc(100vh - 112px);
overflow: hidden;
cursor: grab;
@media screen and (max-width: 768px) {
height: calc(100% - 148px);
}
}
&__content-dragging {
cursor: grabbing;
}
&__content-scrollable {
padding: 0;
}
@ -62,6 +76,11 @@
}
}
&__image-controls-bar {
position: relative;
z-index: 1;
}
.modal.fade & {
transform: translate(0, 0);
}

View file

@ -39,6 +39,7 @@ jest.mock('components/file_preview_modal/file_preview_modal_header/file_preview_
<div>{'File Preview Modal Header'}</div>
));
jest.mock('components/file_preview_modal/image_preview', () => () => <div>{'Image Preview'}</div>);
jest.mock('components/file_preview_modal/image_controls_bar', () => () => <div>{'Image Controls Bar'}</div>);
jest.mock('components/file_preview_modal/popover_bar', () => () => <div>{'Popover Bar'}</div>);
describe('components/FilePreviewModal', () => {
@ -146,9 +147,9 @@ describe('components/FilePreviewModal', () => {
expect(container).toMatchSnapshot();
});
test('should go to next or previous upon key press of right or left, respectively', () => {
test('should go to next or previous upon key press of right or left, respectively for non-image preview', () => {
const fileInfos = [
TestHelper.getFileInfoMock({id: 'file_id_1', extension: 'gif'}),
TestHelper.getFileInfoMock({id: 'file_id_1', extension: 'txt'}),
TestHelper.getFileInfoMock({id: 'file_id_2', extension: 'wma'}),
TestHelper.getFileInfoMock({id: 'file_id_3', extension: 'mp4'}),
];

View file

@ -27,6 +27,7 @@ import type {FilePreviewComponent} from 'types/store/plugins';
import FilePreviewModalFooter from './file_preview_modal_footer/file_preview_modal_footer';
import FilePreviewModalHeader from './file_preview_modal_header/file_preview_modal_header';
import ImagePreview from './image_preview';
import ImageControlsBar from './image_controls_bar';
import PopoverBar from './popover_bar';
import {isFileInfo, isLinkInfo} from './types';
import type {LinkInfo} from './types';
@ -72,6 +73,12 @@ type State = {
showCloseBtn: boolean;
showZoomControls: boolean;
scale: Record<number, number>;
rotation: Record<number, number>;
flipHorizontal: Record<number, boolean>;
flipVertical: Record<number, boolean>;
translateX: Record<number, number>;
translateY: Record<number, number>;
isDraggingImage: boolean;
content: string;
}
@ -82,6 +89,11 @@ export default class FilePreviewModal extends React.PureComponent<Props, State>
pluginFilePreviewComponents: [],
};
private dragStartX = 0;
private dragStartY = 0;
private dragBaseX = 0;
private dragBaseY = 0;
constructor(props: Props) {
super(props);
@ -95,6 +107,12 @@ export default class FilePreviewModal extends React.PureComponent<Props, State>
showCloseBtn: false,
showZoomControls: false,
scale: Utils.fillRecord(ZoomSettings.DEFAULT_SCALE, this.props.fileInfos.length),
rotation: Utils.fillRecord(0, this.props.fileInfos.length),
flipHorizontal: Utils.fillRecord(false, this.props.fileInfos.length),
flipVertical: Utils.fillRecord(false, this.props.fileInfos.length),
translateX: Utils.fillRecord(0, this.props.fileInfos.length),
translateY: Utils.fillRecord(0, this.props.fileInfos.length),
isDraggingImage: false,
content: '',
};
}
@ -115,7 +133,11 @@ export default class FilePreviewModal extends React.PureComponent<Props, State>
this.showImage(id);
};
handleKeyPress = (e: KeyboardEvent) => {
handleKeyUp = (e: KeyboardEvent) => {
if (this.isCurrentImagePreview()) {
return;
}
if (Keyboard.isKeyPressed(e, KeyCodes.RIGHT)) {
this.handleNext();
} else if (Keyboard.isKeyPressed(e, KeyCodes.LEFT)) {
@ -123,14 +145,26 @@ export default class FilePreviewModal extends React.PureComponent<Props, State>
}
};
handleKeyDown = (e: KeyboardEvent) => {
this.handleImagePanByKeyboard(e);
};
handleKeyPress = (e: KeyboardEvent) => {
this.handleKeyUp(e);
};
componentDidMount() {
document.addEventListener('keyup', this.handleKeyPress);
document.addEventListener('keyup', this.handleKeyUp);
document.addEventListener('keydown', this.handleKeyDown);
this.showImage(this.props.startIndex);
}
componentWillUnmount() {
document.removeEventListener('keyup', this.handleKeyPress);
document.removeEventListener('keyup', this.handleKeyUp);
document.removeEventListener('keydown', this.handleKeyDown);
window.removeEventListener('mousemove', this.handleImageMouseMove);
window.removeEventListener('mouseup', this.handleImageMouseUp);
}
static getDerivedStateFromProps(props: Props, state: State) {
@ -143,6 +177,13 @@ export default class FilePreviewModal extends React.PureComponent<Props, State>
if (props.fileInfos.length !== state.prevFileInfosCount) {
updatedState.loaded = Utils.fillRecord(false, props.fileInfos.length);
updatedState.progress = Utils.fillRecord(0, props.fileInfos.length);
updatedState.scale = Utils.fillRecord(ZoomSettings.DEFAULT_SCALE, props.fileInfos.length);
updatedState.rotation = Utils.fillRecord(0, props.fileInfos.length);
updatedState.flipHorizontal = Utils.fillRecord(false, props.fileInfos.length);
updatedState.flipVertical = Utils.fillRecord(false, props.fileInfos.length);
updatedState.translateX = Utils.fillRecord(0, props.fileInfos.length);
updatedState.translateY = Utils.fillRecord(0, props.fileInfos.length);
updatedState.isDraggingImage = false;
updatedState.prevFileInfosCount = props.fileInfos.length;
}
return Object.keys(updatedState).length ? updatedState : null;
@ -260,13 +301,13 @@ export default class FilePreviewModal extends React.PureComponent<Props, State>
};
handleZoomIn = () => {
let newScale = this.state.scale[this.state.imageIndex];
let newScale = this.state.scale[this.state.imageIndex] ?? ZoomSettings.DEFAULT_SCALE;
newScale = Math.min(newScale + ZoomSettings.SCALE_DELTA, ZoomSettings.MAX_SCALE);
this.setScale(this.state.imageIndex, newScale);
};
handleZoomOut = () => {
let newScale = this.state.scale[this.state.imageIndex];
let newScale = this.state.scale[this.state.imageIndex] ?? ZoomSettings.DEFAULT_SCALE;
newScale = Math.max(newScale - ZoomSettings.SCALE_DELTA, ZoomSettings.MIN_SCALE);
this.setScale(this.state.imageIndex, newScale);
};
@ -275,6 +316,162 @@ export default class FilePreviewModal extends React.PureComponent<Props, State>
this.setScale(this.state.imageIndex, ZoomSettings.DEFAULT_SCALE);
};
handleImageWheel = (e: React.WheelEvent) => {
e.preventDefault();
if (e.deltaY < 0) {
this.handleZoomIn();
} else if (e.deltaY > 0) {
this.handleZoomOut();
}
};
private setRotation = (index: number, rotation: number) => {
this.setState((prevState) => {
return {
rotation: {
...prevState.rotation,
[index]: rotation,
},
};
});
};
handleRotateClockwise = () => {
const currentRotation = this.state.rotation[this.state.imageIndex] ?? 0;
this.setRotation(this.state.imageIndex, (currentRotation + 90) % 360);
};
handleRotateCounterClockwise = () => {
const currentRotation = this.state.rotation[this.state.imageIndex] ?? 0;
this.setRotation(this.state.imageIndex, (currentRotation + 270) % 360);
};
private toggleFlipHorizontal = () => {
this.setState((prevState) => {
const index = prevState.imageIndex;
return {
flipHorizontal: {
...prevState.flipHorizontal,
[index]: !prevState.flipHorizontal[index],
},
};
});
};
private toggleFlipVertical = () => {
this.setState((prevState) => {
const index = prevState.imageIndex;
return {
flipVertical: {
...prevState.flipVertical,
[index]: !prevState.flipVertical[index],
},
};
});
};
private getImageTransform = (index: number): string => {
const zoom = this.state.scale[index] ?? ZoomSettings.DEFAULT_SCALE;
const rotation = this.state.rotation[index] ?? 0;
const flipX = this.state.flipHorizontal[index] ? -1 : 1;
const flipY = this.state.flipVertical[index] ? -1 : 1;
const translateX = this.state.translateX[index] ?? 0;
const translateY = this.state.translateY[index] ?? 0;
return `translate(${translateX}px, ${translateY}px) rotate(${rotation}deg) scale(${zoom * flipX}, ${zoom * flipY})`;
};
private setTranslate = (index: number, x: number, y: number) => {
this.setState((prevState) => {
return {
translateX: {
...prevState.translateX,
[index]: x,
},
translateY: {
...prevState.translateY,
[index]: y,
},
};
});
};
private isCurrentImagePreview = () => {
const fileInfo = this.props.fileInfos[this.state.imageIndex];
const fileType = this.getFileTypeFromFileInfo(fileInfo);
return this.state.loaded[this.state.imageIndex] && (fileType === FileTypes.IMAGE || fileType === FileTypes.SVG);
};
private handleImagePanByKeyboard = (e: KeyboardEvent): boolean => {
if (!this.isCurrentImagePreview()) {
return false;
}
const step = e.shiftKey ? 48 : 24;
const currentX = this.state.translateX[this.state.imageIndex] ?? 0;
const currentY = this.state.translateY[this.state.imageIndex] ?? 0;
if (Keyboard.isKeyPressed(e, KeyCodes.UP)) {
e.preventDefault();
this.setTranslate(this.state.imageIndex, currentX, currentY - step);
return true;
}
if (Keyboard.isKeyPressed(e, KeyCodes.DOWN)) {
e.preventDefault();
this.setTranslate(this.state.imageIndex, currentX, currentY + step);
return true;
}
if (Keyboard.isKeyPressed(e, KeyCodes.LEFT)) {
e.preventDefault();
this.setTranslate(this.state.imageIndex, currentX - step, currentY);
return true;
}
if (Keyboard.isKeyPressed(e, KeyCodes.RIGHT)) {
e.preventDefault();
this.setTranslate(this.state.imageIndex, currentX + step, currentY);
return true;
}
return false;
};
private handleImageMouseDown = (e: React.MouseEvent) => {
if (e.button !== 0 || !this.isCurrentImagePreview()) {
return;
}
e.preventDefault();
this.dragStartX = e.clientX;
this.dragStartY = e.clientY;
this.dragBaseX = this.state.translateX[this.state.imageIndex] ?? 0;
this.dragBaseY = this.state.translateY[this.state.imageIndex] ?? 0;
this.setState({isDraggingImage: true});
window.addEventListener('mousemove', this.handleImageMouseMove);
window.addEventListener('mouseup', this.handleImageMouseUp);
};
private handleImageMouseMove = (e: MouseEvent) => {
const deltaX = e.clientX - this.dragStartX;
const deltaY = e.clientY - this.dragStartY;
this.setTranslate(this.state.imageIndex, this.dragBaseX + deltaX, this.dragBaseY + deltaY);
};
private handleImageMouseUp = () => {
this.setState({isDraggingImage: false});
window.removeEventListener('mousemove', this.handleImageMouseMove);
window.removeEventListener('mouseup', this.handleImageMouseUp);
};
private handlePreventNativeDrag = (e: React.DragEvent) => {
e.preventDefault();
};
handleModalClose = () => {
this.setState({show: false});
};
@ -323,6 +520,7 @@ export default class FilePreviewModal extends React.PureComponent<Props, State>
let content;
let zoomBar;
let imageControlsBar;
if (isFileInfo(fileInfo) && fileInfo.archived) {
content = (
@ -339,6 +537,22 @@ export default class FilePreviewModal extends React.PureComponent<Props, State>
<ImagePreview
fileInfo={fileInfo as FileInfo}
canDownloadFiles={this.props.canDownloadFiles}
transform={this.getImageTransform(this.state.imageIndex)}
/>
);
const currentScale = this.state.scale[this.state.imageIndex] ?? ZoomSettings.DEFAULT_SCALE;
imageControlsBar = (
<ImageControlsBar
canZoomIn={currentScale < ZoomSettings.MAX_SCALE}
canZoomOut={currentScale > ZoomSettings.MIN_SCALE}
isFlipHorizontal={Boolean(this.state.flipHorizontal[this.state.imageIndex])}
isFlipVertical={Boolean(this.state.flipVertical[this.state.imageIndex])}
handleZoomIn={this.handleZoomIn}
handleZoomOut={this.handleZoomOut}
handleRotateClockwise={this.handleRotateClockwise}
handleRotateCounterClockwise={this.handleRotateCounterClockwise}
handleFlipHorizontal={this.toggleFlipHorizontal}
handleFlipVertical={this.toggleFlipVertical}
/>
);
} else if (fileType === FileTypes.VIDEO || fileType === FileTypes.AUDIO) {
@ -478,12 +692,18 @@ export default class FilePreviewModal extends React.PureComponent<Props, State>
'file-preview-modal__content',
{
'file-preview-modal__content-scrollable': (!isFileInfo(fileInfo) || !fileInfo.archived) && this.state.loaded[this.state.imageIndex] && (fileType === FileTypes.PDF),
'file-preview-modal__content-with-image-controls': Boolean(imageControlsBar),
'file-preview-modal__content-dragging': this.state.isDraggingImage,
},
)}
onClick={this.handleBgClose}
onWheel={imageControlsBar ? this.handleImageWheel : undefined}
onMouseDown={imageControlsBar ? this.handleImageMouseDown : undefined}
onDragStart={imageControlsBar ? this.handlePreventNativeDrag : undefined}
>
{content}
</div>
{imageControlsBar}
{ this.props.isMobileView &&
<FilePreviewModalFooter
post={this.props.post}

View file

@ -0,0 +1,34 @@
.file-preview-modal {
&__image-controls-bar {
.modal .modal-image & {
background-color: black;
}
}
}
.file-preview-modal-image-controls {
display: flex;
align-items: center;
justify-content: center;
.file-preview-modal-main-actions__action-item {
color: white;
&:disabled {
cursor: default;
opacity: 0.4;
}
}
&__rotate-ccw {
transform: scaleX(-1);
}
&__flip-button {
font-size: 12px;
font-weight: 600;
}
&__flip-label {
line-height: 1;
}
}

View file

@ -0,0 +1,155 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React from 'react';
import {FormattedMessage} from 'react-intl';
import WithTooltip from 'components/with_tooltip';
import './image_controls_bar.scss';
type Props = {
canZoomIn: boolean;
canZoomOut: boolean;
isFlipHorizontal: boolean;
isFlipVertical: boolean;
handleZoomIn: () => void;
handleZoomOut: () => void;
handleRotateClockwise: () => void;
handleRotateCounterClockwise: () => void;
handleFlipHorizontal: () => void;
handleFlipVertical: () => void;
}
export default function ImageControlsBar({
canZoomIn,
canZoomOut,
isFlipHorizontal,
isFlipVertical,
handleZoomIn,
handleZoomOut,
handleRotateClockwise,
handleRotateCounterClockwise,
handleFlipHorizontal,
handleFlipVertical,
}: Props) {
return (
<div className='modal-button-bar file-preview-modal__image-controls-bar'>
<div className='file-preview-modal-image-controls'>
<WithTooltip
title={
<FormattedMessage
id='view_image.zoom_out'
defaultMessage='Zoom Out'
/>
}
>
<button
type='button'
className='file-preview-modal-main-actions__action-item'
onClick={handleZoomOut}
aria-label='Zoom out'
disabled={!canZoomOut}
>
<i className='icon icon-minus'/>
</button>
</WithTooltip>
<WithTooltip
title={
<FormattedMessage
id='view_image.zoom_in'
defaultMessage='Zoom In'
/>
}
>
<button
type='button'
className='file-preview-modal-main-actions__action-item'
onClick={handleZoomIn}
aria-label='Zoom in'
disabled={!canZoomIn}
>
<i className='icon icon-plus'/>
</button>
</WithTooltip>
<WithTooltip
title={
<FormattedMessage
id='view_image.rotate_counter_clockwise'
defaultMessage='Rotate Counter Clockwise'
/>
}
>
<button
type='button'
className='file-preview-modal-main-actions__action-item'
onClick={handleRotateCounterClockwise}
aria-label='Rotate counter clockwise'
>
<i className='icon icon-refresh file-preview-modal-image-controls__rotate-ccw'/>
</button>
</WithTooltip>
<WithTooltip
title={
<FormattedMessage
id='view_image.rotate_clockwise'
defaultMessage='Rotate Clockwise'
/>
}
>
<button
type='button'
className='file-preview-modal-main-actions__action-item'
onClick={handleRotateClockwise}
aria-label='Rotate clockwise'
>
<i className='icon icon-refresh'/>
</button>
</WithTooltip>
<WithTooltip
title={
<FormattedMessage
id='view_image.flip_horizontal'
defaultMessage='Flip Horizontal'
/>
}
>
<button
type='button'
className={classNames(
'file-preview-modal-main-actions__action-item',
'file-preview-modal-image-controls__flip-button',
{'active': isFlipHorizontal},
)}
onClick={handleFlipHorizontal}
aria-label='Flip horizontal'
>
<span className='file-preview-modal-image-controls__flip-label'>{'H'}</span>
</button>
</WithTooltip>
<WithTooltip
title={
<FormattedMessage
id='view_image.flip_vertical'
defaultMessage='Flip Vertical'
/>
}
>
<button
type='button'
className={classNames(
'file-preview-modal-main-actions__action-item',
'file-preview-modal-image-controls__flip-button',
{'active': isFlipVertical},
)}
onClick={handleFlipVertical}
aria-label='Flip vertical'
>
<span className='file-preview-modal-image-controls__flip-label'>{'V'}</span>
</button>
</WithTooltip>
</div>
</div>
);
}

View file

@ -0,0 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import ImageControlsBar from './image_controls_bar';
export default ImageControlsBar;

View file

@ -1,8 +1,18 @@
.image_preview {
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
&__image {
max-height: calc(100vh - 168px);
max-width: 100%;
max-height: 100%;
border-radius: 8px;
cursor: default;
cursor: inherit;
transform-origin: center center;
transition: transform 150ms ease;
user-select: none;
@media screen and (max-width: 768px) {
border-radius: 0;

View file

@ -86,7 +86,20 @@ describe('components/view_image/ImagePreview', () => {
<ImagePreview {...props}/>,
);
expect(screen.getByRole('link')).toHaveAttribute('href', '#');
expect(screen.queryByRole('link')).not.toBeInTheDocument();
expect(screen.getByTestId('imagePreview')).toHaveAttribute('src', props.fileInfo.link);
});
test('should apply transform style', () => {
const props = {
...baseProps,
transform: 'rotate(90deg) scale(1.2, 1.2)',
};
render(
<ImagePreview {...props}/>,
);
expect(screen.getByTestId('imagePreview')).toHaveStyle({transform: props.transform});
});
});

View file

@ -15,9 +15,10 @@ import './image_preview.scss';
interface Props {
fileInfo: FileInfo;
canDownloadFiles: boolean;
transform?: string;
}
export default function ImagePreview({fileInfo, canDownloadFiles}: Props) {
export default function ImagePreview({fileInfo, canDownloadFiles, transform}: Props) {
const isExternalFile = !fileInfo.id;
let fileUrl;
@ -30,10 +31,6 @@ export default function ImagePreview({fileInfo, canDownloadFiles}: Props) {
previewUrl = fileInfo.has_preview_image ? getFilePreviewUrl(fileInfo.id) : fileUrl;
}
if (!canDownloadFiles) {
return <img src={previewUrl}/>;
}
let conditionalSVGStyleAttribute;
if (getFileType(fileInfo.extension) === FileTypes.SVG) {
conditionalSVGStyleAttribute = {
@ -42,10 +39,26 @@ export default function ImagePreview({fileInfo, canDownloadFiles}: Props) {
};
}
const imageStyle = {
...conditionalSVGStyleAttribute,
transform,
};
if (!canDownloadFiles) {
return (
<img
src={previewUrl}
style={imageStyle}
draggable={false}
/>
);
}
return (
<a
<div
className='image_preview'
href='#'
draggable={false}
onDragStart={(e) => e.preventDefault()}
>
<img
className='image_preview__image'
@ -53,8 +66,10 @@ export default function ImagePreview({fileInfo, canDownloadFiles}: Props) {
data-testid='imagePreview'
alt={'preview url image'}
src={previewUrl}
style={conditionalSVGStyleAttribute}
style={imageStyle}
draggable={false}
onDragStart={(e) => e.preventDefault()}
/>
</a>
</div>
);
}

View file

@ -1429,8 +1429,8 @@ export const CacheTypes = {
export const ZoomSettings = {
DEFAULT_SCALE: 1.75,
SCALE_DELTA: 0.25,
MIN_SCALE: 0.25,
MAX_SCALE: 3.0,
MIN_SCALE: 0.1,
MAX_SCALE: 5.0,
};
export const DataSpillagePropertyNames = {

View file

@ -931,6 +931,7 @@ export function loadImage(
request.open('GET', url, true);
request.responseType = 'arraybuffer';
request.onload = onLoad;
request.onerror = onLoad;
request.onprogress = (e) => {
if (onProgress) {
let total = 0;