From c6d47599da65e19edfffdbcf13d6eb70abce153a Mon Sep 17 00:00:00 2001 From: Vetash Date: Tue, 24 Mar 2026 15:37:46 +0800 Subject: [PATCH 1/2] Add image preview controls, pan, and keyboard navigation --- .../file_preview_modal.test.tsx.snap | 20 +- .../__snapshots__/image_preview.test.tsx.snap | 16 +- .../file_preview_modal.scss | 19 ++ .../file_preview_modal.test.tsx | 5 +- .../file_preview_modal/file_preview_modal.tsx | 230 +++++++++++++++++- .../image_controls_bar.scss | 34 +++ .../image_controls_bar/image_controls_bar.tsx | 155 ++++++++++++ .../image_controls_bar/index.ts | 6 + .../file_preview_modal/image_preview.scss | 14 +- .../file_preview_modal/image_preview.test.tsx | 15 +- .../file_preview_modal/image_preview.tsx | 33 ++- webapp/channels/src/utils/constants.tsx | 4 +- 12 files changed, 520 insertions(+), 31 deletions(-) create mode 100644 webapp/channels/src/components/file_preview_modal/image_controls_bar/image_controls_bar.scss create mode 100644 webapp/channels/src/components/file_preview_modal/image_controls_bar/image_controls_bar.tsx create mode 100644 webapp/channels/src/components/file_preview_modal/image_controls_bar/index.ts 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 Preview
+
+ Image Controls Bar +
@@ -161,12 +164,15 @@ exports[`components/FilePreviewModal should match snapshot, loaded and showing f
Image Preview
+
+ Image Controls Bar +
@@ -277,12 +283,15 @@ exports[`components/FilePreviewModal should match snapshot, loaded with footer 1
Image Preview
+
+ Image Controls Bar +
@@ -306,12 +315,15 @@ exports[`components/FilePreviewModal should match snapshot, loaded with image 1`
Image Preview
+
+ 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`] = `
- preview url image - +
`; 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`] = `
- preview url image - +
`; 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 ( +
+
+ + } + > + + + + } + > + + + + } + > + + + + } + > + + + + } + > + + + + } + > + + +
+
+ ); +} 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()} > {'preview e.preventDefault()} /> - + ); } diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 461d7e8d31e..46a8d42b9ef 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -1417,8 +1417,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 = { From c671dc884641b70a916ba48900e7e5e7325f4b19 Mon Sep 17 00:00:00 2001 From: Vetash Date: Wed, 25 Mar 2026 01:08:53 +0800 Subject: [PATCH 2/2] Fix external image preview loading fallback on XHR error --- webapp/channels/src/utils/utils.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/webapp/channels/src/utils/utils.tsx b/webapp/channels/src/utils/utils.tsx index 32f18efc186..b8b090cc897 100644 --- a/webapp/channels/src/utils/utils.tsx +++ b/webapp/channels/src/utils/utils.tsx @@ -909,6 +909,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;