mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Allow files to be dragged from the file tree into the editor (#15028)
GitOrigin-RevId: f926666c032d1398a0e3f72a298116a3c7a9cd75
This commit is contained in:
parent
cb297e342a
commit
c1c098e2f9
12 changed files with 194 additions and 20 deletions
|
@ -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} />}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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])
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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]
|
||||||
)
|
)
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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 },
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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(),
|
||||||
]
|
]
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue