import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; import { siteRoot, username, enableSeadoc } from '../../utils/constants'; import { Utils } from '../../utils/utils'; import { seafileAPI } from '../../utils/seafile-api'; import URLDecorator from '../../utils/url-decorator'; import Loading from '../loading'; import ModalPortal from '../modal-portal'; import ImageDialog from '../../components/dialog/image-dialog'; import DirentGridItem from '../../components/dirent-grid-view/dirent-grid-item'; import ContextMenu from '../context-menu/context-menu'; import { hideMenu, showMenu } from '../context-menu/actions'; import TextTranslation from '../../utils/text-translation'; import MoveDirentDialog from '../dialog/move-dirent-dialog'; import CopyDirentDialog from '../dialog/copy-dirent-dialog'; import ShareDialog from '../dialog/share-dialog'; import ZipDownloadDialog from '../dialog/zip-download-dialog'; import EditFileTagDialog from '../dialog/edit-filetag-dialog'; import Rename from '../../components/dialog/rename-dirent'; import CreateFile from '../dialog/create-file-dialog'; import CreateFolder from '../dialog/create-folder-dialog'; import LibSubFolderPermissionDialog from '../dialog/lib-sub-folder-permission-dialog'; import toaster from '../toast'; import '../../css/grid-view.css'; const propTypes = { path: PropTypes.string.isRequired, repoID: PropTypes.string.isRequired, currentRepoInfo: PropTypes.object, direntList: PropTypes.array.isRequired, fullDirentList: PropTypes.array, selectedDirentList: PropTypes.array.isRequired, onSelectedDirentListUpdate: PropTypes.func.isRequired, onAddFile: PropTypes.func, onItemDelete: PropTypes.func, onItemCopy: PropTypes.func.isRequired, onItemConvert: PropTypes.func.isRequired, onItemMove: PropTypes.func.isRequired, onItemsMove: PropTypes.func.isRequired, onItemsCopy: PropTypes.func.isRequired, onItemsDelete: PropTypes.func.isRequired, onRenameNode: PropTypes.func.isRequired, onItemClick: PropTypes.func.isRequired, isDirentListLoading: PropTypes.bool.isRequired, isGroupOwnedRepo: PropTypes.bool.isRequired, userPerm: PropTypes.string, // current path's user permission enableDirPrivateShare: PropTypes.bool.isRequired, updateDirent: PropTypes.func.isRequired, isDirentDetailShow: PropTypes.bool.isRequired, onGridItemClick: PropTypes.func, repoTags: PropTypes.array.isRequired, onFileTagChanged: PropTypes.func, onAddFolder: PropTypes.func.isRequired, showDirentDetail: PropTypes.func.isRequired, onItemRename: PropTypes.func.isRequired, posX: PropTypes.number, posY: PropTypes.number, dirent: PropTypes.object, getMenuContainerSize: PropTypes.func, }; const DIRENT_GRID_CONTAINER_MENU_ID = 'dirent-grid-container-menu'; const GRID_ITEM_CONTEXTMENU_ID = 'grid-item-contextmenu'; const DIRENTS_MENU_ID = 'dirents-menu'; class DirentGridView extends React.Component { constructor(props) { super(props); this.state = { isImagePopupOpen: false, imageItems: [], imageIndex: 0, // onmenuClick isShareDialogShow: false, isMoveDialogShow: false, isCopyDialogShow: false, isEditFileTagShow: false, isZipDialogOpen: false, isRenameDialogShow: false, isCreateFolderDialogShow: false, isCreateFileDialogShow: false, fileType: '', isPermissionDialogOpen: false, isMultipleOperation: true, isGridItemFreezed: false, activeDirent: null, downloadItems: [], startPoint: { x: 0, y: 0 }, endPoint: { x: 0, y: 0 }, selectedItemsList: [], isSelecting: false, isMouseDown: false, autoScrollInterval: null, }; this.containerRef = React.createRef(); this.isRepoOwner = props.currentRepoInfo.owner_email === username; } componentDidMount() { window.addEventListener('mouseup', this.onGlobalMouseUp); } componentWillUnmount() { window.removeEventListener('mouseup', this.onGlobalMouseUp); } onGridContainerMouseDown = (event) => { if (event.button === 2) { return; } else if (event.button === 0) { hideMenu(); this.props.onGridItemClick(null); if (event.target.closest('img') || event.target.closest('div.grid-file-name')) return; const containerBounds = this.containerRef.current.getBoundingClientRect(); this.setState({ startPoint: { x: event.clientX - containerBounds.left, y: event.clientY - containerBounds.top }, endPoint: { x: event.clientX - containerBounds.left, y: event.clientY - containerBounds.top }, selectedItemsList: [], isSelecting: false, isMouseDown: true, }); } }; onSelectMouseMove = (e) => { if (!this.state.isMouseDown) return; const containerBounds = this.containerRef.current.getBoundingClientRect(); const endPoint = { x: e.clientX - containerBounds.left, y: e.clientY - containerBounds.top }; // Constrain endPoint within the container bounds endPoint.x = Math.max(0, Math.min(endPoint.x, containerBounds.width)); endPoint.y = Math.max(0, Math.min(endPoint.y, containerBounds.height)); // Check if the mouse has moved a certain distance to start selection, prevents accidental selections const distance = Math.sqrt( Math.pow(endPoint.x - this.state.startPoint.x, 2) + Math.pow(endPoint.y - this.state.startPoint.y, 2) ); if (distance > 5) { this.setState({ isSelecting: true, endPoint: endPoint, }, () => { this.determineSelectedItems(); this.autoScroll(e.clientY); const selectedItemNames = new Set(this.state.selectedItemsList.map(item => item.lastChild.lastChild.title)); const filteredDirentList = this.props.direntList .filter(dirent => selectedItemNames.has(dirent.name)) .map(dirent => ({ ...dirent, isSelected: true })); this.props.onSelectedDirentListUpdate(filteredDirentList); }); } }; onGlobalMouseUp = () => { if (!this.state.isMouseDown) return; clearInterval(this.state.autoScrollInterval); this.setState({ isSelecting: false, isMouseDown: false, autoScrollInterval: null, }); }; determineSelectedItems = () => { const { startPoint, endPoint } = this.state; const container = this.containerRef.current; const items = container.querySelectorAll('.grid-item'); const selectionRect = { left: Math.min(startPoint.x, endPoint.x), top: Math.min(startPoint.y, endPoint.y), right: Math.max(startPoint.x, endPoint.x), bottom: Math.max(startPoint.y, endPoint.y), }; const newSelectedItemsList = []; items.forEach(item => { const bounds = item.getBoundingClientRect(); const relativeBounds = { left: bounds.left - container.getBoundingClientRect().left, top: bounds.top - container.getBoundingClientRect().top, right: bounds.right - container.getBoundingClientRect().left, bottom: bounds.bottom - container.getBoundingClientRect().top, }; // Check if the element is within the selection box's bounds if (relativeBounds.left < selectionRect.right && relativeBounds.right > selectionRect.left && relativeBounds.top < selectionRect.bottom && relativeBounds.bottom > selectionRect.top) { newSelectedItemsList.push(item); } }); this.setState({ selectedItemsList: newSelectedItemsList }); }; autoScroll = (mouseY) => { const container = this.containerRef.current; const containerBounds = container.getBoundingClientRect(); const scrollSpeed = 10; const scrollThreshold = 20; const updateEndPoint = () => { const endPoint = { x: this.state.endPoint.x, y: mouseY - containerBounds.top + container.scrollTop, }; this.setState({ endPoint }, () => { this.determineSelectedItems(); }); }; if (mouseY < containerBounds.top + scrollThreshold) { // Scroll Up if (!this.state.autoScrollInterval) { const interval = setInterval(() => { container.scrollTop -= scrollSpeed; updateEndPoint(); }, 50); this.setState({ autoScrollInterval: interval }); } } else if (mouseY > containerBounds.bottom - scrollThreshold) { // Scroll Down if (!this.state.autoScrollInterval) { const interval = setInterval(() => { container.scrollTop += scrollSpeed; updateEndPoint(); }, 50); this.setState({ autoScrollInterval: interval }); } } else { clearInterval(this.state.autoScrollInterval); this.setState({ autoScrollInterval: null }); } }; onCreateFileToggle = (fileType) => { this.setState({ isCreateFileDialogShow: !this.state.isCreateFileDialogShow, fileType: fileType || '' }); }; onGridItemClick = (dirent, event) => { hideMenu(); if (this.state.activeDirent !== dirent) { this.setState({ activeDirent: dirent }); } this.props.onGridItemClick(dirent, event); }; onMoveToggle = () => { this.setState({ isMoveDialogShow: !this.state.isMoveDialogShow }); }; onCopyToggle = () => { this.setState({ isCopyDialogShow: !this.state.isCopyDialogShow }); }; onAddFolder = (dirPath) => { this.setState({ isCreateFolderDialogShow: false }); this.props.onAddFolder(dirPath); }; onItemShare = (e) => { e.nativeEvent.stopImmediatePropagation(); // for document event this.setState({ isShareDialogShow: !this.state.isShareDialogShow }); }; closeSharedDialog = () => { this.setState({ isShareDialogShow: !this.state.isShareDialogShow }); }; onItemDelete = (currentObject, e) => { e.nativeEvent.stopImmediatePropagation(); // for document event if (this.props.selectedDirentList.length === 1) { this.props.onItemDelete(currentObject); return; } else { this.props.onItemsDelete(); } }; onItemConvert = (currentObject, e, dstType) => { e.nativeEvent.stopImmediatePropagation(); // for document event this.props.onItemConvert(currentObject, dstType); }; exportDocx = () => { const serviceUrl = window.app.config.serviceURL; let repoID = this.props.repoID; let filePath = this.getDirentPath(this.props.dirent); let exportToDocxUrl = serviceUrl + '/repo/sdoc_export_to_docx/' + repoID + '/?file_path=' + filePath; window.location.href = exportToDocxUrl; }; onMenuItemClick = (operation, currentObject, event) => { hideMenu(); switch (operation) { case 'Download': this.onItemsDownload(); break; case 'Share': this.onItemShare(event); break; case 'Delete': this.onItemDelete(currentObject, event); break; case 'Rename': this.onItemRenameToggle(); break; case 'Move': this.onItemMoveToggle(); break; case 'Copy': this.onItemCopyToggle(); break; case 'Unfreeze Document': this.onUnlockItem(currentObject); break; case 'Freeze Document': this.onFreezeDocument(currentObject); break; case 'Convert to Markdown': this.onItemConvert(currentObject, event, 'markdown'); break; case 'Convert to docx': this.onItemConvert(currentObject, event, 'docx'); break; case 'Export docx': this.exportDocx(); break; case 'Convert to sdoc': this.onItemConvert(currentObject, event, 'sdoc'); break; case 'Tags': this.onEditFileTagToggle(); break; case 'Permission': this.onPermissionItem(); break; case 'Unlock': this.onUnlockItem(currentObject); break; case 'Lock': this.onLockItem(currentObject); break; case 'History': this.onHistory(currentObject); break; case 'New Folder': this.onCreateFolderToggle(currentObject); break; case 'New File': this.onCreateFileToggle(''); break; case 'New Markdown File': this.onCreateFileToggle('.md'); break; case 'New Excel File': this.onCreateFileToggle('.xlsx'); break; case 'New PowerPoint File': this.onCreateFileToggle('.pptx'); break; case 'New Word File': this.onCreateFileToggle('.docx'); break; case 'New SeaDoc File': this.onCreateFileToggle('.sdoc'); break; case 'Access Log': this.onAccessLog(currentObject); break; case 'Properties': this.props.showDirentDetail('info'); break; case 'Open via Client': this.onOpenViaClient(currentObject); break; default: break; } }; onDirentsMenuItemClick = (operation) => { switch (operation) { case 'Move': this.onMoveToggle(); break; case 'Copy': this.onCopyToggle(); break; case 'Download': this.onItemsDownload(); break; case 'Delete': this.props.onItemsDelete(); break; default: break; } hideMenu(); }; onEditFileTagToggle = () => { this.setState({ isEditFileTagShow: !this.state.isEditFileTagShow }); }; onFileTagChanged = () => { let dirent = this.state.activeDirent ? this.state.activeDirent : ''; let direntPath = Utils.joinPath(this.props.path, dirent.name); this.props.onFileTagChanged(dirent, direntPath); }; getDirentPath = (dirent) => { let path = this.props.path; return path === '/' ? path + dirent.name : path + '/' + dirent.name; }; closeZipDialog = () => { this.setState({ isZipDialogOpen: false }); }; onItemsDownload = () => { let { path, repoID, selectedDirentList } = this.props; if (selectedDirentList.length === 1 && !selectedDirentList[0].isDir()) { let direntPath = Utils.joinPath(path, selectedDirentList[0].name); let url = URLDecorator.getUrl({ type: 'download_file_url', repoID: repoID, filePath: direntPath }); location.href = url; return; } let selectedDirentNames = selectedDirentList.map(dirent => { return dirent.name; }); this.setState({ isZipDialogOpen: true, downloadItems: selectedDirentNames }); }; onCreateFolderToggle = () => { this.setState({ isCreateFolderDialogShow: !this.state.isCreateFolderDialogShow, }); }; onItemRenameToggle = () => { this.setState({ isRenameDialogShow: !this.state.isRenameDialogShow, }); }; onItemMoveToggle = () => { this.setState({ isMoveDialogShow: !this.state.isMoveDialogShow }); }; onItemCopyToggle = () => { this.setState({ isCopyDialogShow: !this.state.isCopyDialogShow }); }; onPermissionItem = () => { this.setState({ isPermissionDialogOpen: !this.state.isPermissionDialogOpen }); }; onLockItem = (currentObject) => { let repoID = this.props.repoID; let filePath = this.getDirentPath(currentObject); seafileAPI.lockfile(repoID, filePath).then(() => { this.props.updateDirent(currentObject, 'is_locked', true); this.props.updateDirent(currentObject, 'locked_by_me', true); let lockName = username.split('@'); this.props.updateDirent(currentObject, 'lock_owner_name', lockName[0]); }).catch(error => { let errMessage = Utils.getErrorMsg(error); toaster.danger(errMessage); }); }; onFreezeDocument = (currentObject) => { let repoID = this.props.repoID; let filePath = this.getDirentPath(currentObject); seafileAPI.lockfile(repoID, filePath, -1).then(() => { this.props.updateDirent(currentObject, 'is_freezed', true); this.props.updateDirent(currentObject, 'is_locked', true); this.props.updateDirent(currentObject, 'locked_by_me', true); let lockName = username.split('@'); this.props.updateDirent(currentObject, 'lock_owner_name', lockName[0]); }).catch(error => { let errMessage = Utils.getErrorMsg(error); toaster.danger(errMessage); }); }; onUnlockItem = (currentObject) => { let repoID = this.props.repoID; let filePath = this.getDirentPath(currentObject); seafileAPI.unlockfile(repoID, filePath).then(() => { this.props.updateDirent(currentObject, 'is_locked', false); this.props.updateDirent(currentObject, 'locked_by_me', false); this.props.updateDirent(currentObject, 'lock_owner_name', ''); }).catch(error => { let errMessage = Utils.getErrorMsg(error); toaster.danger(errMessage); }); }; onHistory = (currentObject) => { let repoID = this.props.repoID; let filePath = this.getDirentPath(currentObject); let url = URLDecorator.getUrl({ type: 'file_revisions', repoID: repoID, filePath: filePath }); location.href = url; }; onAccessLog = (currentObject) => { let filePath = this.getDirentPath(currentObject); let path = siteRoot + 'repo/file-access/' + this.props.repoID + '/?p=' + encodeURIComponent(filePath) ; window.open(path); }; onOpenViaClient = (currentObject) => { let repoID = this.props.repoID; let filePath = this.getDirentPath(currentObject); let url = URLDecorator.getUrl({ type: 'open_via_client', repoID: repoID, filePath: filePath }); location.href = url; }; onItemRename = (newName) => { this.props.onItemRename(this.state.activeDirent, newName); }; prepareImageItem = (item) => { const name = item.name; const repoID = this.props.repoID; const path = Utils.encodePath(Utils.joinPath(this.props.path, name)); const src = `${siteRoot}repo/${repoID}/raw${path}`; return { 'name': name, 'url': `${siteRoot}lib/${repoID}/file${path}`, 'src': src }; }; showImagePopup = (curItem) => { let items = this.props.fullDirentList.filter((item) => { return Utils.imageCheck(item.name); }); const imageItems = items.map((item) => { return this.prepareImageItem(item); }); this.setState({ isImagePopupOpen: true, imageItems: imageItems, imageIndex: items.indexOf(curItem) }); }; closeImagePopup = () => { this.setState({ isImagePopupOpen: false }); }; moveToPrevImage = () => { const imageItemsLength = this.state.imageItems.length; this.setState((prevState) => ({ imageIndex: (prevState.imageIndex + imageItemsLength - 1) % imageItemsLength })); }; moveToNextImage = () => { const imageItemsLength = this.state.imageItems.length; this.setState((prevState) => ({ imageIndex: (prevState.imageIndex + 1) % imageItemsLength })); }; checkDuplicatedName = (newName) => { return Utils.checkDuplicatedNameInList(this.props.direntList, newName); }; onGridItemMouseDown = (event) => { event.stopPropagation(); if (event.button === 2) { return; } }; gridContainerClick = (event) => { event.stopPropagation(); hideMenu(); }; onGridContainerContextMenu = (event) => { event.preventDefault(); const hasCustomPermission = (action) => { const { isCustomPermission, customPermission } = Utils.getUserPermission(this.props.userPerm); if (isCustomPermission) { return customPermission.permission[action]; } return true; }; if (!['admin', 'rw'].includes(this.props.userPerm)) return; const { NEW_FOLDER, NEW_FILE, NEW_MARKDOWN_FILE, NEW_EXCEL_FILE, NEW_POWERPOINT_FILE, NEW_WORD_FILE, NEW_SEADOC_FILE } = TextTranslation; let direntsContainerMenuList = [ NEW_FOLDER, NEW_FILE, 'Divider', ]; const { currentRepoInfo, selectedDirentList } = this.props; if (enableSeadoc && !currentRepoInfo.encrypted) { direntsContainerMenuList.push(NEW_SEADOC_FILE); } direntsContainerMenuList.push( NEW_MARKDOWN_FILE, NEW_EXCEL_FILE, NEW_POWERPOINT_FILE, NEW_WORD_FILE ); if (selectedDirentList.length === 0) { if (!hasCustomPermission('create')) return; this.handleContextClick(event, DIRENT_GRID_CONTAINER_MENU_ID, direntsContainerMenuList); } else if (selectedDirentList.length === 1) { if (!this.state.activeDirent) { let menuList = Utils.getDirentOperationList(this.isRepoOwner, currentRepoInfo, selectedDirentList[0], true); this.handleContextClick(event, GRID_ITEM_CONTEXTMENU_ID, menuList, selectedDirentList[0]); } else { this.props.onGridItemClick(null); event.persist(); if (!hasCustomPermission('modify')) return; setTimeout(() => { this.handleContextClick(event, DIRENT_GRID_CONTAINER_MENU_ID, direntsContainerMenuList); }, 0); } } else { let menuList = []; if (!hasCustomPermission('modify') && !hasCustomPermission('copy') && !hasCustomPermission('download') && !hasCustomPermission('delete')) return; ['move', 'copy', 'download', 'delete'].forEach(action => { if (hasCustomPermission(action)) { menuList.push(TextTranslation[action.toUpperCase()]); } }); this.handleContextClick(event, DIRENTS_MENU_ID, menuList); } }; onGridItemContextMenu = (event, dirent) => { if (this.props.selectedDirentList.length > 1) return; // Display menu items according to the current dirent permission const menuList = this.getDirentItemMenuList(dirent, true); const id = 'grid-item-contextmenu'; this.handleContextClick(event, id, menuList, dirent); if (this.props.direntList.filter(item => item.isSelected).length > 1) return; this.props.onGridItemClick && this.props.onGridItemClick(dirent); }; handleContextClick = (event, id, menuList, currentObject = null) => { event.preventDefault(); event.stopPropagation(); let x = event.clientX || (event.touches && event.touches[0].pageX); let y = event.clientY || (event.touches && event.touches[0].pageY); if (this.props.posX) { x -= this.props.posX; } if (this.props.posY) { y -= this.props.posY; } hideMenu(); this.setState({ activeDirent: currentObject }); let showMenuConfig = { id: id, position: { x, y }, target: event.target, currentObject: currentObject, menuList: menuList, }; if (menuList.length === 0) { return; } showMenu(showMenuConfig); }; getDirentItemMenuList = (dirent, isContextmenu) => { const isRepoOwner = this.isRepoOwner; const currentRepoInfo = this.props.currentRepoInfo; return Utils.getDirentOperationList(isRepoOwner, currentRepoInfo, dirent, isContextmenu); }; renderSelectionBox = () => { const { startPoint, endPoint } = this.state; if (!this.state.isSelecting) return null; const left = Math.min(startPoint.x, endPoint.x); const top = Math.min(startPoint.y, endPoint.y); const width = Math.abs(startPoint.x - endPoint.x); const height = Math.abs(startPoint.y - endPoint.y); return (
); }; render() { let { direntList, selectedDirentList, path } = this.props; let dirent = this.state.activeDirent ? this.state.activeDirent : ''; let direntPath = Utils.joinPath(path, dirent.name); if (this.props.isDirentListLoading) { return (