diff --git a/client/packages/lowcoder/package.json b/client/packages/lowcoder/package.json index d5ef8d284..427509574 100644 --- a/client/packages/lowcoder/package.json +++ b/client/packages/lowcoder/package.json @@ -86,6 +86,7 @@ "react-sortable-hoc": "^2.0.0", "react-test-renderer": "^18.1.0", "react-use": "^17.3.2", + "react-webcam": "^7.2.0", "really-relaxed-json": "^0.3.2", "redux-devtools-extension": "^2.13.9", "redux-saga": "^1.1.3", diff --git a/client/packages/lowcoder/src/comps/comps/fileComp/fileComp.tsx b/client/packages/lowcoder/src/comps/comps/fileComp/fileComp.tsx index 5ce11399e..0d4920bbd 100644 --- a/client/packages/lowcoder/src/comps/comps/fileComp/fileComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/fileComp/fileComp.tsx @@ -1,6 +1,7 @@ import { default as Button } from "antd/es/button"; import { default as AntdUpload } from "antd/es/upload"; -import { UploadFile, UploadProps, UploadChangeParam } from "antd/es/upload/interface"; +import { default as Dropdown } from "antd/es/dropdown"; +import { UploadFile, UploadProps, UploadChangeParam, UploadFileStatus, RcFile } from "antd/es/upload/interface"; import { Buffer } from "buffer"; import { darkenColor } from "components/colorSelect/colorUtils"; import { Section, sectionNames } from "components/Section"; @@ -22,7 +23,7 @@ import { RecordConstructorToView, } from "lowcoder-core"; import { UploadRequestOption } from "rc-upload/lib/interface"; -import { useEffect, useState } from "react"; +import { Suspense, useCallback, useEffect, useRef, useState } from "react"; import styled, { css } from "styled-components"; import { JSONObject, JSONValue } from "../../../util/jsonTypes"; import { BoolControl, BoolPureControl } from "../../controls/boolControl"; @@ -39,9 +40,15 @@ import { stateComp, UICompBuilder, withDefault } from "../../generators"; import { CommonNameConfig, NameConfig, withExposingConfigs } from "../../generators/withExposing"; 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 { 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/index.sdk"; const FileSizeControl = codeControl((value) => { if (typeof value === "number") { @@ -106,6 +113,7 @@ const commonChildren = { parsedValue: stateComp>([]), prefixIcon: withDefault(IconControl, "/icon:solid/arrow-up-from-bracket"), suffixIcon: IconControl, + forceCapture: BoolControl, ...validationChildren, }; @@ -202,6 +210,46 @@ 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; + } + } +`; + export function resolveValue(files: UploadFile[]) { return Promise.all( files.map( @@ -241,17 +289,174 @@ 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)} + dropdownRender={() => ( + + setVideoConstraints({ ...videoConstraints, deviceId: value.key }) + } + /> + )} + > + + + + ) + } + + ) + )} + + ) +} + const Upload = ( props: RecordConstructorToView & { uploadType: "single" | "multiple" | "directory"; text: string; dispatch: (action: CompAction) => void; - } + forceCapture: boolean; + }, ) => { const { dispatch, files, style } = 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([]); @@ -259,110 +464,146 @@ const Upload = ( }, [files]); // chrome86 bug: button children should not contain only empty span const hasChildren = hasIcon(props.prefixIcon) || !!props.text || hasIcon(props.suffixIcon); + + const handleOnChange = (param: UploadChangeParam) => { + const uploadingFiles = param.fileList.filter((f) => f.status === "uploading"); + // the onChange callback will be executed when the state of the antd upload file changes. + // so make a trick logic: the file list with loading will not be processed + 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; // limit 100 currently + } + + 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); + + // After all files are processed, perform base64 encoding on the latest file list uniformly + 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={(param: UploadChangeParam) => { - const uploadingFiles = param.fileList.filter((f) => f.status === "uploading"); - // the onChange callback will be executed when the state of the antd upload file changes. - // so make a trick logic: the file list with loading will not be processed - 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; // limit 100 currently - } - - 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); - - // After all files are processed, perform base64 encoding on the latest file list uniformly - 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"); - }); - } + <> + { + if (!file.size || file.size <= 0) { + messageInstance.error(`${file.name} ` + trans("file.fileEmptyErrorMsg")); + return AntdUpload.LIST_IGNORE; + } - setFileList(uploadedFiles.slice(-maxFiles)); - }} - > - - + 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} + + > + + + + 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 = uuid.v4(); + 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: [uploadFile]}) + }} + /> + ); }; @@ -419,6 +660,10 @@ let FileTmpComp = new UICompBuilder(childrenMap, (props, dispatch) => { })} {children.prefixIcon.propertyView({ label: trans("button.prefixIcon") })} {children.suffixIcon.propertyView({ label: trans("button.suffixIcon") })} + {children.forceCapture.propertyView({ + label: trans("file.forceCapture"), + tooltip: trans("file.forceCaptureTooltip") + })} {children.showUploadList.propertyView({ label: trans("file.showUploadList") })} {children.parseFiles.propertyView({ label: trans("file.parseFiles"), diff --git a/client/packages/lowcoder/src/i18n/locales/en.ts b/client/packages/lowcoder/src/i18n/locales/en.ts index d30bd72d4..f3ea57af9 100644 --- a/client/packages/lowcoder/src/i18n/locales/en.ts +++ b/client/packages/lowcoder/src/i18n/locales/en.ts @@ -1781,7 +1781,12 @@ export const en = { "clearValueDesc": "Clear All Files", "parseFiles": "Parse Files", "parsedValueTooltip1": "If parseFiles Is True, Upload Files Will Parse to Object, Array, or String. Parsed Data Can Be Accessed via the parsedValue Array.", - "parsedValueTooltip2": "Supports Excel, JSON, CSV, and Text Files. Other Formats Will Return Null." + "parsedValueTooltip2": "Supports Excel, JSON, CSV, and Text Files. Other Formats Will Return Null.", + "forceCapture": "Force Capture", + "forceCaptureTooltip": "Instead of upload, capture image from camera", + "usePhoto": "Use Photo", + "retakePhoto": "Retake Photo", + "capture": "Capture", }, "date": { "format": "Format", diff --git a/client/yarn.lock b/client/yarn.lock index 727ee6b7c..2b529c21e 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -14077,6 +14077,7 @@ coolshapes-react@lowcoder-org/coolshapes-react: react-sortable-hoc: ^2.0.0 react-test-renderer: ^18.1.0 react-use: ^17.3.2 + react-webcam: ^7.2.0 really-relaxed-json: ^0.3.2 redux-devtools-extension: ^2.13.9 redux-saga: ^1.1.3 @@ -18101,6 +18102,16 @@ coolshapes-react@lowcoder-org/coolshapes-react: languageName: node linkType: hard +"react-webcam@npm:^7.2.0": + version: 7.2.0 + resolution: "react-webcam@npm:7.2.0" + peerDependencies: + react: ">=16.2.0" + react-dom: ">=16.2.0" + checksum: 55aecc9e33ea711525f9b68a7c14c9cf4089777b9edd6d9c93813568d701a101f3da09ffd7df04b64eca8c9783a37830522376049589699c6015555ec8d7ccd8 + languageName: node + linkType: hard + "react@npm:^18.2.0": version: 18.3.1 resolution: "react@npm:18.3.1"