diff --git a/client/packages/lowcoder/src/comps/comps/fileComp/ImageCaptureModal.tsx b/client/packages/lowcoder/src/comps/comps/fileComp/ImageCaptureModal.tsx new file mode 100644 index 000000000..ea35e2c52 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/fileComp/ImageCaptureModal.tsx @@ -0,0 +1,202 @@ +import React, { Suspense, useCallback, useEffect, useRef, useState } from "react"; +import { default as Button } from "antd/es/button"; +import Dropdown from "antd/es/dropdown"; +import type { ItemType } from "antd/es/menu/interface"; +import Skeleton from "antd/es/skeleton"; +import Menu from "antd/es/menu"; +import Flex from "antd/es/flex"; +import styled from "styled-components"; +import { trans } from "i18n"; +import { CustomModal } from "lowcoder-design"; + +const CustomModalStyled = styled(CustomModal)` + top: 10vh; + .react-draggable { + max-width: 100%; + width: 500px; + + video { + width: 100%; + } + } +`; + +const Error = styled.div` + color: #f5222d; + height: 100px; + width: 100%; + display: flex; + align-items: center; + justify-content: center; +`; + +const Wrapper = styled.div` + img, + video, + .ant-skeleton { + width: 100%; + height: 400px; + max-height: 70vh; + position: relative; + object-fit: cover; + background-color: #000; + } + .ant-skeleton { + h3, + li { + background-color: transparent; + } + } +`; + +const ReactWebcam = React.lazy(() => import("react-webcam")); + +export const ImageCaptureModal = (props: { + showModal: boolean; + onModalClose: () => void; + onImageCapture: (image: string) => void; +}) => { + const [errMessage, setErrMessage] = useState(""); + const [videoConstraints, setVideoConstraints] = useState({ + facingMode: "environment", + }); + const [modeList, setModeList] = useState([]); + const [dropdownShow, setDropdownShow] = useState(false); + const [imgSrc, setImgSrc] = useState(); + const webcamRef = useRef(null); + + useEffect(() => { + if (props.showModal) { + setImgSrc(""); + setErrMessage(""); + setVideoConstraints({ facingMode: "environment" }); + setDropdownShow(false); + } + }, [props.showModal]); + + const handleMediaErr = (err: any) => { + if (typeof err === "string") { + setErrMessage(err); + } else { + if (err.message === "getUserMedia is not implemented in this browser") { + setErrMessage(trans("scanner.errTip")); + } else { + setErrMessage(err.message); + } + } + }; + + const handleCapture = useCallback(() => { + const imageSrc = webcamRef.current?.getScreenshot?.(); + setImgSrc(imageSrc); + }, [webcamRef]); + + const getModeList = () => { + navigator.mediaDevices.enumerateDevices().then((data) => { + const videoData = data.filter((item) => item.kind === "videoinput"); + const faceModeList = videoData.map((item, index) => ({ + label: item.label || trans("scanner.camera", { index: index + 1 }), + key: item.deviceId, + })); + setModeList(faceModeList); + }); + }; + + return ( + + {!!errMessage ? ( + {errMessage} + ) : ( + props.showModal && ( + + {imgSrc ? ( + webcam + ) : ( + }> + + + )} + {imgSrc ? ( + + + + + ) : ( + + + setDropdownShow(value)} + popupRender={() => ( + { + setVideoConstraints({ deviceId: { exact: value.key } }); + setDropdownShow(false); + }} + /> + )} + > + + + + )} + + ) + )} + + ); +}; + +export default ImageCaptureModal; + + diff --git a/client/packages/lowcoder/src/comps/comps/fileComp/draggerUpload.tsx b/client/packages/lowcoder/src/comps/comps/fileComp/draggerUpload.tsx new file mode 100644 index 000000000..2f230ad38 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/fileComp/draggerUpload.tsx @@ -0,0 +1,326 @@ +import { default as AntdUpload } from "antd/es/upload"; +import { default as Button } from "antd/es/button"; +import { UploadFile, UploadChangeParam, UploadFileStatus, RcFile } from "antd/es/upload/interface"; +import { useState, useEffect } from "react"; +import styled, { css } from "styled-components"; +import { trans } from "i18n"; +import _ from "lodash"; +import { + changeValueAction, + CompAction, + multiChangeAction, +} from "lowcoder-core"; +import { hasIcon } from "comps/utils"; +import { messageInstance } from "lowcoder-design/src/components/GlobalInstances"; +import { resolveValue, resolveParsedValue, commonProps } from "./fileComp"; +import { FileStyleType, AnimationStyleType, heightCalculator, widthCalculator } from "comps/controls/styleControlConstants"; +import { ImageCaptureModal } from "./ImageCaptureModal"; +import { v4 as uuidv4 } from "uuid"; +import { checkIsMobile } from "@lowcoder-ee/util/commonUtils"; +import { darkenColor } from "components/colorSelect/colorUtils"; + +const IconWrapper = styled.span` + display: flex; +`; + +const getDraggerStyle = (style: FileStyleType) => { + return css` + .ant-upload-drag { + border-radius: ${style.radius}; + rotate: ${style.rotation}; + margin: ${style.margin}; + padding: ${style.padding}; + width: ${widthCalculator(style.margin)}; + height: ${heightCalculator(style.margin)}; + border-width: ${style.borderWidth}; + border-style: ${style.borderStyle}; + border-color: ${style.border}; + background: ${style.background}; + transition: all 0.3s; + .ant-upload-drag-container { + .ant-upload-text { + color: ${style.text}; + font-family: ${style.fontFamily}; + font-size: ${style.textSize}; + font-weight: ${style.textWeight}; + font-style: ${style.fontStyle}; + text-decoration: ${style.textDecoration}; + text-transform: ${style.textTransform}; + } + + .ant-upload-hint { + color: ${darkenColor(style.text, 0.3)}; + font-family: ${style.fontFamily}; + font-size: calc(${style.textSize} * 0.9); + } + + .ant-upload-drag-icon { + span { + color: ${style.accent}; + } + } + } + } + + .ant-upload-list { + .ant-upload-list-item { + border-color: ${style.border}; + + .ant-upload-list-item-name { + color: ${style.text}; + } + } + } + `; +}; + +const DragAreaOverlay = styled.div` + // make it position fixed, transparent and match the parent + position: fixed; + background-color: transparent; + width: 100%; + height: 100%; + z-index: 1; + top: 0; + left: 0; +`; + +const StyledDraggerUpload = styled(AntdUpload.Dragger)<{ + $auto: boolean; + $style: FileStyleType; + $animationStyle: AnimationStyleType; +}>` + height: ${(p) => (p.$auto ? "auto" : "100%")}; + position: relative; + + /* AntD wraps dragger + list in this */ + &.ant-upload-wrapper { + display: flex; + flex-direction: column; + height: 100%; + overflow: auto; /* allows list to be visible if it grows */ + } + + /* The drag area itself */ + .ant-upload-drag { + ${(p) => + !p.$auto && + ` + flex: 1 1 auto; + min-height: 120px; + min-width: 0; + `} + position: relative; + ${(props) => props.$animationStyle} + + .ant-upload-drag-container { + .ant-upload-drag-icon { + display: flex; + justify-content: center; + } + } + } + + /* The list sits below the dragger */ + .ant-upload-list { + ${(p) => + !p.$auto && + ` + flex: 0 0 auto; + `} + position: relative; + z-index: 2; + } + + /* Apply custom styling */ + ${(props) => props.$style && getDraggerStyle(props.$style)} +`; +interface DraggerUploadProps { + value: Array; + files: any[]; + fileType: string[]; + showUploadList: boolean; + disabled: boolean; + onEvent: (eventName: string) => Promise; + style: FileStyleType; + animationStyle: AnimationStyleType; + parseFiles: boolean; + parsedValue: Array; + prefixIcon: any; + suffixIcon: any; + forceCapture: boolean; + minSize: number; + maxSize: number; + maxFiles: number; + uploadType: "single" | "multiple" | "directory"; + text: string; + dragHintText?: string; + dispatch: (action: CompAction) => void; + autoHeight: boolean; + tabIndex?: number; +} + +export const DraggerUpload = (props: DraggerUploadProps) => { + const { dispatch, files, style, autoHeight, animationStyle } = props; + const [fileList, setFileList] = useState( + files.map((f) => ({ ...f, status: "done" })) as UploadFile[] + ); + const [showModal, setShowModal] = useState(false); + const isMobile = checkIsMobile(window.innerWidth); + + useEffect(() => { + if (files.length === 0 && fileList.length !== 0) { + setFileList([]); + } + }, [files]); + + const handleOnChange = (param: UploadChangeParam) => { + const uploadingFiles = param.fileList.filter((f) => f.status === "uploading"); + if (uploadingFiles.length !== 0) { + setFileList(param.fileList); + return; + } + + let maxFiles = props.maxFiles; + if (props.uploadType === "single") { + maxFiles = 1; + } else if (props.maxFiles <= 0) { + maxFiles = 100; + } + + const uploadedFiles = param.fileList.filter((f) => f.status === "done"); + + if (param.file.status === "removed") { + const index = props.files.findIndex((f) => f.uid === param.file.uid); + dispatch( + multiChangeAction({ + value: changeValueAction( + [...props.value.slice(0, index), ...props.value.slice(index + 1)], + false + ), + files: changeValueAction( + [...props.files.slice(0, index), ...props.files.slice(index + 1)], + false + ), + parsedValue: changeValueAction( + [...props.parsedValue.slice(0, index), ...props.parsedValue.slice(index + 1)], + false + ), + }) + ); + props.onEvent("change"); + } else { + const unresolvedValueIdx = Math.min(props.value.length, uploadedFiles.length); + const unresolvedParsedValueIdx = Math.min(props.parsedValue.length, uploadedFiles.length); + + Promise.all([ + resolveValue(uploadedFiles.slice(unresolvedValueIdx)), + resolveParsedValue(uploadedFiles.slice(unresolvedParsedValueIdx)), + ]).then(([value, parsedValue]) => { + dispatch( + multiChangeAction({ + value: changeValueAction([...props.value, ...value].slice(-maxFiles), false), + files: changeValueAction( + uploadedFiles + .map((file) => _.pick(file, ["uid", "name", "type", "size", "lastModified"])) + .slice(-maxFiles), + false + ), + ...(props.parseFiles + ? { + parsedValue: changeValueAction( + [...props.parsedValue, ...parsedValue].slice(-maxFiles), + false + ), + } + : {}), + }) + ); + props.onEvent("change"); + props.onEvent("parse"); + }); + } + + setFileList(uploadedFiles.slice(-maxFiles)); + }; + + return ( + <> + { + if (!file.size || file.size <= 0) { + messageInstance.error(`${file.name} ` + trans("file.fileEmptyErrorMsg")); + return AntdUpload.LIST_IGNORE; + } + + if ( + (!!props.minSize && file.size < props.minSize) || + (!!props.maxSize && file.size > props.maxSize) + ) { + messageInstance.error(`${file.name} ` + trans("file.fileSizeExceedErrorMsg")); + return AntdUpload.LIST_IGNORE; + } + return true; + }} + onChange={handleOnChange} + > +

+ {hasIcon(props.prefixIcon) ? ( + {props.prefixIcon} + ) : ( + + )} +

+

+ {props.text || trans("file.dragAreaText")} +

+

+ {props.dragHintText} +

+ {/* we need a custom overlay to add the onClick handler */} + {props.forceCapture && !isMobile && ( + { + e.preventDefault(); + e.stopPropagation(); + setShowModal(true); + }} + /> + )} + +
+ + setShowModal(false)} + onImageCapture={async (image) => { + setShowModal(false); + const res: Response = await fetch(image); + const blob: Blob = await res.blob(); + const file = new File([blob], "image.jpg", { type: "image/jpeg" }); + const fileUid = uuidv4(); + const uploadFile = { + uid: fileUid, + name: file.name, + type: file.type, + size: file.size, + lastModified: file.lastModified, + lastModifiedDate: (file as any).lastModifiedDate, + status: "done" as UploadFileStatus, + originFileObj: file as RcFile, + }; + handleOnChange({ file: uploadFile, fileList: [...fileList, uploadFile] }); + }} + /> + + ); +}; diff --git a/client/packages/lowcoder/src/comps/comps/fileComp/fileComp.tsx b/client/packages/lowcoder/src/comps/comps/fileComp/fileComp.tsx index 8580e2d5e..360a81556 100644 --- a/client/packages/lowcoder/src/comps/comps/fileComp/fileComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/fileComp/fileComp.tsx @@ -42,14 +42,12 @@ import { CommonNameConfig, NameConfig, withExposingConfigs } from "../../generat import { formDataChildren, FormDataPropertyView } from "../formComp/formDataConstants"; import { messageInstance } from "lowcoder-design/src/components/GlobalInstances"; import { CustomModal } from "lowcoder-design"; - -import React, { useContext } from "react"; +import { DraggerUpload } from "./draggerUpload"; +import { ImageCaptureModal } from "./ImageCaptureModal"; +import { useContext } from "react"; import { EditorContext } from "comps/editorState"; -import type { ItemType } from "antd/es/menu/interface"; -import Skeleton from "antd/es/skeleton"; -import Menu from "antd/es/menu"; -import Flex from "antd/es/flex"; import { checkIsMobile } from "@lowcoder-ee/util/commonUtils"; +import { AutoHeightControl } from "@lowcoder-ee/comps/controls/autoHeightControl"; const FileSizeControl = codeControl((value) => { if (typeof value === "number") { @@ -131,7 +129,7 @@ const commonValidationFields = (children: RecordConstructorToComp & { uploadType: "single" | "multiple" | "directory"; } @@ -211,45 +209,9 @@ const IconWrapper = styled.span` display: flex; `; -const CustomModalStyled = styled(CustomModal)` - top: 10vh; - .react-draggable { - max-width: 100%; - width: 500px; - - video { - width: 100%; - } - } -`; - -const Error = styled.div` - color: #f5222d; - height: 100px; - width: 100%; - display: flex; - align-items: center; - justify-content: center; -`; - -const Wrapper = styled.div` - img, - video, - .ant-skeleton { - width: 100%; - height: 400px; - max-height: 70vh; - position: relative; - object-fit: cover; - background-color: #000; - } - .ant-skeleton { - h3, - li { - background-color: transparent; - } - } -`; +const CustomModalStyled = styled(CustomModal)``; +const Error = styled.div``; +const Wrapper = styled.div``; export function resolveValue(files: UploadFile[]) { return Promise.all( @@ -290,163 +252,13 @@ export function resolveParsedValue(files: UploadFile[]) { ); } -const ReactWebcam = React.lazy(() => import("react-webcam")); - -const ImageCaptureModal = (props: { - showModal: boolean, - onModalClose: () => void; - onImageCapture: (image: string) => void; -}) => { - const [errMessage, setErrMessage] = useState(""); - const [videoConstraints, setVideoConstraints] = useState({ - facingMode: "environment", - }); - const [modeList, setModeList] = useState([]); - const [dropdownShow, setDropdownShow] = useState(false); - const [imgSrc, setImgSrc] = useState(); - const webcamRef = useRef(null); - - useEffect(() => { - if (props.showModal) { - setImgSrc(''); - setErrMessage(''); - } - }, [props.showModal]); - - const handleMediaErr = (err: any) => { - if (typeof err === "string") { - setErrMessage(err); - } else { - if (err.message === "getUserMedia is not implemented in this browser") { - setErrMessage(trans("scanner.errTip")); - } else { - setErrMessage(err.message); - } - } - }; - - const handleCapture = useCallback(() => { - const imageSrc = webcamRef.current?.getScreenshot?.(); - setImgSrc(imageSrc); - }, [webcamRef]); - - const getModeList = () => { - navigator.mediaDevices.enumerateDevices().then((data) => { - const videoData = data.filter((item) => item.kind === "videoinput"); - const faceModeList = videoData.map((item, index) => ({ - label: item.label || trans("scanner.camera", { index: index + 1 }), - key: item.deviceId, - })); - setModeList(faceModeList); - }); - }; - - return ( - - {!!errMessage ? ( - {errMessage} - ) : ( - props.showModal && ( - - {imgSrc - ? webcam - : ( - }> - - - ) - } - {imgSrc - ? ( - - - - - ) - : ( - - - setDropdownShow(value)} - popupRender={() => ( - - setVideoConstraints({ ...videoConstraints, deviceId: value.key }) - } - /> - )} - > - - - - ) - } - - ) - )} - - ) -} +// ImageCaptureModal moved to its own file for reuse const Upload = ( props: RecordConstructorToView & { uploadType: "single" | "multiple" | "directory"; text: string; + dragHintText?: string; dispatch: (action: CompAction) => void; forceCapture: boolean; tabIndex?: number; @@ -619,25 +431,48 @@ const UploadTypeOptions = [ { label: trans("file.directory"), value: "directory" }, ] as const; +const UploadModeOptions = [ + { label: trans("file.button"), value: "button" }, + { label: trans("file.dragArea"), value: "dragArea" }, +] as const; + const childrenMap = { text: withDefault(StringControl, trans("file.upload")), + dragHintText: withDefault(StringControl, trans("file.dragAreaHint")), uploadType: dropdownControl(UploadTypeOptions, "single"), + uploadMode: dropdownControl(UploadModeOptions, "button"), + autoHeight: withDefault(AutoHeightControl, "auto"), tabIndex: NumberControl, ...commonChildren, ...formDataChildren, }; let FileTmpComp = new UICompBuilder(childrenMap, (props, dispatch) => { - return( - - )}) + const uploadMode = props.uploadMode; + const autoHeight = props.autoHeight; + + if (uploadMode === "dragArea") { + return ; + } + + return ; +}) .setPropertyViewFn((children) => ( <>
{children.text.propertyView({ label: trans("text"), })} + {children.uploadMode.propertyView({ + label: trans("file.uploadMode"), + radioButton: true, + })} + {children.uploadMode.getView() === "dragArea" && + children.dragHintText.propertyView({ + label: trans("file.dragHintText"), + })} {children.uploadType.propertyView({ label: trans("file.uploadType") })} + {children.autoHeight.getPropertyView()}
@@ -693,7 +528,15 @@ let FileTmpComp = new UICompBuilder(childrenMap, (props, dispatch) => { )) .build(); -FileTmpComp = withMethodExposing(FileTmpComp, [ + class FileImplComp extends FileTmpComp { + override autoHeight(): boolean { + // Both button and dragArea modes should respect the autoHeight setting + const h = this.children.autoHeight.getView(); + return h; + } + } + +const FileWithMethods = withMethodExposing(FileImplComp, [ { method: { name: "clearValue", @@ -711,7 +554,7 @@ FileTmpComp = withMethodExposing(FileTmpComp, [ }, ]); -export const FileComp = withExposingConfigs(FileTmpComp, [ +export const FileComp = withExposingConfigs(FileWithMethods, [ new NameConfig("value", trans("file.filesValueDesc")), new NameConfig( "files", diff --git a/client/packages/lowcoder/src/i18n/locales/en.ts b/client/packages/lowcoder/src/i18n/locales/en.ts index 80f667288..43311e49d 100644 --- a/client/packages/lowcoder/src/i18n/locales/en.ts +++ b/client/packages/lowcoder/src/i18n/locales/en.ts @@ -1925,6 +1925,12 @@ export const en = { "usePhoto": "Use Photo", "retakePhoto": "Retake Photo", "capture": "Capture", + "button": "Button", + "dragArea": "Drag Area", + "uploadMode": "Upload Mode", + "dragAreaText": "Click or drag file to this area to upload", + "dragAreaHint": "Support for a single or bulk upload. Strictly prohibited from uploading company data or other band files.", + "dragHintText": "Hint Text", }, "date": { "format": "Format",