Dropzone: dedupe, video preview

This commit is contained in:
Pogodaanton 2020-06-25 18:06:46 +02:00
parent 0ea92244b2
commit c8f11f89a9
7 changed files with 302 additions and 140 deletions

View File

@ -18,7 +18,7 @@
"dropDescription": "You can upload an image by dragging it into here.",
"error": {
"title": "Uh-Oh, an error happened!",
"unsupported": "This filetype cannot be uploaded.\nSupported types are: jpg, png, gif"
"unsupported": "This filetype cannot be uploaded.\nSupported types are: jpg, png, gif, mp4"
},
"aria": {
"dialogLabel": "Upload dialog"

View File

@ -50,12 +50,19 @@ const FullscreenDropzoneStyles: ComponentStyles<
justifyContent: "space-between",
outline: "none",
marginBottom: "20px",
flexShrink: "0",
"& > h1": {
lineHeight: "40px",
},
},
fullscreenDropzoneDialogContent_buttons: {
display: "flex",
alignItems: "center",
"& > *:first-child": {
marginRight: "10px",
marginRight: "15px",
},
"& > *:last-child": {
margin: "0",
},
},
};

View File

@ -5,7 +5,9 @@ import { ManagedClasses } from "@microsoft/fast-jss-manager-react";
*/
export interface DropzoneUploadClassNameContract {
dropzoneUpload?: string;
dropzoneUpload_image?: string;
dropzoneUpload_rejectIcon?: string;
dropzoneUpload_preview?: string;
dropzoneUpload_background?: string;
dropzoneUpload_details?: string;
dropzoneUpload_error?: string;
dropzoneUpload_progress?: string;

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback, Fragment } from "react";
import { FaExclamation, FaCheck } from "react-icons/fa";
import React, { useState, useEffect, useCallback, Fragment, useRef } from "react";
import { FaExclamation, FaCheck, FaRegFrownOpen } from "react-icons/fa";
import { useTranslation } from "react-i18next";
import {
Progress,
@ -22,7 +22,7 @@ import {
backgroundColor,
} from "@microsoft/fast-components-styles-msft";
import { parseColorHexRGBA } from "@microsoft/fast-colors";
import { ProgressIcon } from "../../_DesignSystem";
import { ProgressIcon, getScaleFactorByConstraints } from "../../_DesignSystem";
import axios from "../../_interceptedAxios";
const DropzoneUploadStyles: ComponentStyles<
@ -40,8 +40,15 @@ const DropzoneUploadStyles: ComponentStyles<
transform: "translateY(0%)",
},
},
dropzoneUpload_image: {
objectFit: "contain",
dropzoneUpload_preview: {
position: "absolute",
left: "0",
top: "0",
},
dropzoneUpload_background: {
filter: "blur(14px)",
transform: "scale(1.1)",
opacity: ".8",
},
dropzoneUpload_details: {
position: "absolute",
@ -74,6 +81,16 @@ const DropzoneUploadStyles: ComponentStyles<
fontSize: "4em",
color: neutralForegroundRest,
},
dropzoneUpload_rejectIcon: {
width: "55px",
height: "55px",
position: "absolute",
top: "0",
bottom: "0",
left: "0",
right: "0",
margin: "auto",
},
};
const ProgressStyles: ComponentStyles<ProgressClassNameContract, DesignSystem> = {
@ -85,99 +102,233 @@ const ProgressStyles: ComponentStyles<ProgressClassNameContract, DesignSystem> =
progress_circularSVG__page: {},
};
const DropzoneUpload = (props: DropzoneUploadProps) => {
const { t } = useTranslation("dashboard");
const [errorMessage, setErrorMessage] = useState("");
const [progress, setProgress] = useState(0);
const [resData, setResData] = useState({ id: null, file_url: null });
const onUploadProgress = (prog: ProgressEvent) =>
setProgress(Math.round((prog.loaded * 100) / prog.total));
const uploadFile = useCallback(async () => {
const formData = new FormData();
formData.set("data", props.file);
try {
const res = await axios.post(window.location.origin + "/api/upload.php", formData, {
onUploadProgress,
});
setResData(res.data);
} catch (err) {
console.log("An error happened!\n", err);
setErrorMessage(err.i18n ? t(err.i18n) : err.message || "Unknkown Error!");
return null;
}
}, [props.file, t]);
// onMount
useEffect(() => {
console.log("updated!");
if (props.rejected) {
let savedTimeout = setTimeout(props.onRemoveRequest, 6000);
return () => clearTimeout(savedTimeout);
}
// Uploading process
if (resData.id || errorMessage) return () => {};
uploadFile();
return () => {};
}, [errorMessage, props.onRemoveRequest, props.rejected, resData.id, uploadFile]);
return (
<div className={props.managedClasses.dropzoneUpload}>
{!props.rejected && props.preview && (
<img
src={props.preview}
alt={props.file.name}
className={props.managedClasses.dropzoneUpload_image}
width="200"
height="200"
/>
)}
<footer className={props.managedClasses.dropzoneUpload_details} tabIndex={0}>
<Label
className={
props.rejected || errorMessage || resData.id
? props.managedClasses.dropzoneUpload_error
: ""
}
tabIndex={-1}
>
{props.rejected ? (
t("upload.error.unsupported")
) : errorMessage ? (
errorMessage
) : resData.id ? (
<Fragment>
{t("upload.finished", { filename: resData.id })}
<br />
<Hypertext href={resData.file_url} target="_">
{t("upload.visit")}
</Hypertext>
</Fragment>
) : (
props.file.name
)}
</Label>
</footer>
<div className={props.managedClasses.dropzoneUpload_progress}>
{props.rejected || errorMessage.length > 0 ? (
<ProgressIcon icon={FaExclamation} />
) : progress === 100 ? (
<ProgressIcon icon={FaCheck} />
) : (
<Progress
jssStyleSheet={ProgressStyles}
circular={true}
minValue={0}
maxValue={100}
value={progress}
/>
)}
</div>
</div>
/**
* Centers and scales a given ImageSource,
* so that it fits onto a specified canvas
*/
const alignImageToCanvas = (
source: CanvasImageSource,
srcWidth: number,
srcHeight: number,
ctx: CanvasRenderingContext2D
) => {
const canvas = ctx.canvas;
const factor = getScaleFactorByConstraints(
srcWidth,
srcHeight,
canvas.width,
canvas.height
);
const deltaX = (canvas.width - srcWidth * factor) / 2;
const deltaY = (canvas.height - srcHeight * factor) / 2;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(
source,
0,
0,
srcWidth,
srcHeight,
deltaX,
deltaY,
srcWidth * factor,
srcHeight * factor
);
};
/**
* Centers and scales a given ImageSource,
* so that it fills the entire canvas space
*/
const coverCanvasWithImage = (
source: CanvasImageSource,
srcWidth: number,
srcHeight: number,
ctx: CanvasRenderingContext2D
) => {
const canvas = ctx.canvas;
let factor = 0;
if (srcHeight > srcWidth) factor = canvas.width / srcWidth;
else factor = canvas.height / srcHeight;
const deltaX = (canvas.width - srcWidth * factor) / 2;
const deltaY = (canvas.height - srcHeight * factor) / 2;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(
source,
0,
0,
srcWidth,
srcHeight,
deltaX,
deltaY,
srcWidth * factor,
srcHeight * factor
);
};
const DropzoneUpload: React.ComponentType<DropzoneUploadProps> = React.memo(
({ managedClasses, rejected, file, onRemoveRequest, preview }) => {
const { t } = useTranslation("dashboard");
const [hasStartedUpload, setUploadState] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [progress, setProgress] = useState(0);
const [resData, setResData] = useState({ id: null, file_url: null });
/**
* Ref of the canvas that is used to depict the currently uploaded item
*/
const canvasRef = useRef<HTMLCanvasElement>();
/**
* Ref of the canvas that is used as a background element
*/
const canvasBgRef = useRef<HTMLCanvasElement>();
const onUploadProgress = (prog: ProgressEvent) =>
setProgress(Math.round((prog.loaded * 100) / prog.total));
const uploadFile = useCallback(async () => {
const formData = new FormData();
formData.set("data", file);
setUploadState(true);
try {
const res = await axios.post(
window.location.origin + "/api/upload.php",
formData,
{ onUploadProgress }
);
setResData(res.data);
} catch (err) {
console.log("An error happened!\n", err);
setErrorMessage(err.i18n ? t(err.i18n) : err.message || "Unknkown Error!");
return null;
}
}, [file, t]);
useEffect(() => {
// Request umounting if upload has been rejected
if (rejected) {
let savedTimeout = setTimeout(onRemoveRequest, 6000);
return () => clearTimeout(savedTimeout);
}
// Abort if upload has already started
if (hasStartedUpload) return;
uploadFile();
}, [hasStartedUpload, onRemoveRequest, rejected, uploadFile]);
/**
* Preview image generation.
*/
useEffect(() => {
if (rejected || !preview) return;
const type = file.type.split("/")[0];
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
const ctxBg = canvasBgRef.current.getContext("2d");
console.log(preview, file.type, canvasRef.current, type);
if (type === "image") {
const img = new Image();
img.addEventListener("load", () => {
alignImageToCanvas(img, img.width, img.height, ctx);
coverCanvasWithImage(img, img.width, img.height, ctxBg);
});
img.src = preview;
return;
}
if (type === "video") {
const video = document.createElement("video");
video.style.position = "fixed";
video.autoplay = false;
video.addEventListener("loadeddata", () => {
const seekHandler = () => {
video.removeEventListener("seeked", seekHandler);
video.pause();
console.log("seeked!");
alignImageToCanvas(video, video.videoWidth, video.videoHeight, ctx);
coverCanvasWithImage(video, video.videoWidth, video.videoHeight, ctxBg);
};
video.addEventListener("seeked", seekHandler);
video.currentTime = 0;
video.play();
});
video.src = preview;
document.body.appendChild(video);
}
}, [file.type, preview, rejected]);
return (
<div className={managedClasses.dropzoneUpload}>
{!rejected ? (
preview && (
<>
<canvas
ref={canvasBgRef}
className={managedClasses.dropzoneUpload_background}
width="200"
height="200"
/>
<canvas
ref={canvasRef}
className={managedClasses.dropzoneUpload_preview}
width="200"
height="200"
/>
</>
)
) : (
<FaRegFrownOpen className={managedClasses.dropzoneUpload_rejectIcon} />
)}
<footer className={managedClasses.dropzoneUpload_details} tabIndex={0}>
<Label
className={
rejected || errorMessage || resData.id
? managedClasses.dropzoneUpload_error
: ""
}
tabIndex={-1}
>
{rejected ? (
t("upload.error.unsupported")
) : errorMessage ? (
errorMessage
) : resData.id ? (
<Fragment>
{t("upload.finished", { filename: resData.id })}
<br />
<Hypertext href={resData.file_url} target="_">
{t("upload.visit")}
</Hypertext>
</Fragment>
) : (
file.name
)}
</Label>
</footer>
<div className={managedClasses.dropzoneUpload_progress}>
{rejected || errorMessage.length > 0 ? (
<ProgressIcon icon={FaExclamation} />
) : progress === 100 ? (
<ProgressIcon icon={FaCheck} />
) : (
<Progress
jssStyleSheet={ProgressStyles}
circular={true}
minValue={0}
maxValue={100}
value={progress}
/>
)}
</div>
</div>
);
}
);
export default manageJss(DropzoneUploadStyles)(DropzoneUpload);

View File

@ -29,11 +29,7 @@ const DropzoneUploadManager = (props: DropzoneUploadManagerProps) => {
const manageDropDataChange = useCallback(() => {
const { dropData } = props;
if (dropData.acceptedFiles.length <= 0) {
console.log("bingo");
}
console.log("manageDropDataChange");
const acceptedUploadList = dropData.acceptedFiles.map(file => {
return {
key: uniqueId(new Date().getTime() + ""),
@ -56,7 +52,6 @@ const DropzoneUploadManager = (props: DropzoneUploadManagerProps) => {
...acceptedUploadList,
...rejectedUploadList,
];
console.log(updatedUploadList);
changeUploadList(updatedUploadList);
}, [props, uploadList]);
@ -71,8 +66,6 @@ const DropzoneUploadManager = (props: DropzoneUploadManagerProps) => {
[]
);
//
// Props change listener
useEffect(() => {
if (props.dropData !== dropData) {
@ -102,20 +95,6 @@ const DropzoneUploadManager = (props: DropzoneUploadManagerProps) => {
)}
</div>
);
/*
return (
(dropData.acceptedFiles.length > 0 || dropData.rejectedFiles.length > 0) && (
<Fragment>
<FaExclamationTriangle className={props.managedClasses.dropzoneUploadManagerIcon} />
<Heading tag={HeadingTag.h1} size={2}>
{t("upload.error.title")}
</Heading>
<Heading tag={HeadingTag.h2} size={5}>
{errorMessage}
</Heading>
</Fragment>
)
);*/
};
export default manageJss(styles)(DropzoneUploadManager);

View File

@ -10,6 +10,41 @@ import { useMemo } from "react";
* @param constraintsY Height of the boundary the element in question should align to
* @returns A factor that tells by how much the element in question needs to scale-down.
*/
export const getScaleFactorByConstraints = (
width: number,
height: number,
constraintsX: number,
constraintsY: number
) => {
let newScaleFactor = 1;
const aspectRatio = width / height;
const adjustedConstraintsX = constraintsX;
if (width - adjustedConstraintsX > 0 || height - constraintsY > 0) {
if (constraintsY * aspectRatio <= adjustedConstraintsX) {
const adaptedWidth = width * (constraintsY / height);
newScaleFactor = adaptedWidth / width;
} else {
const adaptedHeight = height * (adjustedConstraintsX / width);
newScaleFactor = adaptedHeight / height;
}
}
return newScaleFactor;
};
/**
* Hookified version of getScaleFactorByConstraints.
*
* Determines whether an element fits inside of specific constraints and returns a factor
* that can be used to scale down the element in question if needed.
*
* @param width Original width of the element in question
* @param height Original height of the element in question
* @param constraintsX Width of the boundary the element in question should align to
* @param constraintsY Height of the boundary the element in question should align to
* @returns A factor that tells by how much the element in question needs to scale-down.
*/
export const useScaleFactor = (
width: number,
height: number,
@ -23,25 +58,12 @@ export const useScaleFactor = (
typeof constraintsY !== "number"
) {
throw new Error(
'Hook "useWindowBreakpoint" needs four arguments: width, height, constraintsX, constraintsY'
'Hook "useScaleFactor" requires four arguments: width, height, constraintsX, constraintsY'
);
}
return useMemo(() => {
let newScaleFactor = 1;
const aspectRatio = width / height;
const adjustedConstraintsX = constraintsX;
if (width - adjustedConstraintsX > 0 || height - constraintsY > 0) {
if (constraintsY * aspectRatio <= adjustedConstraintsX) {
const adaptedWidth = width * (constraintsY / height);
newScaleFactor = adaptedWidth / width;
} else {
const adaptedHeight = height * (adjustedConstraintsX / width);
newScaleFactor = adaptedHeight / height;
}
}
return newScaleFactor;
}, [constraintsX, constraintsY, height, width]);
return useMemo(
() => getScaleFactorByConstraints(width, height, constraintsX, constraintsY),
[width, height, constraintsX, constraintsY]
);
};

View File

@ -14,7 +14,7 @@ import {
import { iconToGlyph } from "./Utils/iconToGlyph";
import ProgressIcon from "./ProgressIcon/ProgressIcon";
import useMotionValueFactory from "./Hooks/useMotionValueFactory";
import { useScaleFactor } from "./Hooks/useScaleFactor";
import { useScaleFactor, getScaleFactorByConstraints } from "./Hooks/useScaleFactor";
import { ToastManager, toast } from "./Toasts/toast";
import TabBar from "./Tabs/TabBar/TabBar";
import TabViewer from "./Tabs/TabViewer/TabViewer";
@ -37,6 +37,7 @@ export {
RouteInterceptionManager,
useRouteInterception,
useScaleFactor,
getScaleFactorByConstraints,
ToastManager,
toast,
TabBar,