Allow files to be dragged from the file tree into the editor (#15028)

GitOrigin-RevId: f926666c032d1398a0e3f72a298116a3c7a9cd75
This commit is contained in:
Alf Eaton 2024-05-08 11:39:18 +01:00 committed by Copybot
parent cb297e342a
commit c1c098e2f9
12 changed files with 194 additions and 20 deletions

View file

@ -9,9 +9,11 @@ import iconTypeFromName from '../util/icon-type-from-name'
import classnames from 'classnames' import classnames from 'classnames'
function FileTreeDoc({ name, id, isFile, isLinkedFile }) { function FileTreeDoc({ name, id, isFile, isLinkedFile }) {
const type = isFile ? 'file' : 'doc'
const { isSelected, props: selectableEntityProps } = useSelectableEntity( const { isSelected, props: selectableEntityProps } = useSelectableEntity(
id, id,
isFile ? 'file' : 'doc' type
) )
return ( return (
@ -26,6 +28,7 @@ function FileTreeDoc({ name, id, isFile, isLinkedFile }) {
<FileTreeItemInner <FileTreeItemInner
id={id} id={id}
name={name} name={name}
type={type}
isSelected={isSelected} isSelected={isSelected}
icons={<FileTreeIcon isLinkedFile={isLinkedFile} name={name} />} icons={<FileTreeIcon isLinkedFile={isLinkedFile} name={name} />}
/> />

View file

@ -80,6 +80,7 @@ function FileTreeFolder({ name, id, folders, docs, files }) {
<FileTreeItemInner <FileTreeItemInner
id={id} id={id}
name={name} name={name}
type="folder"
isSelected={isSelected} isSelected={isSelected}
icons={icons} icons={icons}
/> />

View file

@ -15,11 +15,13 @@ import { useDragDropManager } from 'react-dnd'
function FileTreeItemInner({ function FileTreeItemInner({
id, id,
name, name,
type,
isSelected, isSelected,
icons, icons,
}: { }: {
id: string id: string
name: string name: string
type: string
isSelected: boolean isSelected: boolean
icons?: ReactNode icons?: ReactNode
}) { }) {
@ -76,6 +78,8 @@ function FileTreeItemInner({
ref={dragRef} ref={dragRef}
draggable={!isRenaming} draggable={!isRenaming}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
data-file-id={id}
data-file-type={type}
> >
<div <div
className="entity-name entity-name-react" className="entity-name entity-name-react"

View file

@ -17,6 +17,7 @@ import FileTreeModalCreateFile from './modals/file-tree-modal-create-file'
import FileTreeInner from './file-tree-inner' import FileTreeInner from './file-tree-inner'
import { useDragLayer } from 'react-dnd' import { useDragLayer } from 'react-dnd'
import classnames from 'classnames' import classnames from 'classnames'
import { pathInFolder } from '@/features/file-tree/util/path'
const FileTreeRoot = React.memo<{ const FileTreeRoot = React.memo<{
onSelect: () => void onSelect: () => void
@ -43,6 +44,41 @@ const FileTreeRoot = React.memo<{
const { fileTreeData } = useFileTreeData() const { fileTreeData } = useFileTreeData()
const isReady = Boolean(projectId && fileTreeData) const isReady = Boolean(projectId && fileTreeData)
useEffect(() => {
if (fileTreeContainer) {
const listener = (event: DragEvent) => {
if (event.dataTransfer) {
// store the dragged entity in dataTransfer
const { dataset } = event.target as HTMLDivElement
if (
dataset.fileId &&
dataset.fileType &&
dataset.fileType !== 'folder'
) {
event.dataTransfer.setData(
'application/x-overleaf-file-id',
dataset.fileId
)
const filePath = pathInFolder(fileTreeData, dataset.fileId)
if (filePath) {
event.dataTransfer.setData(
'application/x-overleaf-file-path',
filePath
)
}
}
}
}
fileTreeContainer.addEventListener('dragstart', listener)
return () => {
fileTreeContainer.removeEventListener('dragstart', listener)
}
}
}, [fileTreeContainer, fileTreeData])
useEffect(() => { useEffect(() => {
if (isReady) onInit() if (isReady) onInit()
}, [isReady, onInit]) }, [isReady, onInit])

View file

@ -22,6 +22,7 @@ type FigureModalState = {
includeLabel: boolean includeLabel: boolean
error?: string error?: string
pastedImageData?: PastedImageData pastedImageData?: PastedImageData
selectedItemId?: string
} }
type FigureModalStateUpdate = Partial<FigureModalState> type FigureModalStateUpdate = Partial<FigureModalState>

View file

@ -70,8 +70,16 @@ const FigureModalContent = () => {
const listener = useCallback( const listener = useCallback(
(event: Event) => { (event: Event) => {
const { detail: source } = event as CustomEvent<FigureModalSource> const { detail } = event as CustomEvent<{
dispatch({ source }) source: FigureModalSource
fileId?: string
filePath?: string
}>
dispatch({
source: detail.source,
selectedItemId: detail.fileId,
getPath: detail.filePath ? async () => detail.filePath! : undefined,
})
}, },
[dispatch] [dispatch]
) )

View file

@ -12,7 +12,7 @@ export const FigureModalCurrentProjectSource: FC = () => {
() => filterFiles(rootFolder)?.filter(isImageFile), () => filterFiles(rootFolder)?.filter(isImageFile),
[rootFolder] [rootFolder]
) )
const { dispatch } = useFigureModalContext() const { dispatch, selectedItemId } = useFigureModalContext()
const noFiles = files?.length === 0 const noFiles = files?.length === 0
return ( return (
<Select <Select
@ -20,6 +20,11 @@ export const FigureModalCurrentProjectSource: FC = () => {
itemToString={file => (file ? file.name : '')} itemToString={file => (file ? file.name : '')}
itemToSubtitle={item => item?.path ?? ''} itemToSubtitle={item => item?.path ?? ''}
itemToKey={item => item.id} itemToKey={item => item.id}
defaultItem={
files && selectedItemId
? files.find(item => item.id === selectedItemId)
: undefined
}
defaultText={ defaultText={
noFiles noFiles
? t('no_image_files_found') ? t('no_image_files_found')

View file

@ -16,7 +16,7 @@ export const InsertFigureDropdown = memo(function InsertFigureDropdown() {
emitToolbarEvent(view, `toolbar-figure-modal-${sourceName}`) emitToolbarEvent(view, `toolbar-figure-modal-${sourceName}`)
window.dispatchEvent( window.dispatchEvent(
new CustomEvent('figure-modal:open', { new CustomEvent('figure-modal:open', {
detail: source, detail: { source },
}) })
) )
}, },

View file

@ -0,0 +1,89 @@
import { EditorView } from '@codemirror/view'
import { hasImageExtension } from '@/features/source-editor/utils/file'
import { FigureModalSource } from '@/features/source-editor/components/figure-modal/figure-modal-context'
import { EditorSelection } from '@codemirror/state'
export const fileTreeItemDrop = () =>
EditorView.domEventHandlers({
dragover(event) {
// TODO: detect a drag from the file tree?
if (event.dataTransfer) {
event.preventDefault()
}
},
drop(event, view) {
if (event.dataTransfer) {
const fileId = event.dataTransfer.getData(
'application/x-overleaf-file-id'
)
const filePath = event.dataTransfer.getData(
'application/x-overleaf-file-path'
)
if (fileId && filePath) {
event.preventDefault()
const pos = view.posAtCoords(event)
if (pos !== null) {
handleDroppedFile(view, pos, fileId, filePath)
}
}
}
},
})
const withoutExtension = (filename: string) =>
filename.substring(0, filename.lastIndexOf('.'))
const handleDroppedFile = (
view: EditorView,
pos: number,
fileId: string,
filePath: string
) => {
if (filePath.endsWith('.bib')) {
view.focus()
const insert = `\\bibliography{${withoutExtension(filePath)}}`
view.dispatch({
changes: { from: pos, insert },
selection: EditorSelection.cursor(pos + insert.length),
})
return
}
if (filePath.endsWith('.tex')) {
view.focus()
const insert = `\\input{${withoutExtension(filePath)}}`
view.dispatch({
changes: { from: pos, insert },
selection: EditorSelection.cursor(pos + insert.length),
})
return
}
if (hasImageExtension(filePath)) {
view.focus()
view.dispatch({
selection: EditorSelection.cursor(pos),
})
window.dispatchEvent(
new CustomEvent('figure-modal:open', {
detail: {
source: FigureModalSource.FILE_TREE,
fileId,
filePath,
},
})
)
return
}
return null
}

View file

@ -46,7 +46,8 @@ import { effectListeners } from './effect-listeners'
import { highlightSpecialChars } from './highlight-special-chars' import { highlightSpecialChars } from './highlight-special-chars'
import { toolbarPanel } from './toolbar/toolbar-panel' import { toolbarPanel } from './toolbar/toolbar-panel'
import { geometryChangeEvent } from './geometry-change-event' import { geometryChangeEvent } from './geometry-change-event'
import { docName } from '@/features/source-editor/extensions/doc-name' import { docName } from './doc-name'
import { fileTreeItemDrop } from './file-tree-item-drop'
const moduleExtensions: Array<() => Extension> = importOverleafModules( const moduleExtensions: Array<() => Extension> = importOverleafModules(
'sourceEditorExtensions' 'sourceEditorExtensions'
@ -138,4 +139,5 @@ export const createExtensions = (options: Record<string, any>): Extension[] => [
thirdPartyExtensions(), thirdPartyExtensions(),
effectListeners(), effectListeners(),
geometryChangeEvent(), geometryChangeEvent(),
fileTreeItemDrop(),
] ]

View file

@ -49,8 +49,8 @@ export const filterFolders = filterByType('folder')
const IMAGE_FILE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'pdf'] const IMAGE_FILE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'pdf']
export function isImageFile(file: File) { export const hasImageExtension = (filename: string) => {
const parts = file.name.split('.') const parts = filename.split('.')
if (parts.length < 2) { if (parts.length < 2) {
return false return false
} }
@ -58,11 +58,10 @@ export function isImageFile(file: File) {
return IMAGE_FILE_EXTENSIONS.includes(extension) return IMAGE_FILE_EXTENSIONS.includes(extension)
} }
export function isImageFile(file: File) {
return hasImageExtension(file.name)
}
export function isImageEntity(file: Entity | OutputEntity) { export function isImageEntity(file: Entity | OutputEntity) {
const parts = file.path.split('.') return hasImageExtension(file.path)
if (parts.length < 2) {
return false
}
const extension = parts[parts.length - 1].toLowerCase()
return IMAGE_FILE_EXTENSIONS.includes(extension)
} }

View file

@ -9,7 +9,13 @@ describe('<FileTreeitemInner />', function () {
cy.mount( cy.mount(
<EditorProviders> <EditorProviders>
<FileTreeProvider> <FileTreeProvider>
<FileTreeitemInner id="123abc" name="bar.tex" isSelected={false} />, <FileTreeitemInner
id="123abc"
name="bar.tex"
isSelected={false}
type="doc"
/>
,
</FileTreeProvider> </FileTreeProvider>
</EditorProviders> </EditorProviders>
) )
@ -23,7 +29,12 @@ describe('<FileTreeitemInner />', function () {
cy.mount( cy.mount(
<EditorProviders permissionsLevel="readOnly"> <EditorProviders permissionsLevel="readOnly">
<FileTreeProvider> <FileTreeProvider>
<FileTreeitemInner id="123abc" name="bar.tex" isSelected /> <FileTreeitemInner
id="123abc"
name="bar.tex"
isSelected
type="doc"
/>
<FileTreeContextMenu /> <FileTreeContextMenu />
</FileTreeProvider> </FileTreeProvider>
</EditorProviders> </EditorProviders>
@ -37,7 +48,12 @@ describe('<FileTreeitemInner />', function () {
cy.mount( cy.mount(
<EditorProviders> <EditorProviders>
<FileTreeProvider> <FileTreeProvider>
<FileTreeitemInner id="123abc" name="bar.tex" isSelected /> <FileTreeitemInner
id="123abc"
name="bar.tex"
isSelected
type="doc"
/>
<FileTreeContextMenu /> <FileTreeContextMenu />
</FileTreeProvider> </FileTreeProvider>
</EditorProviders> </EditorProviders>
@ -60,7 +76,12 @@ describe('<FileTreeitemInner />', function () {
cy.mount( cy.mount(
<EditorProviders> <EditorProviders>
<FileTreeProvider> <FileTreeProvider>
<FileTreeitemInner id="123abc" name="bar.tex" isSelected /> <FileTreeitemInner
id="123abc"
name="bar.tex"
isSelected
type="doc"
/>
</FileTreeProvider> </FileTreeProvider>
</EditorProviders> </EditorProviders>
) )
@ -83,7 +104,12 @@ describe('<FileTreeitemInner />', function () {
cy.mount( cy.mount(
<EditorProviders rootDocId="123abc" rootFolder={rootFolder as any}> <EditorProviders rootDocId="123abc" rootFolder={rootFolder as any}>
<FileTreeProvider> <FileTreeProvider>
<FileTreeitemInner id="123abc" name="bar.tex" isSelected /> <FileTreeitemInner
id="123abc"
name="bar.tex"
isSelected
type="doc"
/>
<FileTreeContextMenu /> <FileTreeContextMenu />
</FileTreeProvider> </FileTreeProvider>
</EditorProviders> </EditorProviders>