diff --git a/client/packages/lowcoder/src/comps/comps/fileComp/ImageCaptureModal.tsx b/client/packages/lowcoder/src/comps/comps/fileComp/ImageCaptureModal.tsx index ea35e2c52..350a1d07b 100644 --- a/client/packages/lowcoder/src/comps/comps/fileComp/ImageCaptureModal.tsx +++ b/client/packages/lowcoder/src/comps/comps/fileComp/ImageCaptureModal.tsx @@ -1,4 +1,4 @@ -import React, { Suspense, useCallback, useEffect, useRef, useState } from "react"; +import React, { Suspense, useCallback, useEffect, useMemo, 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"; @@ -8,6 +8,7 @@ import Flex from "antd/es/flex"; import styled from "styled-components"; import { trans } from "i18n"; import { CustomModal } from "lowcoder-design"; +import { CaptureResolution, RESOLUTION_CONSTRAINTS } from "./fileComp"; const CustomModalStyled = styled(CustomModal)` top: 10vh; @@ -53,23 +54,32 @@ const ReactWebcam = React.lazy(() => import("react-webcam")); export const ImageCaptureModal = (props: { showModal: boolean; + captureResolution?: CaptureResolution; onModalClose: () => void; onImageCapture: (image: string) => void; }) => { const [errMessage, setErrMessage] = useState(""); - const [videoConstraints, setVideoConstraints] = useState({ - facingMode: "environment", - }); + const [selectedDeviceId, setSelectedDeviceId] = useState(null); const [modeList, setModeList] = useState([]); const [dropdownShow, setDropdownShow] = useState(false); const [imgSrc, setImgSrc] = useState(); const webcamRef = useRef(null); + const resolution = props.captureResolution ?? "auto"; + const resolutionSize = RESOLUTION_CONSTRAINTS[resolution] ?? {}; + + const videoConstraints = useMemo(() => { + const base: MediaTrackConstraints = selectedDeviceId + ? { deviceId: { exact: selectedDeviceId } } + : { facingMode: "environment" }; + return { ...base, ...resolutionSize }; + }, [selectedDeviceId, resolutionSize]); + useEffect(() => { if (props.showModal) { setImgSrc(""); setErrMessage(""); - setVideoConstraints({ facingMode: "environment" }); + setSelectedDeviceId(null); setDropdownShow(false); } }, [props.showModal]); @@ -125,6 +135,8 @@ export const ImageCaptureModal = (props: { ref={webcamRef} onUserMediaError={handleMediaErr} screenshotFormat="image/jpeg" + screenshotQuality={1} + forceScreenshotSourceSize videoConstraints={videoConstraints} /> @@ -172,7 +184,7 @@ export const ImageCaptureModal = (props: { { - setVideoConstraints({ deviceId: { exact: value.key } }); + setSelectedDeviceId(value.key); setDropdownShow(false); }} /> diff --git a/client/packages/lowcoder/src/comps/comps/fileComp/draggerUpload.tsx b/client/packages/lowcoder/src/comps/comps/fileComp/draggerUpload.tsx index 2f230ad38..19aef9aec 100644 --- a/client/packages/lowcoder/src/comps/comps/fileComp/draggerUpload.tsx +++ b/client/packages/lowcoder/src/comps/comps/fileComp/draggerUpload.tsx @@ -1,7 +1,7 @@ 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 { useState, useMemo } from "react"; import styled, { css } from "styled-components"; import { trans } from "i18n"; import _ from "lodash"; @@ -11,8 +11,7 @@ import { multiChangeAction, } from "lowcoder-core"; import { hasIcon } from "comps/utils"; -import { messageInstance } from "lowcoder-design/src/components/GlobalInstances"; -import { resolveValue, resolveParsedValue, commonProps } from "./fileComp"; +import { resolveValue, resolveParsedValue, commonProps, validateFile, CaptureResolution } from "./fileComp"; import { FileStyleType, AnimationStyleType, heightCalculator, widthCalculator } from "comps/controls/styleControlConstants"; import { ImageCaptureModal } from "./ImageCaptureModal"; import { v4 as uuidv4 } from "uuid"; @@ -149,9 +148,11 @@ interface DraggerUploadProps { prefixIcon: any; suffixIcon: any; forceCapture: boolean; + captureResolution: CaptureResolution; minSize: number; maxSize: number; maxFiles: number; + fileNamePattern: string; uploadType: "single" | "multiple" | "directory"; text: string; dragHintText?: string; @@ -162,25 +163,27 @@ interface DraggerUploadProps { export const DraggerUpload = (props: DraggerUploadProps) => { const { dispatch, files, style, autoHeight, animationStyle } = props; - const [fileList, setFileList] = useState( - files.map((f) => ({ ...f, status: "done" })) as UploadFile[] - ); + // Track only files currently being uploaded (not yet in props.files) + const [uploadingFiles, setUploadingFiles] = useState([]); const [showModal, setShowModal] = useState(false); const isMobile = checkIsMobile(window.innerWidth); - useEffect(() => { - if (files.length === 0 && fileList.length !== 0) { - setFileList([]); - } - }, [files]); + // Derive fileList from props.files (source of truth) + currently uploading files + const fileList = useMemo(() => [ + ...(files.map((f) => ({ ...f, status: "done" as const })) as UploadFile[]), + ...uploadingFiles, + ], [files, uploadingFiles]); const handleOnChange = (param: UploadChangeParam) => { - const uploadingFiles = param.fileList.filter((f) => f.status === "uploading"); - if (uploadingFiles.length !== 0) { - setFileList(param.fileList); + const currentlyUploading = param.fileList.filter((f) => f.status === "uploading"); + if (currentlyUploading.length !== 0) { + setUploadingFiles(currentlyUploading); return; } + // Clear uploading state when all uploads complete + setUploadingFiles([]); + let maxFiles = props.maxFiles; if (props.uploadType === "single") { maxFiles = 1; @@ -240,8 +243,6 @@ export const DraggerUpload = (props: DraggerUploadProps) => { props.onEvent("parse"); }); } - - setFileList(uploadedFiles.slice(-maxFiles)); }; return ( @@ -254,21 +255,11 @@ export const DraggerUpload = (props: DraggerUploadProps) => { $auto={autoHeight} capture={props.forceCapture} openFileDialogOnClick={!(props.forceCapture && !isMobile)} - beforeUpload={(file) => { - 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; - }} + beforeUpload={(file) => validateFile(file, { + minSize: props.minSize, + maxSize: props.maxSize, + fileNamePattern: props.fileNamePattern, + })} onChange={handleOnChange} >

@@ -301,6 +292,7 @@ export const DraggerUpload = (props: DraggerUploadProps) => { setShowModal(false)} onImageCapture={async (image) => { setShowModal(false); diff --git a/client/packages/lowcoder/src/comps/comps/fileComp/fileComp.tsx b/client/packages/lowcoder/src/comps/comps/fileComp/fileComp.tsx index 360a81556..b804c5341 100644 --- a/client/packages/lowcoder/src/comps/comps/fileComp/fileComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/fileComp/fileComp.tsx @@ -24,7 +24,7 @@ import { RecordConstructorToView, } from "lowcoder-core"; import { UploadRequestOption } from "rc-upload/lib/interface"; -import { Suspense, useCallback, useEffect, useRef, useState } from "react"; +import { Suspense, useCallback, useMemo, useRef, useState } from "react"; import styled, { css } from "styled-components"; import { JSONObject, JSONValue } from "../../../util/jsonTypes"; import { BoolControl, BoolPureControl } from "../../controls/boolControl"; @@ -97,6 +97,23 @@ const validationChildren = { minSize: FileSizeControl, maxSize: FileSizeControl, maxFiles: NumberControl, + fileNamePattern: StringControl, +}; + +export type CaptureResolution = "auto" | "1080p" | "720p" | "480p"; + +export const CaptureResolutionOptions = [ + { label: trans("file.captureResolutionAuto"), value: "auto" }, + { label: trans("file.captureResolution1080p"), value: "1080p" }, + { label: trans("file.captureResolution720p"), value: "720p" }, + { label: trans("file.captureResolution480p"), value: "480p" }, +] as const; + +export const RESOLUTION_CONSTRAINTS: Record = { + auto: {}, + "1080p": { width: 1920, height: 1080 }, + "720p": { width: 1280, height: 720 }, + "480p": { width: 640, height: 480 }, }; const commonChildren = { @@ -113,6 +130,7 @@ const commonChildren = { prefixIcon: withDefault(IconControl, "/icon:solid/arrow-up-from-bracket"), suffixIcon: IconControl, forceCapture: BoolControl, + captureResolution: dropdownControl(CaptureResolutionOptions, "auto"), ...validationChildren, }; @@ -127,6 +145,11 @@ const commonValidationFields = (children: RecordConstructorToComp options.onSuccess && options.onSuccess({}), // Override the default upload logic and do not upload to the specified server }); +export interface FileValidationOptions { + minSize?: number; + maxSize?: number; + fileNamePattern?: string; +} + + +export const validateFile = ( + file: { name: string; size?: number }, + options: FileValidationOptions +): boolean | typeof AntdUpload.LIST_IGNORE => { + // Empty file validation + if (!file.size || file.size <= 0) { + messageInstance.error(`${file.name} ` + trans("file.fileEmptyErrorMsg")); + return AntdUpload.LIST_IGNORE; + } + + // File size validation + if ( + (!!options.minSize && file.size < options.minSize) || + (!!options.maxSize && file.size > options.maxSize) + ) { + messageInstance.error(`${file.name} ` + trans("file.fileSizeExceedErrorMsg")); + return AntdUpload.LIST_IGNORE; + } + + // File name pattern validation + if (options.fileNamePattern) { + try { + const pattern = new RegExp(options.fileNamePattern); + if (!pattern.test(file.name)) { + messageInstance.error(`${file.name} ` + trans("file.fileNamePatternErrorMsg")); + return AntdUpload.LIST_IGNORE; + } + } catch (e) { + messageInstance.error(trans("file.invalidFileNamePatternMsg", { error: String(e) })); + return AntdUpload.LIST_IGNORE; + } + } + + return true; +}; + const getStyle = (style: FileStyleType) => { return css` .ant-btn { @@ -265,29 +331,32 @@ const Upload = ( }, ) => { const { dispatch, files, style } = props; - const [fileList, setFileList] = useState( - files.map((f) => ({ ...f, status: "done" })) as UploadFile[] - ); + // Track only files currently being uploaded (not yet in props.files) + const [uploadingFiles, setUploadingFiles] = useState([]); const [showModal, setShowModal] = useState(false); const isMobile = checkIsMobile(window.innerWidth); - useEffect(() => { - if (files.length === 0 && fileList.length !== 0) { - setFileList([]); - } - }, [files]); + // Derive fileList from props.files (source of truth) + currently uploading files + const fileList = useMemo(() => [ + ...(files.map((f) => ({ ...f, status: "done" as const })) as UploadFile[]), + ...uploadingFiles, + ], [files, uploadingFiles]); + // 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"); + const currentlyUploading = 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); + if (currentlyUploading.length !== 0) { + setUploadingFiles(currentlyUploading); return; } + // Clear uploading state when all uploads complete + setUploadingFiles([]); + let maxFiles = props.maxFiles; if (props.uploadType === "single") { maxFiles = 1; @@ -348,8 +417,6 @@ const Upload = ( props.onEvent("parse"); }); } - - setFileList(uploadedFiles.slice(-maxFiles)); }; return ( @@ -360,21 +427,11 @@ const Upload = ( {...commonProps(props)} $style={style} fileList={fileList} - beforeUpload={(file) => { - 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; - }} + beforeUpload={(file) => validateFile(file, { + minSize: props.minSize, + maxSize: props.maxSize, + fileNamePattern: props.fileNamePattern, + })} onChange={handleOnChange} > @@ -401,6 +458,7 @@ const Upload = ( setShowModal(false)} onImageCapture={async (image) => { setShowModal(false); @@ -508,6 +566,10 @@ let FileTmpComp = new UICompBuilder(childrenMap, (props, dispatch) => { label: trans("file.forceCapture"), tooltip: trans("file.forceCaptureTooltip") })} + {children.forceCapture.getView() && children.captureResolution.propertyView({ + label: trans("file.captureResolution"), + tooltip: trans("file.captureResolutionTooltip"), + })} {children.showUploadList.propertyView({ label: trans("file.showUploadList") })} {children.parseFiles.propertyView({ label: trans("file.parseFiles"), @@ -552,6 +614,40 @@ const FileWithMethods = withMethodExposing(FileImplComp, [ }) ), }, + { + method: { + name: "clearValueAt", + description: trans("file.clearValueAtDesc"), + params: [{ name: "index", type: "number" }], + }, + execute: (comp, params) => { + const index = params[0] as number; + const value = comp.children.value.getView(); + const files = comp.children.files.getView(); + const parsedValue = comp.children.parsedValue.getView(); + + if (index < 0 || index >= files.length) { + return; + } + + comp.dispatch( + multiChangeAction({ + value: changeValueAction( + [...value.slice(0, index), ...value.slice(index + 1)], + false + ), + files: changeValueAction( + [...files.slice(0, index), ...files.slice(index + 1)], + false + ), + parsedValue: changeValueAction( + [...parsedValue.slice(0, index), ...parsedValue.slice(index + 1)], + false + ), + }) + ); + }, + }, ]); export const FileComp = withExposingConfigs(FileWithMethods, [ diff --git a/client/packages/lowcoder/src/i18n/locales/en.ts b/client/packages/lowcoder/src/i18n/locales/en.ts index 8cbc26404..329724bce 100644 --- a/client/packages/lowcoder/src/i18n/locales/en.ts +++ b/client/packages/lowcoder/src/i18n/locales/en.ts @@ -1934,6 +1934,7 @@ export const en = { "filesValueDesc": "The Contents of the Currently Uploaded File Are Base64 Encoded", "filesDesc": "List of the Current Uploaded Files. For Details, Refer to", "clearValueDesc": "Clear All Files", + "clearValueAtDesc": "Clear File at Index", "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.", @@ -1948,6 +1949,17 @@ export const en = { "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 banned files.", "dragHintText": "Hint Text", + "fileNamePattern": "File Name Pattern", + "fileNamePatternTooltip": "A regular expression pattern to validate file names (e.g., '^[a-zA-Z0-9_-]+\\.[a-z]+$' for alphanumeric names). Leave empty to allow all file names.", + "fileNamePatternPlaceholder": "^[a-zA-Z0-9_-]+\\.[a-z]+$", + "fileNamePatternErrorMsg": "Upload Failed. The File Name Does Not Match the Required Pattern.", + "invalidFileNamePatternMsg": "Invalid File Name Pattern: {error}", + "captureResolution": "Capture Resolution", + "captureResolutionTooltip": "Set the camera resolution for image capture. Higher resolutions produce better quality but may not be supported by all cameras.", + "captureResolutionAuto": "Auto (Camera Default)", + "captureResolution1080p": "1080p", + "captureResolution720p": "720p", + "captureResolution480p": "480p", }, "date": { "format": "Format",