files can be dragged between windows
A custom MIME type on the DataTransfer survives between tabs and windows of the SAME browser, which is the case somebody actually has: two Officer windows side by side, a folder in each. So a row carries `application/x-officer-files` with its paths, a folder accepts the drop, and the file moves. What it cannot do is cross different browsers, or exchange with Finder — dragging out to the desktop needs a Chrome-only API and only works outward. Worth knowing before expecting it. `Files` is deliberately untouched. That is the type the OS puts on a real file drag and the existing upload path keys off it; a drag from inside carries only ours, so the two never meet. The container's drop handler checks for ours FIRST, because a drag from inside has no `Files` and would otherwise fall through to "read the dropped files", find none, and do nothing at all. Dragging also writes the clipboard, which is cheap now that it is per-user server state: if a drop lands somewhere that cannot read the payload, the paths are still held and a paste finishes the job. The drag is a gesture over the same mechanism rather than a second one beside it. Dragging one of several selected rows takes the whole selection; dragging an unselected row takes just that one. Dropping a folder into itself or into its own child is refused here rather than by the server, and the comparison uses a trailing separator so /a/bc is not read as living inside /a/b. `onDragOver` calls preventDefault, which is the single line whose absence makes a drop target look dead with no error anywhere. `dropEffect` is set by the target rather than the source, because only the target knows whether it can accept — the cursor says "move" over a folder and "no" over a file, which is the only feedback a drag has. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -305,6 +305,8 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
|
|||||||
onCompress={handleCompress}
|
onCompress={handleCompress}
|
||||||
onDuplicate={handleDuplicate}
|
onDuplicate={handleDuplicate}
|
||||||
onPasteInto={handlePasteInto}
|
onPasteInto={handlePasteInto}
|
||||||
|
onDragStart={fileBrowserManager.handleEntryDragStart}
|
||||||
|
onDropInto={fileBrowserManager.handleEntryDrop}
|
||||||
onTogglePin={handleTogglePin}
|
onTogglePin={handleTogglePin}
|
||||||
isPinned={isPinned(entryPath(entry.name))}
|
isPinned={isPinned(entryPath(entry.name))}
|
||||||
canPaste={clipboard !== null}
|
canPaste={clipboard !== null}
|
||||||
|
|||||||
@@ -95,6 +95,15 @@ export type FileItemProps = {
|
|||||||
onCompress: (entry: DirEntry) => void;
|
onCompress: (entry: DirEntry) => void;
|
||||||
onDuplicate: (entry: DirEntry) => void;
|
onDuplicate: (entry: DirEntry) => void;
|
||||||
onPasteInto: (entry: DirEntry) => void;
|
onPasteInto: (entry: DirEntry) => void;
|
||||||
|
/**
|
||||||
|
* Dragging this row, and dropping onto it.
|
||||||
|
*
|
||||||
|
* A custom MIME type on the DataTransfer survives between windows of the same browser, which is what
|
||||||
|
* makes "drag from one Officer window into another" work at all. See useFileBrowserApp for the type
|
||||||
|
* and why `Files` is left alone.
|
||||||
|
*/
|
||||||
|
onDragStart: (ev: React.DragEvent, entry: DirEntry) => void;
|
||||||
|
onDropInto: (ev: React.DragEvent, entry: DirEntry) => Promise<void>;
|
||||||
onTogglePin: (entry: DirEntry) => void;
|
onTogglePin: (entry: DirEntry) => void;
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
canPaste: boolean;
|
canPaste: boolean;
|
||||||
@@ -608,6 +617,8 @@ export const FileItem = ({
|
|||||||
onCompress,
|
onCompress,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
onPasteInto,
|
onPasteInto,
|
||||||
|
onDragStart,
|
||||||
|
onDropInto,
|
||||||
onTogglePin,
|
onTogglePin,
|
||||||
isPinned,
|
isPinned,
|
||||||
canPaste,
|
canPaste,
|
||||||
@@ -780,6 +791,24 @@ export const FileItem = ({
|
|||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onDoubleClick={handleDoubleClick}
|
onDoubleClick={handleDoubleClick}
|
||||||
onPointerDown={(ev) => ev.stopPropagation()}
|
onPointerDown={(ev) => ev.stopPropagation()}
|
||||||
|
draggable
|
||||||
|
onDragStart={(ev) => onDragStart(ev, entry)}
|
||||||
|
/*
|
||||||
|
A folder is a drop target; a file is not. `onDragOver` must preventDefault or the browser
|
||||||
|
refuses the drop entirely — that is the single line whose absence makes a target look
|
||||||
|
dead, with no error anywhere.
|
||||||
|
|
||||||
|
`dropEffect` is set here rather than at dragstart because only the target knows whether
|
||||||
|
it can accept: the cursor says "move" over a folder and "no" over a file, which is the
|
||||||
|
only feedback a drag has.
|
||||||
|
*/
|
||||||
|
onDragOver={(ev) => {
|
||||||
|
if (entry.type !== 'directory' || !ev.dataTransfer.types.includes('application/x-officer-files')) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
ev.dataTransfer.dropEffect = 'move';
|
||||||
|
}}
|
||||||
|
onDrop={(ev) => void onDropInto(ev, entry)}
|
||||||
>
|
>
|
||||||
<Checkbox checked={selected} anySelected={anySelected} onClick={(ev) => onSelect(entry, ev)} />
|
<Checkbox checked={selected} anySelected={anySelected} onClick={(ev) => onSelect(entry, ev)} />
|
||||||
<span className="shrink-0">{listVisual}</span>
|
<span className="shrink-0">{listVisual}</span>
|
||||||
@@ -827,6 +856,15 @@ export const FileItem = ({
|
|||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onDoubleClick={handleDoubleClick}
|
onDoubleClick={handleDoubleClick}
|
||||||
onPointerDown={(ev) => ev.stopPropagation()}
|
onPointerDown={(ev) => ev.stopPropagation()}
|
||||||
|
draggable
|
||||||
|
onDragStart={(ev) => onDragStart(ev, entry)}
|
||||||
|
onDragOver={(ev) => {
|
||||||
|
if (entry.type !== 'directory' || !ev.dataTransfer.types.includes('application/x-officer-files')) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
ev.dataTransfer.dropEffect = 'move';
|
||||||
|
}}
|
||||||
|
onDrop={(ev) => void onDropInto(ev, entry)}
|
||||||
>
|
>
|
||||||
<div className="absolute top-1 left-1">
|
<div className="absolute top-1 left-1">
|
||||||
<Checkbox checked={selected} anySelected={anySelected} onClick={(ev) => onSelect(entry, ev)} />
|
<Checkbox checked={selected} anySelected={anySelected} onClick={(ev) => onSelect(entry, ev)} />
|
||||||
|
|||||||
@@ -1037,6 +1037,78 @@ export const useFileBrowserApp = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
Dragging rows between windows.
|
||||||
|
|
||||||
|
── The MIME type is the whole trick ──
|
||||||
|
|
||||||
|
A custom type on the DataTransfer survives between tabs and windows of the SAME browser, which is
|
||||||
|
the case somebody actually has: two Officer windows side by side, one folder in each. It does not
|
||||||
|
survive between different browsers, and it cannot exchange with Finder — dragging out to the
|
||||||
|
desktop needs a Chrome-only API and only works outward.
|
||||||
|
|
||||||
|
`Files` is deliberately untouched. That is the type the OS puts on a real file drag, and the
|
||||||
|
existing upload path keys off it; a drag from inside carries only ours, so the two never meet.
|
||||||
|
|
||||||
|
── It also writes the clipboard ──
|
||||||
|
|
||||||
|
Belt and braces, and cheap now that the clipboard is per-user server state: if a drop lands
|
||||||
|
somewhere that cannot read the payload, the paths are still held and a paste finishes the job. The
|
||||||
|
drag becomes a gesture over the same mechanism rather than a second one beside it.
|
||||||
|
*/
|
||||||
|
const DRAG_TYPE = 'application/x-officer-files';
|
||||||
|
|
||||||
|
const handleEntryDragStart = (ev: React.DragEvent, entry: DirEntry) => {
|
||||||
|
// Dragging one of several selected rows takes the whole selection; dragging an unselected row
|
||||||
|
// takes just that one, which is what every file manager does and what a hand expects.
|
||||||
|
const paths = selected.has(entry.name)
|
||||||
|
? [...selected].map((name) => entryPath(name))
|
||||||
|
: [entryPath(entry.name)];
|
||||||
|
|
||||||
|
ev.dataTransfer.setData(DRAG_TYPE, JSON.stringify(paths));
|
||||||
|
// `move` so the cursor shows what will happen. The drop decides for real; this is the hint.
|
||||||
|
ev.dataTransfer.effectAllowed = 'move';
|
||||||
|
setClipboard({ paths, mode: 'cut' });
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Paths from a drag that started in an Officer window, or null if this is something else. */
|
||||||
|
const draggedPaths = (ev: React.DragEvent): string[] | null => {
|
||||||
|
if (!ev.dataTransfer.types.includes(DRAG_TYPE)) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(ev.dataTransfer.getData(DRAG_TYPE));
|
||||||
|
return Array.isArray(parsed) && parsed.every((p) => typeof p === 'string') ? parsed : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop onto a folder row — move into it.
|
||||||
|
*
|
||||||
|
* Refuses a folder being dropped into itself or into its own child, which the server would reject
|
||||||
|
* anyway but with an error rather than a shrug. Comparing with a trailing separator so `/a/bc` is
|
||||||
|
* not read as living inside `/a/b`.
|
||||||
|
*/
|
||||||
|
const handleEntryDrop = async (ev: React.DragEvent, entry: DirEntry) => {
|
||||||
|
const paths = draggedPaths(ev);
|
||||||
|
if (!paths || entry.type !== 'directory') return;
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
const destination = entryPath(entry.name);
|
||||||
|
const into = paths.filter((path) => destination !== path && !destination.startsWith(`${path}/`));
|
||||||
|
if (into.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await files.move(into, destination);
|
||||||
|
setClipboard(null);
|
||||||
|
await refresh();
|
||||||
|
toast.success(t('files.pastedItems', { count: into.length }));
|
||||||
|
} catch {
|
||||||
|
toast.error(t('files.pasteFailed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDragEnter = (ev: React.DragEvent) => {
|
const handleDragEnter = (ev: React.DragEvent) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
dragCounter.current++;
|
dragCounter.current++;
|
||||||
@@ -1062,6 +1134,28 @@ export const useFileBrowserApp = (
|
|||||||
dragCounter.current = 0;
|
dragCounter.current = 0;
|
||||||
setDragging(false);
|
setDragging(false);
|
||||||
|
|
||||||
|
/*
|
||||||
|
A drag that began in an Officer window, dropped on this folder's background.
|
||||||
|
|
||||||
|
Checked before the upload path, because a drag from inside carries no `Files` and would
|
||||||
|
otherwise fall through to "read the dropped files", find none, and do nothing at all.
|
||||||
|
*/
|
||||||
|
const dragged = draggedPaths(ev);
|
||||||
|
if (dragged) {
|
||||||
|
const into = dragged.filter((path) => currentPath !== path && !currentPath.startsWith(`${path}/`));
|
||||||
|
// Dropping a folder into the folder it already lives in. Nothing to do, and nothing to say.
|
||||||
|
if (into.length === 0) return;
|
||||||
|
try {
|
||||||
|
await files.move(into, currentPath);
|
||||||
|
setClipboard(null);
|
||||||
|
await refresh();
|
||||||
|
toast.success(t('files.pastedItems', { count: into.length }));
|
||||||
|
} catch {
|
||||||
|
toast.error(t('files.pasteFailed'));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const items = ev.dataTransfer.items;
|
const items = ev.dataTransfer.items;
|
||||||
if (!items || items.length === 0) return;
|
if (!items || items.length === 0) return;
|
||||||
|
|
||||||
@@ -1155,6 +1249,8 @@ export const useFileBrowserApp = (
|
|||||||
// Selection
|
// Selection
|
||||||
selected,
|
selected,
|
||||||
setSelected,
|
setSelected,
|
||||||
|
handleEntryDragStart,
|
||||||
|
handleEntryDrop,
|
||||||
clipboard,
|
clipboard,
|
||||||
// Exposed so the toolbar can let go of what is held. It is per-user state now, so a stale
|
// Exposed so the toolbar can let go of what is held. It is per-user state now, so a stale
|
||||||
// clipboard is something a person has to be able to clear rather than wait out.
|
// clipboard is something a person has to be able to clear rather than wait out.
|
||||||
|
|||||||
Reference in New Issue
Block a user