diff --git a/packages/client/src/pages/Home/MediaViewer/index.js b/packages/client/src/pages/Home/MediaViewer/index.js index 000e281c1a..3a0da85f61 100644 --- a/packages/client/src/pages/Home/MediaViewer/index.js +++ b/packages/client/src/pages/Home/MediaViewer/index.js @@ -11,6 +11,7 @@ const FilesMediaViewer = (props) => { t, files, playlist, + currentPostionIndex, visible, currentMediaFileId, deleteItemAction, @@ -43,10 +44,19 @@ const FilesMediaViewer = (props) => { extsMediaPreviewed, setIsPreview, isPreview, + nextMedia, + prevMedia, resetUrl, firstLoad, + setSelection, } = props; + useEffect(() => { + if (visible) { + resetSelection(); + } + }, [visible]); + useEffect(() => { const previewId = queryString.parse(location.search).preview; @@ -88,6 +98,10 @@ const FilesMediaViewer = (props) => { window.history.pushState(null, null, url); }; + const resetSelection = () => { + setSelection([]); + }; + const removeQuery = (queryName) => { const queryParams = new URLSearchParams(location.search); @@ -108,9 +122,6 @@ const FilesMediaViewer = (props) => { } }; - const canDelete = (fileId) => true; //TODO: - const canDownload = (fileId) => true; //TODO: - const onDeleteMediaFile = (id) => { const translations = { deleteOperation: t("Translations:DeleteOperation"), @@ -138,22 +149,12 @@ const FilesMediaViewer = (props) => { if (isPreview) { setIsPreview(false); resetUrl(); - setScrollToItem({ id: previewFile.id, type: "file" }); - setBufferSelection(previewFile); + if (previewFile) { + setScrollToItem({ id: previewFile.id, type: "file" }); + setBufferSelection(previewFile); + } setToPreviewFile(null); } - // if (previewFile) { - // setIsLoading(true); - // setFirstLoad(true); - - // fetchFiles(previewFile.folderId).finally(() => { - // setIsLoading(false); - // setFirstLoad(false); - // setScrollToItem({ id: previewFile.id, type: "file" }); - // setBufferSelection(previewFile); - // setToPreviewFile(null); - // }); - // } setMediaViewerData({ visible: false, id: null }); @@ -176,20 +177,16 @@ const FilesMediaViewer = (props) => { t={t} userAccess={userAccess} currentFileId={currentMediaFileId} - allowConvert={true} //TODO: - canDelete={canDelete} //TODO: - canDownload={canDownload} //TODO: visible={visible} playlist={playlist} + playlistPos={currentPostionIndex} onDelete={onDeleteMediaFile} onDownload={onDownloadMediaFile} - onClickFavorite={onClickFavorite} setBufferSelection={setBufferSelection} archiveRoomsId={archiveRoomsId} files={files} onClickDownload={onClickDownload} onShowInfoPanel={onShowInfoPanel} - onClickDownloadAs={onClickDownloadAs} onClickDelete={onClickDelete} onClickRename={onClickRename} onMoveAction={onMoveAction} @@ -201,11 +198,10 @@ const FilesMediaViewer = (props) => { deleteDialogVisible={deleteDialogVisible} extsMediaPreviewed={extsMediaPreviewed} extsImagePreviewed={extsImagePreviewed} - errorLabel={t("Translations:MediaLoadError")} isPreviewFile={firstLoad} - previewFile={previewFile} onChangeUrl={onChangeUrl} - isFavoritesFolder={isFavoritesFolder} + nextMedia={nextMedia} + prevMedia={prevMedia} /> ) ); @@ -233,15 +229,19 @@ export default inject( setIsPreview, isPreview, resetUrl, + setSelection, } = filesStore; const { visible, id: currentMediaFileId, + currentPostionIndex, setMediaViewerData, playlist, previewFile, setToPreviewFile, setCurrentId, + nextMedia, + prevMedia, } = mediaViewerDataStore; const { deleteItemAction } = filesActionsStore; const { getIcon, extsImagePreviewed, extsMediaPreviewed } = settingsStore; @@ -262,6 +262,9 @@ export default inject( return { files, playlist, + currentPostionIndex, + nextMedia, + prevMedia, userAccess, visible: playlist.length > 0 && visible, currentMediaFileId, @@ -295,6 +298,7 @@ export default inject( onCopyAction, onDuplicate, archiveRoomsId, + setSelection, }; } )( diff --git a/packages/client/src/store/MediaViewerDataStore.js b/packages/client/src/store/MediaViewerDataStore.js index 0a50b03be3..7ad3732a80 100644 --- a/packages/client/src/store/MediaViewerDataStore.js +++ b/packages/client/src/store/MediaViewerDataStore.js @@ -1,4 +1,8 @@ -import { makeAutoObservable } from "mobx"; +import { makeAutoObservable, runInAction } from "mobx"; +import { + isNullOrUndefined, + findNearestIndex, +} from "@docspace/common/components/MediaViewer/helpers"; class MediaViewerDataStore { filesStore; @@ -8,6 +12,7 @@ class MediaViewerDataStore { visible = false; previewFile = null; currentItem = null; + prevPostionIndex = 0; constructor(filesStore, settingsStore) { makeAutoObservable(this); @@ -45,6 +50,68 @@ class MediaViewerDataStore { this.id = id; }; + changeUrl = (id) => { + const url = "/products/files/#preview/" + id; + window.history.pushState(null, null, url); + }; + + nextMedia = () => { + const { setBufferSelection, files } = this.filesStore; + + const postionIndex = (this.currentPostionIndex + 1) % this.playlist.length; + + if (postionIndex === 0) { + return; + } + const currentFileId = this.playlist[postionIndex].fileId; + + const targetFile = files.find((item) => item.id === currentFileId); + + if (!isNullOrUndefined(targetFile)) setBufferSelection(targetFile); + + const fileId = this.playlist[postionIndex].fileId; + this.setCurrentId(fileId); + this.changeUrl(fileId); + }; + + prevMedia = () => { + const { setBufferSelection, files } = this.filesStore; + + let currentPlaylistPos = this.currentPostionIndex - 1; + + if (currentPlaylistPos === -1) { + return; + } + + const currentFileId = this.playlist[currentPlaylistPos].fileId; + + const targetFile = files.find((item) => item.id === currentFileId); + + if (!isNullOrUndefined(targetFile)) setBufferSelection(targetFile); + + const fileId = this.playlist[currentPlaylistPos].fileId; + this.setCurrentId(fileId); + this.changeUrl(fileId); + }; + + get currentPostionIndex() { + if (this.playlist.length === 0) { + return 0; + } + + let index = this.playlist.find((file) => file.fileId === this.id)?.id; + + if (isNullOrUndefined(index)) { + index = findNearestIndex(this.playlist, this.prevPostionIndex); + } + + runInAction(() => { + this.prevPostionIndex = index; + }); + + return index; + } + get playlist() { const { files } = this.filesStore; @@ -83,6 +150,11 @@ class MediaViewerDataStore { id++; } }); + if (this.previewFile) { + runInAction(() => { + this.previewFile = null; + }); + } } else if (this.previewFile) { playlist.push({ ...this.previewFile, diff --git a/packages/common/components/MediaViewer/MediaViewer.js b/packages/common/components/MediaViewer/MediaViewer.js deleted file mode 100644 index aa62afac04..0000000000 --- a/packages/common/components/MediaViewer/MediaViewer.js +++ /dev/null @@ -1,722 +0,0 @@ -import React from "react"; -import PropTypes from "prop-types"; -import styled from "styled-components"; -import ImageViewer from "./sub-components/image-viewer"; -import equal from "fast-deep-equal/react"; -import Hammer from "hammerjs"; -import { isMobileOnly } from "react-device-detect"; -import { FileStatus } from "@docspace/common/constants"; - -import InfoOutlineReactSvgUrl from "PUBLIC_DIR/images/info.outline.react.svg?url"; -import CopyReactSvgUrl from "PUBLIC_DIR/images/copy.react.svg?url"; -import DuplicateReactSvgUrl from "PUBLIC_DIR/images/duplicate.react.svg?url"; -import DownloadReactSvgUrl from "PUBLIC_DIR/images/download.react.svg?url"; -import DownloadAsReactSvgUrl from "PUBLIC_DIR/images/download-as.react.svg?url"; -import RenameReactSvgUrl from "PUBLIC_DIR/images/rename.react.svg?url"; -import TrashReactSvgUrl from "PUBLIC_DIR/images/trash.react.svg?url"; -import MoveReactSvgUrl from "PUBLIC_DIR/images/duplicate.react.svg?url"; - -const mediaTypes = Object.freeze({ - audio: 1, - video: 2, -}); - -const ButtonKeys = Object.freeze({ - leftArrow: 37, - rightArrow: 39, - upArrow: 38, - downArrow: 40, - space: 32, - esc: 27, - ctr: 17, - one: 49, - del: 46, - s: 83, -}); - -let ctrIsPressed = false; -class MediaViewer extends React.Component { - constructor(props) { - super(props); - - const { playlist, currentFileId, visible } = props; - - const item = playlist.find( - (file) => String(file.fileId) === String(currentFileId) - ); - - if (!item) { - console.error("MediaViewer: file not found in playlist", { - playlist, - currentFileId, - }); - return; - } - - const playlistPos = item ? item.id : 0; - - this.state = { - visible, - allowConvert: true, - playlist, - playlistPos, - fileUrl: item.src, - canSwipeImage: true, - }; - - this.detailsContainer = React.createRef(); - this.viewerToolbox = React.createRef(); - } - - updateHammer() { - const { playlistPos, playlist } = this.state; - - const currentFile = playlist[playlistPos]; - const { title } = currentFile; - - const ext = this.getFileExtension(title); - const _this = this; - - if (this.hammer) { - this.hammer.off("doubletap", this.prevMedia); - } - this.hammer = null; - setTimeout(function () { - try { - if (_this.canImageView(ext)) { - const pinch = new Hammer.Pinch(); - _this.hammer = Hammer( - document.getElementsByClassName("react-viewer-canvas")[0] - ); - _this.hammer.add([pinch]); - - _this.hammer.on("doubletap", _this.doubleTap); - } - } catch (ex) { - //console.error("MediaViewer updateHammer", ex); - this.hammer = null; - } - }, 500); - } - - componentDidUpdate(prevProps, prevState) { - const { - visible, - playlist, - currentFileId, - onEmptyPlaylistError, - } = this.props; - - const { playlistPos, fileUrl } = this.state; - const src = playlist[playlistPos]?.src; - const title = playlist[playlistPos]?.title; - const ext = this.getFileExtension(title); - - if (visible !== prevProps.visible) { - const newPlaylistPos = - playlist.length > 0 - ? playlist.find((file) => file.fileId === currentFileId).id - : 0; - - this.setState({ - visible: visible, - playlistPos: newPlaylistPos, - }); - } - - if ( - src && - src !== fileUrl && - playlistPos === prevState.playlistPos && - ext !== ".tif" && - ext !== ".tiff" - ) { - this.setState({ fileUrl: src }); - } - - if ( - visible && - visible === prevProps.visible && - playlistPos !== prevState.playlistPos - ) { - this.updateHammer(); - if (ext === ".tiff" || ext === ".tif") { - this.getTiffDataURL(src); - } else { - this.setState({ fileUrl: src }); - } - } - - if ( - visible && - visible === prevProps.visible && - !equal(playlist, prevProps.playlist) - ) { - if (playlist.length > 0) { - this.updateHammer(); - //switching from index to id - const newPlaylistPos = currentFileId - ? playlist.find((file) => file.fileId === currentFileId)?.id ?? 0 - : 0; - - this.setState({ - playlist: playlist, - playlistPos: newPlaylistPos, - }); - } else { - onEmptyPlaylistError(); - this.setState({ - visible: false, - }); - } - } else if (!equal(playlist, prevProps.playlist)) { - this.setState({ - playlist: playlist, - }); - } - } - - componentDidMount() { - const { playlist, files, setBufferSelection } = this.props; - const { playlistPos } = this.state; - - const currentFile = playlist[playlistPos]; - - const currentFileId = - playlist.length > 0 - ? playlist.find((file) => file.id === playlistPos).fileId - : 0; - - const targetFile = files.find((item) => item.id === currentFileId); - if (targetFile) setBufferSelection(targetFile); - const { src, title } = currentFile; - const ext = this.getFileExtension(title); - - if (ext === ".tiff" || ext === ".tif") { - this.getTiffDataURL(src); - } - - this.updateHammer(); - - document.addEventListener("keydown", this.onKeydown, false); - document.addEventListener("keyup", this.onKeyup, false); - } - - componentWillUnmount() { - if (this.hammer) { - this.hammer.off("doubletap", this.prevMedia); - } - document.removeEventListener("keydown", this.onKeydown, false); - document.removeEventListener("keyup", this.onKeyup, false); - this.onClose(); - } - - mapSupplied = { - ".aac": { supply: "m4a", type: mediaTypes.audio }, - ".flac": { supply: "mp3", type: mediaTypes.audio }, - ".m4a": { supply: "m4a", type: mediaTypes.audio }, - ".mp3": { supply: "mp3", type: mediaTypes.audio }, - ".oga": { supply: "oga", type: mediaTypes.audio }, - ".ogg": { supply: "oga", type: mediaTypes.audio }, - ".wav": { supply: "wav", type: mediaTypes.audio }, - - ".f4v": { supply: "m4v", type: mediaTypes.video }, - ".m4v": { supply: "m4v", type: mediaTypes.video }, - ".mov": { supply: "m4v", type: mediaTypes.video }, - ".mp4": { supply: "m4v", type: mediaTypes.video }, - ".ogv": { supply: "ogv", type: mediaTypes.video }, - ".webm": { supply: "webmv", type: mediaTypes.video }, - ".wmv": { supply: "m4v", type: mediaTypes.video, convertable: true }, - ".avi": { supply: "m4v", type: mediaTypes.video, convertable: true }, - ".mpeg": { supply: "m4v", type: mediaTypes.video, convertable: true }, - ".mpg": { supply: "m4v", type: mediaTypes.video, convertable: true }, - }; - - canImageView = function (ext) { - const { extsImagePreviewed } = this.props; - return extsImagePreviewed.indexOf(ext) != -1; - }; - - canPlay = (fileTitle, allowConvert) => { - const { extsMediaPreviewed } = this.props; - - const ext = - fileTitle[0] === "." ? fileTitle : this.getFileExtension(fileTitle); - - const supply = this.mapSupplied[ext]; - - const canConvert = allowConvert || this.props.allowConvert; - - return ( - !!supply && - extsMediaPreviewed.indexOf(ext) != -1 && - (!supply.convertable || canConvert) - ); - }; - - getFileExtension = (fileTitle) => { - if (!fileTitle) { - return ""; - } - fileTitle = fileTitle.trim(); - const posExt = fileTitle.lastIndexOf("."); - return 0 <= posExt ? fileTitle.substring(posExt).trim().toLowerCase() : ""; - }; - - zoom = 1; - - handleZoomEnd = () => { - this.zoom = 1; - }; - - handleZoomIn = (e) => { - if (this.zoom - e.scale > 0.1) { - this.zoom = e.scale; - document.querySelector('li[data-key="zoomOut"]').click(); - } - }; - - handleZoomOut = (e) => { - if (e.scale - this.zoom > 0.3) { - this.zoom = e.scale; - document.querySelector('li[data-key="zoomIn"]').click(); - } - }; - - doubleTap = () => { - document.querySelector('li[data-key="zoomIn"]')?.click(); - }; - - prevMedia = () => { - const { playlistPos, playlist } = this.state; - const { setBufferSelection } = this.props; - - let currentPlaylistPos = playlistPos; - currentPlaylistPos--; - if (currentPlaylistPos === -1) return; - if (currentPlaylistPos < 0) currentPlaylistPos = playlist.length - 1; - - const currentFileId = playlist[currentPlaylistPos].fileId; - - const targetFile = this.props.files.find( - (item) => item.id === currentFileId - ); - setBufferSelection(targetFile); - - this.setState({ - playlistPos: currentPlaylistPos, - }); - - const id = playlist[currentPlaylistPos].fileId; - this.props.onChangeUrl(id); - }; - - nextMedia = () => { - const { playlistPos, playlist } = this.state; - const { setBufferSelection } = this.props; - - let currentPlaylistPos = playlistPos; - currentPlaylistPos = (currentPlaylistPos + 1) % playlist.length; - if (currentPlaylistPos === 0) return; - - const currentFileId = playlist[currentPlaylistPos].fileId; - - const targetFile = this.props.files.find( - (item) => item.id === currentFileId - ); - setBufferSelection(targetFile); - - this.setState({ - playlistPos: currentPlaylistPos, - }); - - const id = playlist[currentPlaylistPos].fileId; - this.props.onChangeUrl(id); - }; - - getOffset = () => { - if (this.detailsContainer.current && this.viewerToolbox.current) { - return ( - this.detailsContainer.current.offsetHeight + - this.viewerToolbox.current.offsetHeight - ); - } else { - return 0; - } - }; - - onDelete = () => { - const { playlist, playlistPos } = this.state; - - let currentFileId = - playlist.length > 0 - ? playlist.find((file) => file.id === playlistPos).fileId - : 0; - this.props.onDelete && this.props.onDelete(currentFileId); - this.setState({ - canSwipeImage: false, - }); - }; - - onDownload = () => { - const { playlist, playlistPos } = this.state; - - let currentFileId = - playlist.length > 0 - ? playlist.find((file) => file.id === playlistPos).fileId - : 0; - this.props.onDownload && this.props.onDownload(currentFileId); - }; - - onKeyup = (e) => { - if (ButtonKeys.ctr === e.keyCode) { - ctrIsPressed = false; - } - }; - - onKeydown = (e) => { - let isActionKey = false; - for (let key in ButtonKeys) { - if (ButtonKeys[key] === e.keyCode) { - e.preventDefault(); - isActionKey = true; - } - } - - if (isActionKey) { - switch (e.keyCode) { - case ButtonKeys.leftArrow: - if (document.fullscreenElement) return; - this.state.canSwipeImage - ? ctrIsPressed - ? document.getElementsByClassName("iconContainer rotateLeft") - .length > 0 && - document - .getElementsByClassName("iconContainer rotateLeft")[0] - .click() - : this.prevMedia() - : null; - break; - case ButtonKeys.rightArrow: - if (document.fullscreenElement) return; - this.state.canSwipeImage - ? ctrIsPressed - ? document.getElementsByClassName("iconContainer rotateRight") - .length > 0 && - document - .getElementsByClassName("iconContainer rotateRight")[0] - .click() - : this.nextMedia() - : null; - break; - case ButtonKeys.space: - document.getElementsByClassName("video-play").length > 0 && - document.getElementsByClassName("video-play")[0].click(); - break; - case ButtonKeys.esc: - if (!this.props.deleteDialogVisible) this.props.onClose(); - break; - case ButtonKeys.upArrow: - document.getElementsByClassName("iconContainer zoomIn").length > 0 && - document.getElementsByClassName("iconContainer zoomIn")[0].click(); - break; - case ButtonKeys.downArrow: - document.getElementsByClassName("iconContainer zoomOut").length > 0 && - document.getElementsByClassName("iconContainer zoomOut")[0].click(); - break; - case ButtonKeys.ctr: - ctrIsPressed = true; - break; - case ButtonKeys.s: - if (ctrIsPressed) this.onDownload(); - break; - case ButtonKeys.one: - ctrIsPressed && - document.getElementsByClassName("iconContainer reset").length > 0 && - document.getElementsByClassName("iconContainer reset")[0].click(); - break; - case ButtonKeys.del: - this.onDelete(); - break; - - default: - break; - } - } - }; - - onClose = (e) => { - //fix memory leak - this.setState({ visible: false }); - this.props.onClose(e); - }; - - getTiffDataURL = (src) => { - if (!window.Tiff) return; - const _this = this; - const xhr = new XMLHttpRequest(); - xhr.responseType = "arraybuffer"; - xhr.open("GET", src); - xhr.onload = function () { - try { - const tiff = new window.Tiff({ buffer: xhr.response }); - const dataUrl = tiff.toDataURL(); - _this.setState({ fileUrl: dataUrl }); - } catch (e) { - console.log(e); - } - }; - xhr.send(); - }; - - render() { - const { playlistPos, playlist, visible, fileUrl } = this.state; - const { - t, - onClose, - userAccess, - canDelete, - canDownload, - errorLabel, - isPreviewFile, - onClickFavorite, - onShowInfoPanel, - onClickDownload, - onMoveAction, - onCopyAction, - onDuplicate, - onClickDownloadAs, - getIcon, - onClickRename, - onClickDelete, - setBufferSelection, - files, - archiveRoomsId, - } = this.props; - - const currentFileId = - playlist.length > 0 - ? playlist.find((file) => file.id === playlistPos).fileId - : 0; - - const currentFile = playlist[playlistPos]; - - const targetFile = - files.find((item) => item.id === currentFileId) || playlist[0]; - - const archiveRoom = - archiveRoomsId === targetFile.rootFolderId || - (!targetFile?.security?.Rename && !targetFile?.security?.Delete); - const { title } = currentFile; - - let isImage = false; - let isVideo = false; - let isAudio = false; - let canOpen = true; - - const isFavorite = - (playlist[playlistPos].fileStatus & FileStatus.IsFavorite) === - FileStatus.IsFavorite; - - const ext = this.getFileExtension(title); - - const onSetSelectionFile = () => { - setBufferSelection(targetFile); - }; - - const getContextModel = () => { - const desktopModel = [ - { - key: "download", - label: t("Common:Download"), - icon: DownloadReactSvgUrl, - onClick: () => onClickDownload(targetFile, t), - disabled: false, - }, - { - key: "rename", - label: t("Rename"), - icon: RenameReactSvgUrl, - onClick: () => onClickRename(targetFile), - disabled: archiveRoom, - }, - { - key: "delete", - label: t("Common:Delete"), - icon: TrashReactSvgUrl, - onClick: () => onClickDelete(targetFile, t), - disabled: archiveRoom, - }, - ]; - - const model = [ - { - id: "option_room-info", - key: "room-info", - label: t("Common:Info"), - icon: InfoOutlineReactSvgUrl, - onClick: () => { - return onShowInfoPanel(targetFile); - }, - disabled: false, - }, - { - key: "download", - label: t("Common:Download"), - icon: DownloadReactSvgUrl, - onClick: () => onClickDownload(targetFile, t), - disabled: false, - }, - { - key: "move-to", - label: t("MoveTo"), - icon: MoveReactSvgUrl, - onClick: onMoveAction, - disabled: !targetFile.security.Move, - }, - // { - // key: "download-as", - // label: t("Translations:DownloadAs"), - // icon: DownloadAsReactSvgUrl, // TODO: uncomment when we can download media by changing the format - // onClick: onClickDownloadAs, - // disabled: false, - // }, - { - id: "option_copy-to", - key: "copy-to", - label: t("Translations:Copy"), - icon: CopyReactSvgUrl, - onClick: onCopyAction, - disabled: !targetFile.security.Copy, - }, - { - id: "option_create-copy", - key: "copy", - label: t("Common:Duplicate"), - icon: DuplicateReactSvgUrl, - onClick: () => onDuplicate(targetFile, t), - disabled: !targetFile.security.Duplicate, - }, - { - key: "rename", - label: t("Rename"), - icon: RenameReactSvgUrl, - onClick: () => onClickRename(targetFile), - disabled: !targetFile.security.Rename, - }, - - { - key: "separator0", - isSeparator: true, - disabled: !targetFile.security.Delete, - }, - { - key: "delete", - label: t("Common:Delete"), - icon: TrashReactSvgUrl, - onClick: () => onClickDelete(targetFile, t), - disabled: !targetFile.security.Delete, - }, - ]; - - return isMobileOnly - ? model - : isImage && !isMobileOnly - ? desktopModel.filter((el) => el.key !== "download") - : desktopModel; - }; - - if (!this.canPlay(ext) && !this.canImageView(ext)) { - canOpen = false; - this.props.onError && this.props.onError(); - } - - if (this.canImageView(ext)) { - isImage = true; - } else { - isImage = false; - isVideo = this.mapSupplied[ext] - ? this.mapSupplied[ext].type == mediaTypes.video - : false; - isAudio = this.mapSupplied[ext] - ? this.mapSupplied[ext].type == mediaTypes.audio - : false; - } - - let audioIcon = getIcon(96, ext); - let headerIcon = getIcon(24, ext); - - // TODO: rewrite with fileURL - /*if (this.mapSupplied[ext]) - if (!isImage && this.mapSupplied[ext].convertable && !src.includes("#")) { - src += (src.includes("?") ? "&" : "?") + "convpreview=true"; - }*/ - return ( - <> - {canOpen && ( - - )} - - ); - } -} - -MediaViewer.propTypes = { - allowConvert: PropTypes.bool, - visible: PropTypes.bool, - currentFileId: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - playlist: PropTypes.arrayOf(PropTypes.object), - extsImagePreviewed: PropTypes.arrayOf(PropTypes.string), - extsMediaPreviewed: PropTypes.arrayOf(PropTypes.string), - onError: PropTypes.func, - canDelete: PropTypes.func, - canDownload: PropTypes.func, - onDelete: PropTypes.func, - onDownload: PropTypes.func, - onClose: PropTypes.func, - onEmptyPlaylistError: PropTypes.func, - deleteDialogVisible: PropTypes.bool, - errorLabel: PropTypes.string, - isPreviewFile: PropTypes.bool, - onChangeUrl: PropTypes.func, -}; - -MediaViewer.defaultProps = { - currentFileId: 0, - visible: false, - allowConvert: true, - canDelete: () => { - return true; - }, - canDownload: () => { - return true; - }, - isPreviewFile: false, -}; - -export default MediaViewer; diff --git a/packages/common/components/MediaViewer/MediaViewer.props.ts b/packages/common/components/MediaViewer/MediaViewer.props.ts new file mode 100644 index 0000000000..f1a83794f8 --- /dev/null +++ b/packages/common/components/MediaViewer/MediaViewer.props.ts @@ -0,0 +1,45 @@ +import { IFile, NumberOrString, PlaylistType, TranslationType } from "./types"; +export interface MediaViewerProps { + t: TranslationType; + + userAccess: boolean; + currentFileId: NumberOrString; + + visible: boolean; + + extsMediaPreviewed: string[]; + extsImagePreviewed: string[]; + + deleteDialogVisible: boolean; + errorLabel: string; + isPreviewFile: boolean; + + files: IFile[]; + + playlist: PlaylistType[]; + + setBufferSelection: Function; + archiveRoomsId: number; + + playlistPos: number; + + getIcon: (size: number, ext: string, ...arg: any) => any; + + onClose: VoidFunction; + onError?: VoidFunction; + onEmptyPlaylistError: VoidFunction; + onDelete: (id: NumberOrString) => void; + onDownload: (id: NumberOrString) => void; + onChangeUrl: (id: NumberOrString) => void; + + onMoveAction: VoidFunction; + onCopyAction: VoidFunction; + onClickRename: (file: IFile) => void; + onShowInfoPanel: (file: IFile) => void; + onDuplicate: (file: IFile, t: TranslationType) => void; + onClickDelete: (file: IFile, t: TranslationType) => void; + onClickDownload: (file: IFile, t: TranslationType) => void; + + nextMedia: VoidFunction; + prevMedia: VoidFunction; +} diff --git a/packages/common/components/MediaViewer/helpers/index.ts b/packages/common/components/MediaViewer/helpers/index.ts new file mode 100644 index 0000000000..79daa46e26 --- /dev/null +++ b/packages/common/components/MediaViewer/helpers/index.ts @@ -0,0 +1,59 @@ +import { NullOrUndefined, PlaylistType } from "../types"; + +export const mediaTypes = Object.freeze({ + audio: 1, + video: 2, +}); + +export enum KeyboardEventKeys { + ArrowRight = "ArrowRight", + ArrowLeft = "ArrowLeft", + Escape = "Escape", + Space = "Space", + Delete = "Delete", + KeyS = "KeyS", + Numpad1 = "Numpad1", + Digit1 = "Digit1", +} + +export const mapSupplied = { + ".aac": { supply: "m4a", type: mediaTypes.audio }, + ".flac": { supply: "mp3", type: mediaTypes.audio }, + ".m4a": { supply: "m4a", type: mediaTypes.audio }, + ".mp3": { supply: "mp3", type: mediaTypes.audio }, + ".oga": { supply: "oga", type: mediaTypes.audio }, + ".ogg": { supply: "oga", type: mediaTypes.audio }, + ".wav": { supply: "wav", type: mediaTypes.audio }, + + ".f4v": { supply: "m4v", type: mediaTypes.video }, + ".m4v": { supply: "m4v", type: mediaTypes.video }, + ".mov": { supply: "m4v", type: mediaTypes.video }, + ".mp4": { supply: "m4v", type: mediaTypes.video }, + ".ogv": { supply: "ogv", type: mediaTypes.video }, + ".webm": { supply: "webmv", type: mediaTypes.video }, + ".wmv": { supply: "m4v", type: mediaTypes.video, convertable: true }, + ".avi": { supply: "m4v", type: mediaTypes.video, convertable: true }, + ".mpeg": { supply: "m4v", type: mediaTypes.video, convertable: true }, + ".mpg": { supply: "m4v", type: mediaTypes.video, convertable: true }, +} as Record; + +export const isNullOrUndefined = (arg: unknown): arg is NullOrUndefined => { + return arg === undefined || arg === null; +}; + +export const findNearestIndex = ( + items: PlaylistType[], + index: number +): number => { + if (!Array.isArray(items) || items.length === 0 || index < 0) { + return -1; + } + + let found = items[0].id; + for (const item of items) { + if (Math.abs(item.id - index) < Math.abs(found - index)) { + found = item.id; + } + } + return found; +}; diff --git a/packages/common/components/MediaViewer/index.js b/packages/common/components/MediaViewer/index.js deleted file mode 100644 index 76bd8cb268..0000000000 --- a/packages/common/components/MediaViewer/index.js +++ /dev/null @@ -1 +0,0 @@ -export default from "./MediaViewer"; diff --git a/packages/common/components/MediaViewer/index.tsx b/packages/common/components/MediaViewer/index.tsx new file mode 100644 index 0000000000..782e3e4f20 --- /dev/null +++ b/packages/common/components/MediaViewer/index.tsx @@ -0,0 +1,448 @@ +import { isMobileOnly } from "react-device-detect"; +import React, { useState, useCallback, useMemo, useEffect } from "react"; + +import ImageViewer from "./sub-components/image-viewer"; + +import { MediaViewerProps } from "./MediaViewer.props"; +import { FileStatus } from "@docspace/common/constants"; +import { + isNullOrUndefined, + KeyboardEventKeys, + mapSupplied, + mediaTypes, +} from "./helpers"; + +import InfoOutlineReactSvgUrl from "PUBLIC_DIR/images/info.outline.react.svg?url"; +import CopyReactSvgUrl from "PUBLIC_DIR/images/copy.react.svg?url"; +import DuplicateReactSvgUrl from "PUBLIC_DIR/images/duplicate.react.svg?url"; +import DownloadReactSvgUrl from "PUBLIC_DIR/images/download.react.svg?url"; +import RenameReactSvgUrl from "PUBLIC_DIR/images/rename.react.svg?url"; +import TrashReactSvgUrl from "PUBLIC_DIR/images/trash.react.svg?url"; +import MoveReactSvgUrl from "PUBLIC_DIR/images/duplicate.react.svg?url"; + +function MediaViewer({ + playlistPos, + nextMedia, + prevMedia, + ...props +}: MediaViewerProps): JSX.Element { + const [title, setTitle] = useState(""); + const [canSwipeImage, setCanSwipeImage] = useState(true); + const [fileUrl, setFileUrl] = useState(() => { + const { playlist, currentFileId } = props; + const item = playlist.find( + (file) => file.fileId.toString() === currentFileId.toString() + ); + return item?.src ?? ""; + }); + + const [targetFile, setTargetFile] = useState(() => { + const { files, currentFileId } = props; + return files.find((item) => item.id === currentFileId); + }); + + const [isFavorite, setIsFavorite] = useState(() => { + const { playlist } = props; + + return ( + (playlist[playlistPos].fileStatus & FileStatus.IsFavorite) === + FileStatus.IsFavorite + ); + }); + + useEffect(() => { + const fileId = props.playlist[playlistPos]?.fileId; + + if (!isNullOrUndefined(fileId) && props.currentFileId !== fileId) { + props.onChangeUrl(fileId); + } + }, [props.playlist.length]); + + useEffect(() => { + const { playlist, files, setBufferSelection } = props; + + const currentFile = playlist[playlistPos]; + + const currentFileId = + playlist.length > 0 + ? playlist.find((file) => file.id === playlistPos)?.fileId + : 0; + + const targetFile = files.find((item) => item.id === currentFileId); + + if (targetFile) setBufferSelection(targetFile); + + const { src, title } = currentFile; + + const ext = getFileExtension(title); + + if (ext === ".tiff" || ext === ".tif") { + fetchAndSetTiffDataURL(src); + } + + document.addEventListener("keydown", onKeydown); + + return () => { + document.removeEventListener("keydown", onKeydown); + }; + }, []); + + useEffect(() => { + const { playlist, onEmptyPlaylistError, files, setBufferSelection } = props; + + const { src, title, fileId } = playlist[playlistPos]; + const ext = getFileExtension(title); + + if (!src) return onEmptyPlaylistError(); + + if (ext !== ".tif" && ext !== ".tiff" && src !== fileUrl) { + setFileUrl(src); + } + + if (ext === ".tiff" || ext === ".tif") { + fetchAndSetTiffDataURL(src); + } + + const foundFile = files.find((file) => file.id === fileId); + + if (!isNullOrUndefined(foundFile)) { + setTargetFile(foundFile); + setBufferSelection(foundFile); + } + + setTitle(title); + setIsFavorite( + (playlist[playlistPos].fileStatus & FileStatus.IsFavorite) === + FileStatus.IsFavorite + ); + }, [ + props.playlist.length, + props.files.length, + props.currentFileId, + playlistPos, + ]); + + const getContextModel = () => { + const { + t, + onClickDownload, + onClickRename, + onClickDelete, + onShowInfoPanel, + onMoveAction, + onCopyAction, + onDuplicate, + } = props; + + if (!targetFile) return []; + + const desktopModel = [ + { + key: "download", + label: t("Common:Download"), + icon: DownloadReactSvgUrl, + onClick: () => onClickDownload(targetFile, t), + disabled: false, + }, + { + key: "rename", + label: t("Rename"), + icon: RenameReactSvgUrl, + onClick: () => onClickRename(targetFile), + disabled: archiveRoom, + }, + { + key: "delete", + label: t("Common:Delete"), + icon: TrashReactSvgUrl, + onClick: () => onClickDelete(targetFile, t), + disabled: archiveRoom, + }, + ]; + + const model = [ + { + id: "option_room-info", + key: "room-info", + label: t("Common:Info"), + icon: InfoOutlineReactSvgUrl, + onClick: () => { + return onShowInfoPanel(targetFile); + }, + disabled: false, + }, + { + key: "download", + label: t("Common:Download"), + icon: DownloadReactSvgUrl, + onClick: () => onClickDownload(targetFile, t), + disabled: false, + }, + { + key: "move-to", + label: t("MoveTo"), + icon: MoveReactSvgUrl, + onClick: onMoveAction, + disabled: !targetFile.security.Move, + }, + { + id: "option_copy-to", + key: "copy-to", + label: t("Translations:Copy"), + icon: CopyReactSvgUrl, + onClick: onCopyAction, + disabled: !targetFile.security.Copy, + }, + { + id: "option_create-copy", + key: "copy", + label: t("Common:Duplicate"), + icon: DuplicateReactSvgUrl, + onClick: () => onDuplicate(targetFile, t), + disabled: !targetFile.security.Duplicate, + }, + { + key: "rename", + label: t("Rename"), + icon: RenameReactSvgUrl, + onClick: () => onClickRename(targetFile), + disabled: !targetFile.security.Rename, + }, + + { + key: "separator0", + isSeparator: true, + disabled: !targetFile.security.Delete, + }, + { + key: "delete", + label: t("Common:Delete"), + icon: TrashReactSvgUrl, + onClick: () => onClickDelete(targetFile, t), + disabled: !targetFile.security.Delete, + }, + ]; + + return isMobileOnly + ? model + : isImage && !isMobileOnly + ? desktopModel.filter((el) => el.key !== "download") + : desktopModel; + }; + + const canImageView = useCallback( + (ext: string) => { + const { extsImagePreviewed } = props; + return extsImagePreviewed.indexOf(ext) != -1; + }, + [props.extsImagePreviewed] + ); + + const canPlay = useCallback( + (fileTitle: string) => { + const { extsMediaPreviewed } = props; + + const ext = + fileTitle[0] === "." ? fileTitle : getFileExtension(fileTitle); + + const supply = mapSupplied[ext]; + + return !!supply && extsMediaPreviewed.indexOf(ext) != -1; + }, + [props.extsMediaPreviewed] + ); + + const getFileExtension = useCallback((fileTitle: string) => { + if (!fileTitle) { + return ""; + } + fileTitle = fileTitle.trim(); + const posExt = fileTitle.lastIndexOf("."); + return 0 <= posExt ? fileTitle.substring(posExt).trim().toLowerCase() : ""; + }, []); + + const onDelete = () => { + const { playlist, onDelete } = props; + + let currentFileId = playlist.find((file) => file.id === playlistPos) + ?.fileId; + + setCanSwipeImage(false); + if (!isNullOrUndefined(currentFileId)) onDelete(currentFileId); + }; + + const onDownload = () => { + const { playlist, onDownload } = props; + + let currentFileId = playlist.find((file) => file.id === playlistPos) + ?.fileId; + + if (!isNullOrUndefined(currentFileId)) onDownload(currentFileId); + }; + + const onKeydown = (event: KeyboardEvent) => { + const { code, ctrlKey } = event; + + if (code in KeyboardEventKeys) { + event.preventDefault(); + } + + switch (code) { + case KeyboardEventKeys.ArrowLeft: + if (document.fullscreenElement || !canSwipeImage) return; + + if (ctrlKey) { + const rotateLeftElement = document.getElementsByClassName( + "iconContainer rotateLeft" + )?.[0] as HTMLElement | undefined; + rotateLeftElement?.click(); + } else { + prevMedia(); + } + + break; + + case KeyboardEventKeys.ArrowRight: + if (document.fullscreenElement || !canSwipeImage) return; + + if (ctrlKey) { + const rotateRightElement = document.getElementsByClassName( + "iconContainer rotateRight" + )?.[0] as HTMLElement | undefined; + rotateRightElement?.click(); + } else { + nextMedia(); + } + + break; + + case KeyboardEventKeys.Space: + const videoPlayElement = document.getElementsByClassName( + "video-play" + )?.[0] as HTMLElement | undefined; + + videoPlayElement?.click(); + + break; + + case KeyboardEventKeys.Escape: + if (!props.deleteDialogVisible) props.onClose(); + break; + + case KeyboardEventKeys.KeyS: + if (ctrlKey) onDownload(); + break; + + case KeyboardEventKeys.Digit1: + case KeyboardEventKeys.Numpad1: + if (ctrlKey) { + const resetElement = document.getElementsByClassName( + "iconContainer reset" + )?.[0] as HTMLElement | undefined; + resetElement?.click(); + } + break; + + case KeyboardEventKeys.Delete: + onDelete(); + break; + + default: + break; + } + }; + + const onClose = useCallback(() => { + props.onClose(); + }, [props.onClose]); + + const fetchAndSetTiffDataURL = useCallback((src: string) => { + if (!window.Tiff) return; + + const xhr = new XMLHttpRequest(); + xhr.responseType = "arraybuffer"; + + xhr.open("GET", src); + xhr.onload = function () { + try { + const tiff = new window.Tiff({ buffer: xhr.response }); + + const dataUrl = tiff.toDataURL(); + + setFileUrl(dataUrl); + } catch (e) { + console.log(e); + } + }; + xhr.send(); + }, []); + + const onSetSelectionFile = useCallback(() => { + props.setBufferSelection(targetFile); + }, [targetFile]); + + const ext = getFileExtension(title); + const images = useMemo(() => [{ src: fileUrl, alt: "" }], [fileUrl]); + const audioIcon = useMemo(() => props.getIcon(96, ext), [ext]); + const headerIcon = useMemo(() => props.getIcon(24, ext), [ext]); + + let isVideo = false; + let isAudio = false; + let canOpen = true; + let isImage = false; + + const archiveRoom = + props.archiveRoomsId === targetFile?.rootFolderId || + (!targetFile?.security?.Rename && !targetFile?.security?.Delete); + + if (canPlay(ext) && canImageView(ext)) { + canOpen = false; + props.onError?.(); + } + + if (canImageView(ext)) { + isImage = true; + } else { + isImage = false; + isVideo = mapSupplied[ext] + ? mapSupplied[ext]?.type == mediaTypes.video + : false; + isAudio = mapSupplied[ext] + ? mapSupplied[ext]?.type == mediaTypes.audio + : false; + } + + return ( + <> + {canOpen && ( + + )} + + ); +} + +export default MediaViewer; diff --git a/packages/common/components/MediaViewer/types/index.ts b/packages/common/components/MediaViewer/types/index.ts new file mode 100644 index 0000000000..7b152d2249 --- /dev/null +++ b/packages/common/components/MediaViewer/types/index.ts @@ -0,0 +1,89 @@ +declare global { + interface Window { + Tiff: new (arg: object) => any; + } +} + +export type TranslationType = (key: string, opt?: object) => string; + +export type NumberOrString = number | string; + +export type NullOrUndefined = null | undefined; + +export type PlaylistType = { + id: number; + canShare: boolean; + fileExst: string; + fileId: number; + fileStatus: number; + src: string; + title: string; +}; + +export type CreatedType = { + id: string; + avatarSmall: string; + displayName: string; + hasAvatar: boolean; + profileUrl: string; +}; + +export type SecurityType = { + Comment: boolean; + Copy: boolean; + CustomFilter: boolean; + Delete: boolean; + Duplicate: boolean; + Edit: boolean; + EditHistory: boolean; + FillForms: boolean; + Lock: boolean; + Move: boolean; + Read: boolean; + ReadHistory: boolean; + Rename: boolean; + Review: boolean; +}; + +export type ViewAccessabilityType = { + CoAuhtoring: boolean; + Convert: boolean; + ImageView: boolean; + MediaView: boolean; + WebComment: boolean; + WebCustomFilterEditing: boolean; + WebEdit: boolean; + WebRestrictedEditing: boolean; + WebReview: boolean; + WebView: boolean; +}; + +export interface IFile { + id: number; + access: number; + canShare: boolean; + comment: string; + contentLength: string; + created: string; + createdBy: CreatedType; + denyDownload: boolean; + denySharing: boolean; + fileExst: string; + fileStatus: number; + fileType: number; + folderId: number; + pureContentLength: number; + rootFolderId: number; + rootFolderType: number; + security: SecurityType; + shared: boolean; + thumbnailStatus: number; + title: string; + updated: string; + updatedBy: CreatedType; + version: number; + versionGroup: number; + viewAccessability: ViewAccessabilityType; + viewUrl: string; + webUrl: string; +} diff --git a/packages/components/selection-area/SelectionArea.js b/packages/components/selection-area/SelectionArea.js index 3191c31b43..f896bda910 100644 --- a/packages/components/selection-area/SelectionArea.js +++ b/packages/components/selection-area/SelectionArea.js @@ -234,6 +234,9 @@ class SelectionArea extends React.Component { passive: false, }); document.addEventListener("mouseup", this.onTapStop); + + window.addEventListener("blur", this.onTapStop); + this.scrollElement.addEventListener("scroll", this.onScroll); }; @@ -242,6 +245,7 @@ class SelectionArea extends React.Component { document.removeEventListener("mousemove", this.onTapMove); document.removeEventListener("mouseup", this.onTapStop); + window.removeEventListener("blur", this.onTapStop); this.scrollElement.removeEventListener("scroll", this.onScroll); }; @@ -256,7 +260,11 @@ class SelectionArea extends React.Component { folderHeaderHeight, } = this.props; - if (e.target.closest(".not-selectable")) return; + if ( + e.target.closest(".not-selectable") || + e.target.closest(".row-selected") + ) + return; const selectables = document.getElementsByClassName(selectableClass); if (!selectables.length) return; diff --git a/packages/components/viewer/sub-components/viewer-base.js b/packages/components/viewer/sub-components/viewer-base.js index 5b55e9f92f..2e16c09dac 100644 --- a/packages/components/viewer/sub-components/viewer-base.js +++ b/packages/components/viewer/sub-components/viewer-base.js @@ -455,7 +455,7 @@ const ViewerBase = (props) => { document[funcName]("keydown", handleKeydown, true); } if (viewerCore.current) { - viewerCore.current[funcName]("wheel", handleMouseScroll, false); + viewerCore.current[funcName]("wheel", handleMouseScroll, {passive: true}); } }