diff --git a/webapp/channels/src/components/file_preview_modal/__snapshots__/file_preview_modal.test.tsx.snap b/webapp/channels/src/components/file_preview_modal/__snapshots__/file_preview_modal.test.tsx.snap
index 8127eba41ec..5cafab11d57 100644
--- a/webapp/channels/src/components/file_preview_modal/__snapshots__/file_preview_modal.test.tsx.snap
+++ b/webapp/channels/src/components/file_preview_modal/__snapshots__/file_preview_modal.test.tsx.snap
@@ -132,12 +132,15 @@ exports[`components/FilePreviewModal should match snapshot, loaded 1`] = `
+
+ Image Controls Bar
+
@@ -161,12 +164,15 @@ exports[`components/FilePreviewModal should match snapshot, loaded and showing f
+
+ Image Controls Bar
+
@@ -277,12 +283,15 @@ exports[`components/FilePreviewModal should match snapshot, loaded with footer 1
+
+ Image Controls Bar
+
@@ -306,12 +315,15 @@ exports[`components/FilePreviewModal should match snapshot, loaded with image 1`
+
+ Image Controls Bar
+
diff --git a/webapp/channels/src/components/file_preview_modal/__snapshots__/image_preview.test.tsx.snap b/webapp/channels/src/components/file_preview_modal/__snapshots__/image_preview.test.tsx.snap
index 22ba8ec936d..1a493ff1de9 100644
--- a/webapp/channels/src/components/file_preview_modal/__snapshots__/image_preview.test.tsx.snap
+++ b/webapp/channels/src/components/file_preview_modal/__snapshots__/image_preview.test.tsx.snap
@@ -2,24 +2,26 @@
exports[`components/view_image/ImagePreview should match snapshot, with preview 1`] = `
-
-
+
`;
exports[`components/view_image/ImagePreview should match snapshot, with preview, cannot download 1`] = `
@@ -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`] = `
-
-
+
`;
exports[`components/view_image/ImagePreview should match snapshot, without preview, cannot download 1`] = `
diff --git a/webapp/channels/src/components/file_preview_modal/file_preview_modal.scss b/webapp/channels/src/components/file_preview_modal/file_preview_modal.scss
index b9e215aad7f..d4cd633efbe 100644
--- a/webapp/channels/src/components/file_preview_modal/file_preview_modal.scss
+++ b/webapp/channels/src/components/file_preview_modal/file_preview_modal.scss
@@ -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);
}
diff --git a/webapp/channels/src/components/file_preview_modal/file_preview_modal.test.tsx b/webapp/channels/src/components/file_preview_modal/file_preview_modal.test.tsx
index 785a019dbc1..8e324bafdbb 100644
--- a/webapp/channels/src/components/file_preview_modal/file_preview_modal.test.tsx
+++ b/webapp/channels/src/components/file_preview_modal/file_preview_modal.test.tsx
@@ -39,6 +39,7 @@ jest.mock('components/file_preview_modal/file_preview_modal_header/file_preview_
{'File Preview Modal Header'}
));
jest.mock('components/file_preview_modal/image_preview', () => () => {'Image Preview'}
);
+jest.mock('components/file_preview_modal/image_controls_bar', () => () => {'Image Controls Bar'}
);
jest.mock('components/file_preview_modal/popover_bar', () => () => {'Popover Bar'}
);
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'}),
];
diff --git a/webapp/channels/src/components/file_preview_modal/file_preview_modal.tsx b/webapp/channels/src/components/file_preview_modal/file_preview_modal.tsx
index 6dbfe274e7c..255a25f874f 100644
--- a/webapp/channels/src/components/file_preview_modal/file_preview_modal.tsx
+++ b/webapp/channels/src/components/file_preview_modal/file_preview_modal.tsx
@@ -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;
+ rotation: Record;
+ flipHorizontal: Record;
+ flipVertical: Record;
+ translateX: Record;
+ translateY: Record;
+ isDraggingImage: boolean;
content: string;
}
@@ -82,6 +89,11 @@ export default class FilePreviewModal extends React.PureComponent
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
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
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
}
};
+ 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
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
};
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
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
let content;
let zoomBar;
+ let imageControlsBar;
if (isFileInfo(fileInfo) && fileInfo.archived) {
content = (
@@ -339,6 +537,22 @@ export default class FilePreviewModal extends React.PureComponent
+ );
+ const currentScale = this.state.scale[this.state.imageIndex] ?? ZoomSettings.DEFAULT_SCALE;
+ imageControlsBar = (
+ 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
'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}
+ {imageControlsBar}
{ this.props.isMobileView &&
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 (
+
+
+
+ }
+ >
+
+
+
+
+
+ }
+ >
+
+
+
+
+
+ }
+ >
+
+
+
+
+
+ }
+ >
+
+
+
+
+
+ }
+ >
+
+ {'H'}
+
+
+
+ }
+ >
+
+ {'V'}
+
+
+
+
+ );
+}
diff --git a/webapp/channels/src/components/file_preview_modal/image_controls_bar/index.ts b/webapp/channels/src/components/file_preview_modal/image_controls_bar/index.ts
new file mode 100644
index 00000000000..63905a1bd23
--- /dev/null
+++ b/webapp/channels/src/components/file_preview_modal/image_controls_bar/index.ts
@@ -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;
diff --git a/webapp/channels/src/components/file_preview_modal/image_preview.scss b/webapp/channels/src/components/file_preview_modal/image_preview.scss
index fea64a3d5ea..3010b04bd1d 100644
--- a/webapp/channels/src/components/file_preview_modal/image_preview.scss
+++ b/webapp/channels/src/components/file_preview_modal/image_preview.scss
@@ -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;
diff --git a/webapp/channels/src/components/file_preview_modal/image_preview.test.tsx b/webapp/channels/src/components/file_preview_modal/image_preview.test.tsx
index 31aac4156ea..81f217e00a1 100644
--- a/webapp/channels/src/components/file_preview_modal/image_preview.test.tsx
+++ b/webapp/channels/src/components/file_preview_modal/image_preview.test.tsx
@@ -86,7 +86,20 @@ describe('components/view_image/ImagePreview', () => {
,
);
- 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(
+ ,
+ );
+
+ expect(screen.getByTestId('imagePreview')).toHaveStyle({transform: props.transform});
+ });
});
diff --git a/webapp/channels/src/components/file_preview_modal/image_preview.tsx b/webapp/channels/src/components/file_preview_modal/image_preview.tsx
index f4a6d600590..2edde369a16 100644
--- a/webapp/channels/src/components/file_preview_modal/image_preview.tsx
+++ b/webapp/channels/src/components/file_preview_modal/image_preview.tsx
@@ -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 ;
- }
-
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 (
+
+ );
+ }
+
return (
- e.preventDefault()}
>
e.preventDefault()}
/>
-
+
);
}
diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx
index d1cc00213f9..f7d8432b2f7 100644
--- a/webapp/channels/src/utils/constants.tsx
+++ b/webapp/channels/src/utils/constants.tsx
@@ -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 = {
diff --git a/webapp/channels/src/utils/utils.tsx b/webapp/channels/src/utils/utils.tsx
index 6603adf4c09..81640837dd0 100644
--- a/webapp/channels/src/utils/utils.tsx
+++ b/webapp/channels/src/utils/utils.tsx
@@ -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;