first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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