mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-07 20:31:06 -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'
|
||||
|
||||
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} />}
|
||||
/>
|
||||
|
|
|
@ -80,6 +80,7 @@ function FileTreeFolder({ name, id, folders, docs, files }) {
|
|||
<FileTreeItemInner
|
||||
id={id}
|
||||
name={name}
|
||||
type="folder"
|
||||
isSelected={isSelected}
|
||||
icons={icons}
|
||||
/>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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])
|
||||
|
|
|
@ -22,6 +22,7 @@ type FigureModalState = {
|
|||
includeLabel: boolean
|
||||
error?: string
|
||||
pastedImageData?: PastedImageData
|
||||
selectedItemId?: string
|
||||
}
|
||||
|
||||
type FigureModalStateUpdate = Partial<FigureModalState>
|
||||
|
|
|
@ -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]
|
||||
)
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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 },
|
||||
})
|
||||
)
|
||||
},
|
||||
|
|
|
@ -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 { 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(),
|
||||
]
|
||||
|
|
|
@ -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 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)
|
||||
export function isImageFile(file: File) {
|
||||
return hasImageExtension(file.name)
|
||||
}
|
||||
|
||||
export function isImageEntity(file: Entity | OutputEntity) {
|
||||
return hasImageExtension(file.path)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue