Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Drag 'n drop #551

Merged
merged 1 commit into from
Dec 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions src/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,32 @@

// Passthrough for layout and styling purposes, to enable main page parts to participate in the grid
.pf-v5-c-page__main-section,
.files-view-stack {
.files-view-stack,
.upload-drop-zone {
display: contents;
}

.files-card {
position: relative;
}

.drag-drop-upload,
.drag-drop-upload-blocked {
position: absolute;
z-index: 10;
border: 3px dashed var(--pf-v5-global--link--Color);
background: rgb(from var(--pf-v5-global--BackgroundColor--100) r g b / 80%);
display: flex;
align-items: center;
justify-content: center;
block-size: 100%;
inline-size: 100%;

.pf-v5-c-empty-state__icon {
color: var(--pf-v5-global--Color--100);
}
}

.pf-v5-c-page__main {
gap: var(--pf-v5-global--spacer--md);
display: grid;
Expand All @@ -30,12 +52,12 @@
}

.files-empty-state,
.files-view-stack > .pf-v5-c-card,
.files-view-stack > .upload-drop-zone > .pf-v5-c-card,
.files-view-stack > .files-footer-info {
grid-column: content;
}

.files-view-stack > .pf-v5-c-card {
.files-view-stack > .upload-drop-zone > .pf-v5-c-card {
overflow: auto;
}

Expand Down
6 changes: 6 additions & 0 deletions src/files-card-body.scss
Original file line number Diff line number Diff line change
Expand Up @@ -309,3 +309,9 @@
column-gap: var(--pf-v5-global--spacer--sm);
text-align: start;
}

// use all available space when there is not enough files to fill the whole view
// so drag&drop works in empty space
.fileview-wrapper {
block-size: 100%;
}
4 changes: 2 additions & 2 deletions src/files-card-body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ export const FilesCardBody = ({
return files.filter(file => file.name.startsWith(".")).length;
}, [files]);
const isMounted = useRef<boolean>();
const folderViewRef = React.useRef<HTMLDivElement>(null);
const folderViewRef = useRef<HTMLDivElement>(null);

function calculateBoxPerRow () {
const boxes = document.querySelectorAll(".fileview tbody > tr") as NodeListOf<HTMLElement>;
Expand Down Expand Up @@ -206,7 +206,7 @@ export const FilesCardBody = ({
});

useEffect(() => {
let folderViewElem = null;
let folderViewElem: HTMLDivElement | null = null;

const resetSelected = (e: MouseEvent) => {
if ((e.target instanceof HTMLElement)) {
Expand Down
183 changes: 149 additions & 34 deletions src/files-folder-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,35 @@
* along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
*/

import React, { useEffect, useState } from "react";
import React, { createContext, useContext, useEffect, useState, useRef } from "react";

import { Card } from '@patternfly/react-core/dist/esm/components/Card';
import { BanIcon, UploadIcon } from '@patternfly/react-icons';
import { debounce } from "throttle-debounce";

import cockpit from "cockpit";
import { EmptyStatePanel } from "cockpit-components-empty-state";

import type { FolderFileInfo } from "./app.tsx";
import { FilesCardBody } from "./files-card-body.tsx";
import { as_sort, FilesCardHeader } from "./header.tsx";

const _ = cockpit.gettext;

export interface UploadedFilesType {[name: string]:{file: File, progress: number, cancel:() => void}}

interface UploadContextType {
uploadedFiles: UploadedFilesType,
setUploadedFiles: React.Dispatch<React.SetStateAction<UploadedFilesType>>,
}

export const UploadContext = createContext({
uploadedFiles: {},
setUploadedFiles: () => console.warn("UploadContext not initialized!"),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This added line is not executed by any test.

} as UploadContextType);

export const useUploadContext = () => useContext(UploadContext);

export const FilesFolderView = ({
path,
files,
Expand All @@ -45,9 +65,13 @@ export const FilesFolderView = ({
selected: FolderFileInfo[], setSelected: React.Dispatch<React.SetStateAction<FolderFileInfo[]>>,
clipboard: string[], setClipboard: React.Dispatch<React.SetStateAction<string[]>>,
}) => {
const dropzoneRef = useRef<HTMLDivElement>(null);
const [currentFilter, setCurrentFilter] = useState("");
const [isGrid, setIsGrid] = useState(localStorage.getItem("files:isGrid") !== "false");
const [sortBy, setSortBy] = useState(as_sort(localStorage.getItem("files:sort")));
const [dragDropActive, setDragDropActive] = useState(false);
const [uploadedFiles, setUploadedFiles] = useState<{[name: string]:
{file: File, progress: number, cancel:() => void}}>({});
const onFilterChange = debounce(300,
(_event: React.FormEvent<HTMLInputElement>, value: string) =>
setCurrentFilter(value));
Expand All @@ -57,39 +81,130 @@ export const FilesFolderView = ({
setCurrentFilter("");
}, [path]);

// counter to manage current status of drag&drop
// dragging items over file entries causes a bunch of drag-enter and drag-leave
// events, this counter helps checking when final drag-leave event is fired
const dragDropCnt = useRef(0);

useEffect(() => {
let dropzoneElem: HTMLDivElement | null = null;
const isUploading = Object.keys(uploadedFiles).length !== 0;

const handleDragEnter = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();

if (dragDropCnt.current === 0) {
setDragDropActive(true);
}
dragDropCnt.current++;
};

const handleDragLeave = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();

dragDropCnt.current--;
if (dragDropCnt.current === 0) {
setDragDropActive(false);
}
};

const handleDrop = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();

setDragDropActive(false);
dragDropCnt.current = 0;

// disable drag & drop when upload is in progress
if (isUploading) {
return;
}

cockpit.assert(event.dataTransfer !== null, "dataTransfer cannot be null");
dispatchEvent(new CustomEvent('files-drop', { detail: event.dataTransfer.files }));
};

const handleDragOver = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
};

if (dropzoneRef.current) {
dropzoneElem = dropzoneRef.current;
dropzoneElem.addEventListener("dragenter", handleDragEnter);
dropzoneElem.addEventListener("dragleave", handleDragLeave);
dropzoneElem.addEventListener("dragover", handleDragOver, false);
dropzoneElem.addEventListener("drop", handleDrop, false);
}

return () => {
if (dropzoneElem) {
dropzoneElem.removeEventListener("dragenter", handleDragEnter);
dropzoneElem.removeEventListener("dragover", handleDragOver);
dropzoneElem.removeEventListener("dragleave", handleDragLeave);
dropzoneElem.removeEventListener("drop", handleDrop);
}
};
}, [uploadedFiles, dragDropCnt]);

const dropzoneComponent = (Object.keys(uploadedFiles).length === 0)
? (
<div className="drag-drop-upload">
<EmptyStatePanel
icon={UploadIcon}
title={_("Drop files to upload")}
/>
</div>
)
: (
<div className="drag-drop-upload-blocked">
<EmptyStatePanel
icon={BanIcon}
title={_("Cannot drop files, another upload is already in progress")}
/>
</div>
);

return (
<Card>
<FilesCardHeader
currentFilter={currentFilter}
onFilterChange={onFilterChange}
isGrid={isGrid}
setIsGrid={setIsGrid}
sortBy={sortBy}
setSortBy={setSortBy}
path={path}
showHidden={showHidden}
setShowHidden={setShowHidden}
selected={selected}
setSelected={setSelected}
clipboard={clipboard}
setClipboard={setClipboard}
/>
<FilesCardBody
files={files}
currentFilter={currentFilter}
path={path}
isGrid={isGrid}
sortBy={sortBy}
setSortBy={setSortBy}
selected={selected}
setSelected={setSelected}
loadingFiles={loadingFiles}
clipboard={clipboard}
setClipboard={setClipboard}
showHidden={showHidden}
setShowHidden={setShowHidden}
setCurrentFilter={setCurrentFilter}
/>
</Card>
<UploadContext.Provider value={{ uploadedFiles, setUploadedFiles }}>
<div className="upload-drop-zone" ref={dropzoneRef}>
<Card className="files-card">
<FilesCardHeader
currentFilter={currentFilter}
onFilterChange={onFilterChange}
isGrid={isGrid}
setIsGrid={setIsGrid}
sortBy={sortBy}
setSortBy={setSortBy}
path={path}
showHidden={showHidden}
setShowHidden={setShowHidden}
selected={selected}
setSelected={setSelected}
clipboard={clipboard}
setClipboard={setClipboard}
/>
<FilesCardBody
files={files}
currentFilter={currentFilter}
path={path}
isGrid={isGrid}
sortBy={sortBy}
setSortBy={setSortBy}
selected={selected}
setSelected={setSelected}
loadingFiles={loadingFiles}
clipboard={clipboard}
setClipboard={setClipboard}
showHidden={showHidden}
setShowHidden={setShowHidden}
setCurrentFilter={setCurrentFilter}
/>
{dragDropActive && dropzoneComponent}
</Card>
</div>
</UploadContext.Provider>
);
};
44 changes: 33 additions & 11 deletions src/upload-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
* along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
*/

import React, { useState, useRef } from "react";
import React, { useRef, useCallback, useContext, useEffect } from "react";

import { AlertVariant, AlertActionLink } from "@patternfly/react-core/dist/esm/components/Alert";
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
Expand All @@ -40,6 +40,7 @@ import { fmt_to_fragments } from "utils";
import { FolderFileInfo, useFilesContext } from "./app.tsx";
import { permissionShortStr } from "./common.ts";
import { edit_permissions } from "./dialogs/permissions.tsx";
import { UploadContext } from "./files-folder-view.tsx";
import { get_owner_candidates } from "./ownership.tsx";

import "./upload-button.scss";
Expand Down Expand Up @@ -158,8 +159,7 @@ export const UploadButton = ({
const { addAlert, removeAlert, cwdInfo } = useFilesContext();
const dialogs = useDialogs();
const [showPopover, setPopover] = React.useState(false);
const [uploadedFiles, setUploadedFiles] = useState<{[name: string]:
{file: File, progress: number, cancel:() => void}}>({});
const { uploadedFiles, setUploadedFiles } = useContext(UploadContext);

const handleClick = () => {
if (ref.current) {
Expand All @@ -175,8 +175,7 @@ export const UploadButton = ({
event.returnValue = true;
};

const onUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
cockpit.assert(event.target.files, "not an <input type='file'>?");
const onUpload = useCallback(async (files: FileList, event?: React.ChangeEvent<HTMLInputElement>) => {
cockpit.assert(cwdInfo?.entries, "cwdInfo.entries is undefined");
let next_progress = 0;
let owner = null;
Expand All @@ -193,14 +192,15 @@ export const UploadButton = ({
const resetInput = () => {
// Reset input field in the case a download was cancelled and has to be re-uploaded
// https://stackoverflow.com/questions/26634616/filereader-upload-same-file-again-not-working
event.target.value = "";
if (event)
event.target.value = "";
};

let resolution;
let replaceAll = false;
let skipAll = false;
for (let i = 0; i < event.target.files.length; i++) {
const uploadFile = event.target.files[i];
for (let i = 0; i < files.length; i++) {
const uploadFile = files[i];
const file = cwdInfo?.entries[uploadFile.name];

if (replaceAll)
Expand All @@ -210,7 +210,7 @@ export const UploadButton = ({
} else if (file) {
try {
resolution = await dialogs.run(FileConflictDialog, {
path, file, uploadFile, isMultiUpload: event.target.files.length > 1
path, file, uploadFile, isMultiUpload: files.length > 1
});
} catch (_exc) { // eslint-disable-line @typescript-eslint/no-unused-vars
resetInput();
Expand Down Expand Up @@ -396,7 +396,26 @@ export const UploadButton = ({

addAlert(title, AlertVariant.success, key, description, action);
}
};
}, [
addAlert,
removeAlert,
cwdInfo,
dialogs,
path,
setUploadedFiles,
]);

useEffect(() => {
const handleFilesDrop = ((event: CustomEvent) => {
onUpload(event.detail);
}) as EventListener;

window.addEventListener("files-drop", handleFilesDrop);

return () => {
window.removeEventListener("files-drop", handleFilesDrop);
};
}, [path, cwdInfo, onUpload]);

const isUploading = Object.keys(uploadedFiles).length !== 0;
let popover;
Expand Down Expand Up @@ -479,7 +498,10 @@ export const UploadButton = ({
type="file"
hidden
multiple
onChange={onUpload}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
cockpit.assert(event?.target.files, "not an <input type='file'>?");
onUpload(event.target.files, event);
}}
/>
</>
);
Expand Down
Loading