diff --git a/frontend/src/metadata/metadata-view/components/context-menu/context-menu.css b/frontend/src/metadata/metadata-view/components/context-menu/context-menu.css new file mode 100644 index 0000000000..2c33bf5a80 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/context-menu/context-menu.css @@ -0,0 +1,5 @@ +.sf-metadata-contextmenu { + display: block; + opacity: 1; + box-shadow: 0 0 5px #ccc; +} diff --git a/frontend/src/metadata/metadata-view/components/context-menu/context-menu.jsx b/frontend/src/metadata/metadata-view/components/context-menu/context-menu.jsx new file mode 100644 index 0000000000..dd6e763be0 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/context-menu/context-menu.jsx @@ -0,0 +1,118 @@ +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import PropTypes from 'prop-types'; +import './context-menu.css'; + +const ContextMenu = ({ options, onOptionClick }) => { + const menuRef = useRef(null); + const [visible, setVisible] = useState(false); + const [position, setPosition] = useState({ top: 0, left: 0 }); + + const handleHide = useCallback((event) => { + if (menuRef.current && !menuRef.current.contains(event.target)) { + setVisible(false); + } + }, [menuRef]); + + const handleOptionClick = (event, option) => { + onOptionClick(event, option); + setVisible(false); + }; + + const getMenuPosition = (x = 0, y = 0) => { + let menuStyles = { + top: y, + left: x + }; + if (!menuRef.current) return menuStyles; + + const { innerWidth, innerHeight } = window; + const rect = menuRef.current.getBoundingClientRect(); + + // Calculate the offset of the parent components + const parentRect = menuRef.current.parentElement.getBoundingClientRect(); + const offsetX = parentRect.left; + const offsetY = parentRect.top; + + // Adjust the position based on the offset + menuStyles.top = y - offsetY; + menuStyles.left = x - offsetX; + + const metadataResultFooterHeight = 32; + const contentHeight = innerHeight - metadataResultFooterHeight; + if (y + rect.height > contentHeight) { + menuStyles.top -= rect.height; + } + if (x + rect.width > innerWidth) { + menuStyles.left -= rect.width; + } + if (menuStyles.top < 0) { + menuStyles.top = rect.height < contentHeight ? (contentHeight - rect.height) / 2 : 0; + } + if (menuStyles.left < 0) { + menuStyles.left = rect.width < innerWidth ? (innerWidth - rect.width) / 2 : 0; + } + return menuStyles; + }; + + useEffect(() => { + const handleShow = (event) => { + event.preventDefault(); + if (menuRef.current && menuRef.current.contains(event.target)) return; + + setVisible(true); + + const position = getMenuPosition(event.clientX, event.clientY); + setPosition(position); + }; + + document.addEventListener('contextmenu', handleShow); + + return () => { + document.removeEventListener('contextmenu', handleShow); + }; + }, []); + + useEffect(() => { + if (visible) { + document.addEventListener('mousedown', handleHide); + } else { + document.removeEventListener('mousedown', handleHide); + } + + return () => { + document.removeEventListener('mousedown', handleHide); + }; + }, [visible, handleHide]); + + if (!visible) return null; + + return ( +
+ {options.map((option, index) => ( + + ))} +
+ ); +}; + +ContextMenu.propTypes = { + options: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + }) + ).isRequired, + onOptionClick: PropTypes.func.isRequired, +}; + +export default ContextMenu; diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/body.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/body.js index 42da0b93d9..df3f96c9c9 100644 --- a/frontend/src/metadata/metadata-view/components/table/table-main/records/body.js +++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/body.js @@ -28,7 +28,6 @@ class RecordsBody extends Component { this.state = { startRenderIndex: 0, endRenderIndex: this.getInitEndIndex(props), - isContextMenuShow: false, activeRecords: [], menuPosition: null, selectedPosition: null, @@ -47,7 +46,6 @@ class RecordsBody extends Component { componentDidMount() { this.props.onRef(this); window.sfMetadataBody = this; - document.addEventListener('contextmenu', this.handleContextMenu); } UNSAFE_componentWillReceiveProps(nextProps) { @@ -58,8 +56,6 @@ class RecordsBody extends Component { } componentWillUnmount() { - document.removeEventListener('contextmenu', this.handleContextMenu); - this.clearHorizontalScroll(); this.clearScrollbarTimer(); this.setState = (state, callback) => { @@ -335,6 +331,10 @@ class RecordsBody extends Component { this.props.onCellRangeSelectionUpdated(selectedRange); }; + onCellContextMenu = (event, cell) => { + this.props.onCellContextMenu(event, cell); + }; + /** * When updating the selection by moving the mouse, you need to automatically scroll to expand the visible area * @param {object} selectedRange @@ -414,6 +414,7 @@ class RecordsBody extends Component { onCellMouseMove: this.onCellMouseMove, onDragEnter: this.handleDragEnter, modifyRecord: this.props.modifyRecord, + onCellContextMenu: this.onCellContextMenu }; return this.cellMetaData; }; @@ -504,7 +505,6 @@ class RecordsBody extends Component { }; render() { - // const { isContextMenuShow, menuPosition, activeRecords } = this.state; return (
@@ -605,6 +605,7 @@ RecordsBody.propTypes = { getCopiedRecordsAndColumnsFromRange: PropTypes.func, openDownloadFilesDialog: PropTypes.func, cacheDownloadFilesProps: PropTypes.func, + onCellContextMenu: PropTypes.func, }; export default RecordsBody; diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/group-body/index.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-body/index.js index 5448053cf1..2386f7587c 100644 --- a/frontend/src/metadata/metadata-view/components/table/table-main/records/group-body/index.js +++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-body/index.js @@ -305,6 +305,7 @@ class GroupBody extends Component { onCellMouseMove: this.onCellMouseMove, onDragEnter: this.handleDragEnter, modifyRecord: this.props.modifyRecord, + onCellContextMenu: this.onCellContextMenu, }; return this.cellMetaData; }; @@ -445,6 +446,10 @@ class GroupBody extends Component { this.selectUpdate(cellPosition, false, this.updateViewableArea); }; + onCellContextMenu = (event, cell) => { + this.props.onCellContextMenu(event, cell); + }; + onWindowMouseUp = (event) => { window.removeEventListener('mouseup', this.onWindowMouseUp); if (isShiftKeyDown(event)) return; @@ -966,6 +971,7 @@ GroupBody.propTypes = { getCopiedRecordsAndColumnsFromRange: PropTypes.func, openDownloadFilesDialog: PropTypes.func, cacheDownloadFilesProps: PropTypes.func, + onCellContextMenu: PropTypes.func, }; export default GroupBody; diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/index.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/index.js index a1edf1b074..6d66a1bfd2 100644 --- a/frontend/src/metadata/metadata-view/components/table/table-main/records/index.js +++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/index.js @@ -15,6 +15,10 @@ import RecordMetrics from '../../../../utils/record-metrics'; import { isShiftKeyDown } from '../../../../utils/keyboard-utils'; import { getVisibleBoundaries } from '../../../../utils/viewport'; import { getColOverScanEndIdx, getColOverScanStartIdx } from '../../../../utils/grid'; +import TextTranslation from '../../../../../../utils/text-translation'; +import { Utils } from '../../../../../../utils/utils'; +import { siteRoot } from '../../../../../../utils/constants'; +import ContextMenu from '../../../context-menu/context-menu'; class Records extends Component { @@ -38,10 +42,16 @@ class Records extends Component { topLeft: this.initPosition, bottomRight: this.initPosition, }, + selectedPosition: this.initPosition, ...initHorizontalScrollState, }; this.isWindows = isWindowsBrowser(); this.isWebkit = isWebkitBrowser(); + this.baseURI = ''; + this.contextMenuOptions = [ + { label: TextTranslation.OPEN_FILE_IN_NEW_TAB.value, value: 'openFileInNewTab' }, + { label: TextTranslation.OPEN_PARENT_FOLDER.value, value: 'openParentFolder' }, + ]; } componentDidMount() { @@ -587,6 +597,56 @@ class Records extends Component { this.setState(scrollState); }; + onOpenFileInNewTab = () => { + const { isGroupView, recordGetterByIndex } = this.props; + const { groupRecordIndex, rowIdx } = this.state.selectedPosition; + const record = recordGetterByIndex({ isGroupView, groupRecordIndex, recordIndex: rowIdx }); + const repoID = window.sfMetadataStore.repoId; + let url; + if (record._is_dir) { + url = `${this.baseURI}${record._parent_dir === '/' ? '' : record._parent_dir}/${record._name}`; + } else { + url = `${siteRoot}lib/${repoID}/file${Utils.encodePath(record._parent_dir + '/' + record._name)}`; + } + + window.open(url, '_blank'); + }; + + onOpenParentFolder = (event) => { + event.preventDefault(); + event.stopPropagation(); + + const { isGroupView, recordGetterByIndex } = this.props; + const { groupRecordIndex, rowIdx } = this.state.selectedPosition; + const record = recordGetterByIndex({ isGroupView, groupRecordIndex, recordIndex: rowIdx }); + const url = `${this.baseURI}${record._parent_dir}`; + + window.open(url, '_blank'); + }; + + onOptionClick = (event, option) => { + + const handlers = { + openFileInNewTab: this.onOpenFileInNewTab.bind(this), + openParentFolder: this.onOpenParentFolder.bind(this), + }; + + const handler = handlers[option.value]; + if (handler) { + handler(event); + } + }; + + onCellContextMenu = (event, cell) => { + const url = new URL(event.target.baseURI); + url.search = ''; + this.baseURI = url.toString(); + + this.setState({ + selectedPosition: cell, + }); + }; + renderRecordsBody = ({ containerWidth }) => { const { recordMetrics, columnMetrics, colOverScanStartIdx, colOverScanEndIdx } = this.state; const { @@ -606,6 +666,7 @@ class Records extends Component { setRecordsScrollLeft: this.setScrollLeft, hasSelectedCell: this.hasSelectedCell, cacheScrollTop: this.storeScrollTop, + onCellContextMenu: this.onCellContextMenu, }; if (this.props.isGroupView) { return ( @@ -667,6 +728,10 @@ class Records extends Component { /> {this.renderRecordsBody({ containerWidth })}
+ {this.isWindows && this.isWebkit && ( { + event.preventDefault(); + + const cell = { idx: column.idx, groupRecordIndex, rowIdx: recordIndex }; + if (!isFunction(cellMetaData.onCellContextMenu)) return; + cellMetaData.onCellContextMenu(event, cell,); + cellMetaData.onCellClick(cell, event); + }, [cellMetaData, column, groupRecordIndex, recordIndex]); + const getEvents = useCallback(() => { return { onClick: onCellClick, @@ -110,9 +119,10 @@ const Cell = React.memo(({ onMouseEnter: onCellMouseEnter, onMouseMove: onCellMouseMove, onMouseLeave: onCellMouseLeave, - onDragOver: onDragOver + onDragOver: onDragOver, + onContextMenu: onCellContextMenu, }; - }, [onCellClick, onCellDoubleClick, onCellMouseDown, onCellMouseEnter, onCellMouseMove, onCellMouseLeave, onDragOver]); + }, [onCellClick, onCellDoubleClick, onCellMouseDown, onCellMouseEnter, onCellMouseMove, onCellMouseLeave, onDragOver, onCellContextMenu]); const getOldRowData = useCallback((originalOldCellValue) => { const { key: columnKey, name: columnName } = column; diff --git a/frontend/src/utils/text-translation.js b/frontend/src/utils/text-translation.js index 5241f610db..62c01f9471 100644 --- a/frontend/src/utils/text-translation.js +++ b/frontend/src/utils/text-translation.js @@ -19,6 +19,8 @@ const TextTranslation = { 'PERMISSION': { key: 'Permission', value: gettext('Permission') }, 'DETAILS': { key: 'Details', value: gettext('Details') }, 'OPEN_VIA_CLIENT': { key: 'Open via Client', value: gettext('Open via Client') }, + 'OPEN_FILE_IN_NEW_TAB': { key: 'Open file in new tab', value: gettext('Open file in new tab') }, + 'OPEN_PARENT_FOLDER': { key: 'Open parent folder', value: gettext('Open parent folder') }, 'LOCK': { key: 'Lock', value: gettext('Lock') }, 'UNLOCK': { key: 'Unlock', value: gettext('Unlock') }, 'FREEZE_DOCUMENT': { key: 'Freeze Document', value: gettext('Freeze Document') },