Răsfoiți Sursa

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

GitOrigin-RevId: f926666c032d1398a0e3f72a298116a3c7a9cd75
Alf Eaton 2 ani în urmă
părinte
comite
c1c098e2f9

+ 4 - 1
services/web/frontend/js/features/file-tree/components/file-tree-doc.jsx

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

+ 1 - 0
services/web/frontend/js/features/file-tree/components/file-tree-folder.jsx

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

+ 4 - 0
services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner.tsx

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

+ 36 - 0
services/web/frontend/js/features/file-tree/components/file-tree-root.tsx

@@ -17,6 +17,7 @@ import FileTreeModalCreateFile from './modals/file-tree-modal-create-file'
 import FileTreeInner from './file-tree-inner'
 import { useDragLayer } from 'react-dnd'
 import classnames from 'classnames'
+import { pathInFolder } from '@/features/file-tree/util/path'
 
 const FileTreeRoot = React.memo<{
   onSelect: () => void
@@ -43,6 +44,41 @@ const FileTreeRoot = React.memo<{
   const { fileTreeData } = useFileTreeData()
   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(() => {
     if (isReady) onInit()
   }, [isReady, onInit])

+ 1 - 0
services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal-context.tsx

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

+ 10 - 2
services/web/frontend/js/features/source-editor/components/figure-modal/figure-modal.tsx

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

+ 6 - 1
services/web/frontend/js/features/source-editor/components/figure-modal/file-sources/figure-modal-project-source.tsx

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

+ 1 - 1
services/web/frontend/js/features/source-editor/components/toolbar/insert-figure-dropdown.tsx

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

+ 89 - 0
services/web/frontend/js/features/source-editor/extensions/file-tree-item-drop.ts

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

+ 3 - 1
services/web/frontend/js/features/source-editor/extensions/index.ts

@@ -46,7 +46,8 @@ import { effectListeners } from './effect-listeners'
 import { highlightSpecialChars } from './highlight-special-chars'
 import { toolbarPanel } from './toolbar/toolbar-panel'
 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(
   'sourceEditorExtensions'
@@ -138,4 +139,5 @@ export const createExtensions = (options: Record<string, any>): Extension[] => [
   thirdPartyExtensions(),
   effectListeners(),
   geometryChangeEvent(),
+  fileTreeItemDrop(),
 ]

+ 7 - 8
services/web/frontend/js/features/source-editor/utils/file.ts

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

+ 31 - 5
services/web/test/frontend/features/file-tree/components/file-tree-item/file-tree-item-inner.spec.tsx

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