first commit
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import { ReactNode, useEffect, useRef } from 'react'
|
||||
import classNames from 'classnames'
|
||||
import scrollIntoViewIfNeeded from 'scroll-into-view-if-needed'
|
||||
|
||||
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
|
||||
import { useFileTreeMainContext } from '../../contexts/file-tree-main'
|
||||
import { useDraggable } from '../../contexts/file-tree-draggable'
|
||||
|
||||
import FileTreeItemName from './file-tree-item-name'
|
||||
import FileTreeItemMenu from './file-tree-item-menu'
|
||||
import { useFileTreeSelectable } from '../../contexts/file-tree-selectable'
|
||||
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
|
||||
import { useDragDropManager } from 'react-dnd'
|
||||
|
||||
function FileTreeItemInner({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
isSelected,
|
||||
icons,
|
||||
}: {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
isSelected: boolean
|
||||
icons?: ReactNode
|
||||
}) {
|
||||
const { fileTreeReadOnly } = useFileTreeData()
|
||||
const { setContextMenuCoords } = useFileTreeMainContext()
|
||||
const { isRenaming } = useFileTreeActionable()
|
||||
|
||||
const { selectedEntityIds } = useFileTreeSelectable()
|
||||
|
||||
const hasMenu =
|
||||
!fileTreeReadOnly && isSelected && selectedEntityIds.size === 1
|
||||
|
||||
const { dragRef, setIsDraggable } = useDraggable(id)
|
||||
|
||||
const dragDropItem = useDragDropManager().getMonitor().getItem()
|
||||
|
||||
const itemRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const item = itemRef.current
|
||||
if (isSelected && item) {
|
||||
// we're delaying scrolling due to a race condition with other elements,
|
||||
// mainly the Outline, being resized inside the same panel, causing the
|
||||
// FileTree to have its viewport shrinked after the selected item is
|
||||
// scrolled into the view, hiding it again.
|
||||
// See `left-pane-resize-all` in `file-tree-controller` for more information.
|
||||
setTimeout(() => {
|
||||
if (item) {
|
||||
scrollIntoViewIfNeeded(item, {
|
||||
scrollMode: 'if-needed',
|
||||
})
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}, [isSelected, itemRef])
|
||||
|
||||
function handleContextMenu(ev: React.MouseEvent<HTMLDivElement>) {
|
||||
ev.preventDefault()
|
||||
|
||||
setContextMenuCoords({
|
||||
top: ev.pageY,
|
||||
left: ev.pageX,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('entity', {
|
||||
'file-tree-entity-dragging': dragDropItem?.draggedEntityIds?.has(id),
|
||||
})}
|
||||
role="presentation"
|
||||
ref={dragRef}
|
||||
draggable={!isRenaming}
|
||||
onContextMenu={handleContextMenu}
|
||||
data-file-id={id}
|
||||
data-file-type={type}
|
||||
>
|
||||
<div
|
||||
className="entity-name entity-name-react"
|
||||
role="presentation"
|
||||
ref={itemRef}
|
||||
>
|
||||
{icons}
|
||||
<FileTreeItemName
|
||||
name={name}
|
||||
isSelected={isSelected}
|
||||
setIsDraggable={setIsDraggable}
|
||||
/>
|
||||
{hasMenu ? <FileTreeItemMenu id={id} name={name} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileTreeItemInner
|
@@ -0,0 +1,94 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import * as eventTracking from '../../../../infrastructure/event-tracking'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
|
||||
import {
|
||||
DropdownDivider,
|
||||
DropdownItem,
|
||||
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
|
||||
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
|
||||
|
||||
function FileTreeItemMenuItems() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
canRename,
|
||||
canDelete,
|
||||
canCreate,
|
||||
startRenaming,
|
||||
startDeleting,
|
||||
startCreatingFolder,
|
||||
startCreatingDocOrFile,
|
||||
startUploadingDocOrFile,
|
||||
downloadPath,
|
||||
selectedFileName,
|
||||
} = useFileTreeActionable()
|
||||
|
||||
const { owner } = useProjectContext()
|
||||
|
||||
const downloadWithAnalytics = useCallback(() => {
|
||||
// we are only interested in downloads of bib files WRT analytics, for the purposes of promoting the tpr integrations
|
||||
if (selectedFileName?.endsWith('.bib')) {
|
||||
eventTracking.sendMB('download-bib-file', { projectOwner: owner._id })
|
||||
}
|
||||
}, [selectedFileName, owner])
|
||||
|
||||
const createWithAnalytics = useCallback(() => {
|
||||
eventTracking.sendMB('new-file-click', { location: 'file-menu' })
|
||||
startCreatingDocOrFile()
|
||||
}, [startCreatingDocOrFile])
|
||||
|
||||
const uploadWithAnalytics = useCallback(() => {
|
||||
eventTracking.sendMB('upload-click', { location: 'file-menu' })
|
||||
startUploadingDocOrFile()
|
||||
}, [startUploadingDocOrFile])
|
||||
|
||||
return (
|
||||
<>
|
||||
{canRename ? (
|
||||
<li role="none">
|
||||
<DropdownItem onClick={startRenaming}>{t('rename')}</DropdownItem>
|
||||
</li>
|
||||
) : null}
|
||||
{downloadPath ? (
|
||||
<li role="none">
|
||||
<DropdownItem
|
||||
href={downloadPath}
|
||||
onClick={downloadWithAnalytics}
|
||||
download={selectedFileName ?? undefined}
|
||||
>
|
||||
{t('download')}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
) : null}
|
||||
{canDelete ? (
|
||||
<li role="none">
|
||||
<DropdownItem onClick={startDeleting}>{t('delete')}</DropdownItem>
|
||||
</li>
|
||||
) : null}
|
||||
{canCreate ? (
|
||||
<>
|
||||
<DropdownDivider />
|
||||
<li role="none">
|
||||
<DropdownItem onClick={createWithAnalytics}>
|
||||
{t('new_file')}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
<li role="none">
|
||||
<DropdownItem onClick={startCreatingFolder}>
|
||||
{t('new_folder')}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
<li role="none">
|
||||
<DropdownItem onClick={uploadWithAnalytics}>
|
||||
{t('upload')}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileTreeItemMenuItems
|
@@ -0,0 +1,43 @@
|
||||
import { useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useFileTreeMainContext } from '../../contexts/file-tree-main'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
|
||||
function FileTreeItemMenu({ id, name }: { id: string; name: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { contextMenuCoords, setContextMenuCoords } = useFileTreeMainContext()
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const isMenuOpen = Boolean(contextMenuCoords)
|
||||
|
||||
function handleClick(event: React.MouseEvent) {
|
||||
event.stopPropagation()
|
||||
if (!contextMenuCoords && menuButtonRef.current) {
|
||||
const target = menuButtonRef.current.getBoundingClientRect()
|
||||
setContextMenuCoords({
|
||||
top: target.top + target.height / 2,
|
||||
left: target.right,
|
||||
})
|
||||
} else {
|
||||
setContextMenuCoords(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="menu-button btn-group">
|
||||
<button
|
||||
className="entity-menu-toggle btn btn-sm"
|
||||
id={`menu-button-${id}`}
|
||||
onClick={handleClick}
|
||||
ref={menuButtonRef}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={isMenuOpen}
|
||||
aria-label={t('open_action_menu', { name })}
|
||||
>
|
||||
<MaterialIcon type="more_vert" accessibilityLabel={t('menu')} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileTreeItemMenu
|
@@ -0,0 +1,133 @@
|
||||
import { useState, useEffect, RefObject } from 'react'
|
||||
import { useRefWithAutoFocus } from '../../../../shared/hooks/use-ref-with-auto-focus'
|
||||
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
|
||||
|
||||
function FileTreeItemName({
|
||||
name,
|
||||
isSelected,
|
||||
setIsDraggable,
|
||||
}: {
|
||||
name: string
|
||||
isSelected: boolean
|
||||
setIsDraggable: (isDraggable: boolean) => void
|
||||
}) {
|
||||
const { isRenaming, startRenaming, finishRenaming, error, cancel } =
|
||||
useFileTreeActionable()
|
||||
|
||||
const isRenamingEntity = isRenaming && isSelected && !error
|
||||
|
||||
useEffect(() => {
|
||||
setIsDraggable(!isRenamingEntity)
|
||||
}, [setIsDraggable, isRenamingEntity])
|
||||
|
||||
if (isRenamingEntity) {
|
||||
return (
|
||||
<InputName
|
||||
initialValue={name}
|
||||
finishRenaming={finishRenaming}
|
||||
cancel={cancel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<DisplayName
|
||||
name={name}
|
||||
isSelected={isSelected}
|
||||
startRenaming={startRenaming}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DisplayName({
|
||||
name,
|
||||
isSelected,
|
||||
startRenaming,
|
||||
}: {
|
||||
name: string
|
||||
isSelected: boolean
|
||||
startRenaming: () => void
|
||||
}) {
|
||||
const [clicksInSelectedCount, setClicksInSelectedCount] = useState(0)
|
||||
|
||||
function onClick() {
|
||||
setClicksInSelectedCount(clicksInSelectedCount + 1)
|
||||
if (!isSelected) setClicksInSelectedCount(0)
|
||||
}
|
||||
|
||||
function onDoubleClick() {
|
||||
// only start renaming if the button got two or more clicks while the item
|
||||
// was selected. This is to prevent starting a rename on an unselected item.
|
||||
// When the item is being selected it can trigger a loss of focus which
|
||||
// causes UI problems.
|
||||
if (clicksInSelectedCount < 2) return
|
||||
startRenaming()
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className="item-name-button"
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
<span>{name}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function InputName({
|
||||
initialValue,
|
||||
finishRenaming,
|
||||
cancel,
|
||||
}: {
|
||||
initialValue: string
|
||||
finishRenaming: (value: string) => void
|
||||
cancel: () => void
|
||||
}) {
|
||||
const [value, setValue] = useState(initialValue)
|
||||
|
||||
// The react-bootstrap Dropdown re-focuses on the Dropdown.Toggle
|
||||
// after a menu item is clicked, following the ARIA authoring practices:
|
||||
// https://www.w3.org/TR/wai-aria-practices/examples/menu-button/menu-button-links.html
|
||||
// To improve UX, we want to auto-focus to the input when renaming. We use
|
||||
// requestAnimationFrame to immediately move focus to the input after it is
|
||||
// shown
|
||||
const { autoFocusedRef } = useRefWithAutoFocus()
|
||||
|
||||
function handleFocus(ev: React.FocusEvent<HTMLInputElement>) {
|
||||
const lastDotIndex = ev.target.value.lastIndexOf('.')
|
||||
ev.target.setSelectionRange(0, lastDotIndex)
|
||||
}
|
||||
|
||||
function handleChange(ev: React.ChangeEvent<HTMLInputElement>) {
|
||||
setValue(ev.target.value)
|
||||
}
|
||||
|
||||
function handleKeyDown(ev: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (ev.key === 'Enter') {
|
||||
finishRenaming(value)
|
||||
}
|
||||
if (ev.key === 'Escape') {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
finishRenaming(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="rename-input">
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
ref={autoFocusedRef as RefObject<HTMLInputElement>}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileTreeItemName
|
Reference in New Issue
Block a user