├── .github
└── workflows
│ └── chromatic.yml
├── .gitignore
├── README.md
├── package.json
├── packages
├── components
│ ├── .babelrc
│ ├── .npmignore
│ ├── .storybook
│ │ ├── main.ts
│ │ └── preview.ts
│ ├── README.md
│ ├── package.json
│ ├── postcss.config.js
│ ├── rollup.config.js
│ ├── src
│ │ ├── actions
│ │ │ ├── FileDeleteAllButton.tsx
│ │ │ ├── FileItemActions.tsx
│ │ │ ├── FileUploadAllButton.tsx
│ │ │ └── index.ts
│ │ ├── components
│ │ │ ├── FileUploadContainer.tsx
│ │ │ ├── FileUploadControl.tsx
│ │ │ ├── camera
│ │ │ │ ├── custom-react-webcam.tsx
│ │ │ │ ├── frames
│ │ │ │ │ ├── WebcamFrameA4.tsx
│ │ │ │ │ └── index.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── use-webcam.tsx
│ │ │ ├── file-drop
│ │ │ │ ├── FileDropArea.tsx
│ │ │ │ ├── FileDropLarge.tsx
│ │ │ │ ├── FileDropSmall.tsx
│ │ │ │ └── index.ts
│ │ │ ├── file-list
│ │ │ │ ├── FileList.tsx
│ │ │ │ ├── FileListActions.tsx
│ │ │ │ ├── FileListContainer.tsx
│ │ │ │ ├── FileListItem.tsx
│ │ │ │ └── index.ts
│ │ │ └── index.ts
│ │ ├── index.ts
│ │ ├── lib
│ │ │ └── cn.ts
│ │ ├── loaders
│ │ │ ├── FileLoaderActions.tsx
│ │ │ ├── FileLoaderCamera.tsx
│ │ │ ├── FileLoaderClipboard.tsx
│ │ │ ├── FileLoaderFileSystem.tsx
│ │ │ └── index.ts
│ │ ├── stories
│ │ │ ├── FileUploadControlPreProcess.stories.tsx
│ │ │ ├── FileUploadControlPreProcess.tsx
│ │ │ ├── FileUploadControlSimple.stories.tsx
│ │ │ ├── FileUploadControlSimple.tsx
│ │ │ ├── FileUploadControlSmall.stories.tsx
│ │ │ ├── FileUploadControlSmall.tsx
│ │ │ ├── FileUploadControlWithProgress.stories.tsx
│ │ │ ├── FileUploadControlWithProgress.tsx
│ │ │ └── assets
│ │ │ │ ├── accessibility.png
│ │ │ │ ├── accessibility.svg
│ │ │ │ ├── addon-library.png
│ │ │ │ ├── assets.png
│ │ │ │ ├── avif-test-image.avif
│ │ │ │ ├── context.png
│ │ │ │ ├── discord.svg
│ │ │ │ ├── docs.png
│ │ │ │ ├── figma-plugin.png
│ │ │ │ ├── github.svg
│ │ │ │ ├── share.png
│ │ │ │ ├── styling.png
│ │ │ │ ├── testing.png
│ │ │ │ ├── theming.png
│ │ │ │ ├── tutorials.svg
│ │ │ │ └── youtube.svg
│ │ ├── styles
│ │ │ └── tailwind.css
│ │ ├── translations
│ │ │ ├── de.json
│ │ │ └── en.json
│ │ ├── types.ts
│ │ └── ui
│ │ │ ├── button
│ │ │ ├── Button.tsx
│ │ │ └── index.ts
│ │ │ ├── card
│ │ │ ├── Card.tsx
│ │ │ └── index.ts
│ │ │ ├── dialog
│ │ │ ├── Dialog.tsx
│ │ │ └── index.ts
│ │ │ ├── icons
│ │ │ └── index.ts
│ │ │ ├── image-zoom
│ │ │ └── index.ts
│ │ │ ├── progress
│ │ │ ├── Progress.tsx
│ │ │ └── index.ts
│ │ │ ├── select
│ │ │ ├── Select.tsx
│ │ │ └── index.ts
│ │ │ ├── seperator
│ │ │ ├── Seperator.tsx
│ │ │ └── index.ts
│ │ │ └── tooltip
│ │ │ ├── Tooltip.tsx
│ │ │ └── index.ts
│ ├── tailwind.config.js
│ └── tsconfig.json
├── core
│ ├── .babelrc
│ ├── .npmignore
│ ├── README.md
│ ├── package.json
│ ├── rollup.config.js
│ ├── src
│ │ ├── hooks
│ │ │ ├── index.ts
│ │ │ ├── useFileOperations.ts
│ │ │ ├── useFileValidation.ts
│ │ │ ├── useUploadActions.ts
│ │ │ └── useUploadStatus.ts
│ │ ├── index.ts
│ │ ├── providers
│ │ │ ├── UploadedFilesManager.tsx
│ │ │ ├── UploadedFilesProvider.tsx
│ │ │ └── index.ts
│ │ └── types.ts
│ └── tsconfig.json
├── processors
│ ├── README.md
│ ├── package.json
│ ├── src
│ │ ├── canvas-utils.ts
│ │ ├── index.ts
│ │ ├── load-image.ts
│ │ ├── pdf
│ │ │ ├── generate-images-from-pdf.ts
│ │ │ └── process-pdf-to-jpeg.ts
│ │ └── types.ts
│ ├── tsconfig.json
│ └── webpack.config.js
└── shared
│ ├── .babelrc
│ ├── package.json
│ ├── rollup.config.js
│ ├── src
│ ├── hooks
│ │ ├── index.ts
│ │ ├── use-mobile-detect.ts
│ │ └── use-state-machine.ts
│ ├── index.ts
│ ├── types.ts
│ └── utils
│ │ ├── api-mocker.ts
│ │ ├── date
│ │ └── index.ts
│ │ ├── file-types.ts
│ │ ├── format-bytes.ts
│ │ ├── generate-id.ts
│ │ ├── image-processing
│ │ ├── blob-to-base64.ts
│ │ ├── canvas-utils.ts
│ │ ├── index.ts
│ │ └── load-image.ts
│ │ ├── index.ts
│ │ └── is-filedrop-error.ts
│ └── tsconfig.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
└── tsconfig.json
/.github/workflows/chromatic.yml:
--------------------------------------------------------------------------------
1 | # Workflow name
2 | name: "Chromatic Deployment"
3 |
4 | # Event for the workflow
5 | on: push
6 |
7 | # List of jobs
8 | jobs:
9 | chromatic:
10 | name: "Run Chromatic"
11 | runs-on: ubuntu-latest
12 | # Job steps
13 | steps:
14 | - uses: actions/checkout@v4
15 | with:
16 | fetch-depth: 0
17 | - uses: pnpm/action-setup@v2
18 | with:
19 | version: 8
20 | - uses: actions/setup-node@v3
21 | with:
22 | node-version: 18
23 | cache: "pnpm"
24 | - run: pnpm install
25 | #👇 Build all packages before running Chromatic
26 | - name: Build packages
27 | run: |
28 | pnpm --filter "@osmandvc/react-upload-control-shared" build
29 | pnpm --filter "@osmandvc/react-upload-control-processors" build
30 | pnpm --filter "@osmandvc/react-upload-control" build
31 | pnpm --filter "@osmandvc/react-upload-control-components" build
32 | #👇 Adds Chromatic as a step in the workflow
33 | - uses: chromaui/action@latest
34 | # Options required for Chromatic's GitHub Action
35 | with:
36 | #👇 Chromatic projectToken, see https://storybook.js.org/tutorials/intro-to-storybook/svelte/en/deploy/ to obtain it
37 | projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
38 | token: ${{ secrets.GITHUB_TOKEN }}
39 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | storybook-static
4 |
5 | *.tgz
6 | *.log
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Upload Control
2 |
3 | [](https://opensource.org/licenses/MIT)
4 |
5 | A modern, flexible, easy-to-use file upload solution for React applications. This monorepo contains the following packages:
6 |
7 | ## Packages
8 |
9 | ### [@osmandvc/react-upload-control](packages/core/README.md)
10 |
11 | [](https://www.npmjs.com/package/@osmandvc/react-upload-control)
12 |
13 | The lightweight core package providing essential hooks and providers for file upload management:
14 |
15 | - Powerful file upload hooks and providers
16 | - Built-in file ordering system
17 | - State machine for upload lifecycle
18 | - Zero dependencies (except optional toaster)
19 |
20 | ### [@osmandvc/react-upload-control-components](packages/components/README.md)
21 |
22 | [](https://www.npmjs.com/package/@osmandvc/react-upload-control-components)
23 |
24 | Pre-built UI components around the core package with a beautiful, modern design:
25 |
26 | - Drag & Drop support
27 | - Camera integration
28 | - Clipboard support
29 | - Tailwind-powered UI
30 |
31 | ## Quick Start
32 |
33 | ```bash
34 | # Install the core package
35 | npm install @osmandvc/react-upload-control
36 |
37 | # Optional: Install the components package
38 | npm install @osmandvc/react-upload-control-components
39 | ```
40 |
41 | ## Demo
42 |
43 | Check out our interactive demo:
44 | [React Upload Control Demo](https://main--675c9582166050575d7b72e2.chromatic.com)
45 |
46 | ## Development
47 |
48 | ```bash
49 | # Install dependencies
50 | npm install
51 |
52 | # Build packages
53 | npm run build
54 |
55 | # Run tests
56 | npm run test
57 |
58 | # Run Storybook
59 | npm run storybook
60 | ```
61 |
62 | ## License
63 |
64 | MIT [Osman Deveci](https://github.com/osmandvc)
65 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-upload-control",
3 | "private": true,
4 | "workspaces": [
5 | "packages/*"
6 | ],
7 | "scripts": {
8 | "build": "cd packages/core && npm run build",
9 | "dev": "cd packages/core && npm run start",
10 | "test": "cd packages/core && npm run test",
11 | "lint": "cd packages/core && npm run lint",
12 | "storybook": "cd packages/components && npm run storybook",
13 | "build-storybook": "cd packages/components && npm run build-storybook"
14 | },
15 | "devDependencies": {
16 | "@rollup/plugin-babel": "^6.0.4",
17 | "@rollup/plugin-commonjs": "^28.0.2",
18 | "@rollup/plugin-node-resolve": "^16.0.0",
19 | "@rollup/plugin-typescript": "^12.1.2",
20 | "rollup-plugin-terser": "^7.0.2",
21 | "rollup-plugin-visualizer": "^5.14.0"
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/packages/components/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "@babel/preset-env",
4 | "@babel/preset-react",
5 | "@babel/preset-typescript"
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/packages/components/.npmignore:
--------------------------------------------------------------------------------
1 | # Source
2 | src/
3 | tests/
4 | stories/
5 | .storybook/
6 | temp/
7 |
8 | # Configuration files
9 | tsconfig.json
10 | webpack.config.js
11 | .eslintrc
12 | .prettierrc
13 | jest.config.js
14 | .babelrc
15 | rollup.config.js
16 | postcss.config.js
17 | tailwind.config.js
18 |
19 | # Development files
20 | *.test.ts
21 | *.test.tsx
22 | *.stories.tsx
23 |
24 | # Build artifacts
25 | storybook-static/
26 | coverage/
27 | .cache/
28 |
29 | # IDE and OS files
30 | .DS_Store
31 | .idea/
32 | .vscode/
33 |
34 | # Dependencies
35 | node_modules/
36 |
37 | # Logs
38 | *.log
--------------------------------------------------------------------------------
/packages/components/.storybook/main.ts:
--------------------------------------------------------------------------------
1 | import type { StorybookConfig } from "@storybook/react-webpack5";
2 | import webpack from "webpack";
3 |
4 | const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin");
5 |
6 | import { join, dirname } from "path";
7 |
8 | /**
9 | * This function is used to resolve the absolute path of a package.
10 | * It is needed in projects that use Yarn PnP or are set up within a monorepo.
11 | */
12 | function getAbsolutePath(value: string): any {
13 | return dirname(require.resolve(join(value, "package.json")));
14 | }
15 | const config: StorybookConfig = {
16 | stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
17 | addons: [
18 | getAbsolutePath("@storybook/addon-webpack5-compiler-swc"),
19 | getAbsolutePath("@storybook/addon-onboarding"),
20 | getAbsolutePath("@storybook/addon-links"),
21 | getAbsolutePath("@storybook/addon-essentials"),
22 | getAbsolutePath("@storybook/addon-interactions"),
23 | {
24 | name: "@storybook/addon-styling",
25 | options: {
26 | // Check out https://github.com/storybookjs/addon-styling/blob/main/docs/api.md
27 | // For more details on this addon's options.
28 | postCss: true,
29 | },
30 | },
31 | ],
32 |
33 | framework: {
34 | name: getAbsolutePath("@storybook/react-webpack5"),
35 | options: {},
36 | },
37 | webpackFinal: async (config) => {
38 | config.resolve!.plugins = [
39 | ...(config.resolve!.plugins || []),
40 | new TsconfigPathsPlugin({
41 | extensions: config.resolve!.extensions,
42 | }),
43 | ];
44 |
45 | config.plugins!.push(
46 | new webpack.ProvidePlugin({
47 | React: "react",
48 | })
49 | );
50 |
51 | return config;
52 | },
53 | };
54 | export default config;
55 |
--------------------------------------------------------------------------------
/packages/components/.storybook/preview.ts:
--------------------------------------------------------------------------------
1 | import type { Preview } from "@storybook/react";
2 |
3 | const preview: Preview = {
4 | parameters: {
5 | actions: { argTypesRegex: "^on[A-Z].*" },
6 | controls: {
7 | matchers: {
8 | color: /(background|color)$/i,
9 | date: /Date$/i,
10 | },
11 | },
12 | options: {
13 | storySort: {
14 | order: [
15 | "Examples",
16 | [
17 | "Upload Control With Progress",
18 | "Small Upload Control",
19 | "Upload Control without Drag and Drop",
20 | "Upload Control with PDF Pre-Processing",
21 | ],
22 | ],
23 | },
24 | },
25 | },
26 | };
27 |
28 | export default preview;
29 |
--------------------------------------------------------------------------------
/packages/components/README.md:
--------------------------------------------------------------------------------
1 | # React Upload Control Components
2 |
3 | [](https://www.npmjs.com/package/@osmandvc/react-upload-control-components)
4 | [](https://www.typescriptlang.org/)
5 | [](https://opensource.org/licenses/MIT)
6 |
7 | Pre-built UI components for React Upload Control, providing a beautiful and feature-rich file upload experience. This package is part of the React Upload Control ecosystem.
8 |
9 | ## Demo
10 |
11 | Check out our interactive demo cases here:
12 | [React Upload Control Demo](https://main--675c9582166050575d7b72e2.chromatic.com)
13 |
14 | ## Features 🔥
15 |
16 | - 📁 **Drag & Drop:** Intuitive file uploading with visual feedback and validation
17 | - 📋 **File Management:** Built-in drag-to-reorder capability for organizing uploads
18 | - 📷 **Camera Integration:** Optional camera integration for capturing photos directly
19 | - 🎨 **Beautiful UI:** Modern, responsive design powered by Tailwind CSS
20 | - 📱 **Mobile Ready:** Optimized experience across all device sizes
21 | - 🔍 **File Preview:** Built-in preview support for images
22 | - 📎 **Clipboard Support:** Paste files directly from clipboard
23 |
24 | ## Installation
25 |
26 | ```bash
27 | # Install the components package
28 | npm install @osmandvc/react-upload-control-components
29 |
30 | # Install the core package (peer dependency)
31 | npm install @osmandvc/react-upload-control
32 | ```
33 |
34 | ## Basic Usage
35 |
36 | ```tsx
37 | import { FileUploadControl } from "@osmandvc/react-upload-control-components";
38 | import { UploadedFilesProvider } from "@osmandvc/react-upload-control";
39 |
40 | function App() {
41 | return (
42 | {
50 | // Your upload logic
51 | },
52 | }}
53 | >
54 |
55 |
56 | );
57 | }
58 | ```
59 |
60 | ## Available Components
61 |
62 | ### FileUploadControl
63 |
64 | The main component that combines all features:
65 |
66 | ```tsx
67 |
74 | ```
75 |
76 | ## Why a Separate Components Package?
77 |
78 | 1. **Flexibility:** Use only what you need. If you want to build your own UI, you can use just the core package
79 | 2. **Bundle Size:** Keep your bundle size minimal by only including the components you use
80 | 3. **Styling Freedom:** The components package includes styles, but you can build your own UI using the core package
81 | 4. **Independent Updates:** UI components can be updated without affecting core functionality
82 |
83 | ## Contributing
84 |
85 | We welcome contributions! Feel free to open issues or submit pull requests on our [GitHub repository](https://github.com/osmandvc/react-upload-control).
86 |
87 | ## License
88 |
89 | MIT © [osmandvc](https://github.com/osmandvc)
90 |
--------------------------------------------------------------------------------
/packages/components/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@osmandvc/react-upload-control-components",
3 | "version": "1.0.1",
4 | "description": "UI components for the react-upload-control library.",
5 | "main": "./dist/index.js",
6 | "module": "./dist/index.esm.js",
7 | "types": "./dist/index.d.ts",
8 | "sideEffects": [
9 | "*.css"
10 | ],
11 | "files": [
12 | "dist",
13 | "README.md",
14 | "LICENSE"
15 | ],
16 | "repository": {
17 | "type": "git",
18 | "url": "https://github.com/osmandvc/react-upload-control.git"
19 | },
20 | "publishConfig": {
21 | "access": "public"
22 | },
23 | "keywords": [
24 | "react",
25 | "react-upload",
26 | "react-upload-control",
27 | "file-upload",
28 | "drag-and-drop",
29 | "upload",
30 | "typescript",
31 | "react-component",
32 | "file-uploader",
33 | "react18",
34 | "tailwindcss",
35 | "camera-upload",
36 | "image-upload",
37 | "file-preview",
38 | "multiple-upload",
39 | "async-upload"
40 | ],
41 | "scripts": {
42 | "build": "rollup -c",
43 | "test": "jest",
44 | "lint": "eslint src --ext .js,.jsx,.ts,.tsx",
45 | "storybook": "storybook dev -p 6006",
46 | "build-storybook": "storybook build",
47 | "prepublishOnly": "pnpm run build"
48 | },
49 | "dependencies": {
50 | "@dnd-kit/core": "^6.2.0",
51 | "@dnd-kit/sortable": "^9.0.0",
52 | "@dnd-kit/utilities": "^3.2.2",
53 | "@osmandvc/react-upload-control-shared": "workspace:*",
54 | "@radix-ui/react-dialog": "^1.1.2",
55 | "@radix-ui/react-progress": "^1.1.0",
56 | "@radix-ui/react-select": "^2.1.2",
57 | "@radix-ui/react-separator": "^1.1.0",
58 | "@radix-ui/react-slot": "^1.1.0",
59 | "@radix-ui/react-tooltip": "^1.1.3",
60 | "@tabler/icons-react": "^3.19.0",
61 | "class-variance-authority": "^0.7.0",
62 | "clsx": "^2.1.1",
63 | "react-medium-image-zoom": "^5.2.10",
64 | "sonner": "^1.5.0",
65 | "tailwind-merge": "^2.5.4",
66 | "tailwindcss-animate": "^1.0.7"
67 | },
68 | "devDependencies": {
69 | "@babel/core": "^7.25.8",
70 | "@babel/preset-env": "^7.25.8",
71 | "@babel/preset-react": "^7.25.7",
72 | "@babel/preset-typescript": "^7.25.7",
73 | "@osmandvc/react-upload-control-processors": "workspace:*",
74 | "@rollup/plugin-json": "^6.1.0",
75 | "@storybook/addon-essentials": "^8.3.6",
76 | "@storybook/addon-interactions": "^8.3.6",
77 | "@storybook/addon-links": "^8.3.6",
78 | "@storybook/addon-onboarding": "^8.3.6",
79 | "@storybook/addon-styling": "^1.3.7",
80 | "@storybook/addon-webpack5-compiler-swc": "^1.0.5",
81 | "@storybook/blocks": "^8.3.6",
82 | "@storybook/react": "^8.3.6",
83 | "@storybook/react-webpack5": "^8.3.6",
84 | "@storybook/test": "^8.3.6",
85 | "@types/react": "^18.3.11",
86 | "@types/react-dom": "^18.3.1",
87 | "autoprefixer": "^10.4.20",
88 | "babel-loader": "^9.2.1",
89 | "chromatic": "^11.20.2",
90 | "css-loader": "^7.1.2",
91 | "mini-css-extract-plugin": "^2.9.2",
92 | "postcss": "^8.4.47",
93 | "postcss-loader": "^8.1.1",
94 | "process": "^0.11.10",
95 | "rollup-plugin-postcss": "^4.0.2",
96 | "storybook": "^8.3.6",
97 | "style-loader": "^4.0.0",
98 | "tailwind-scrollbar": "^3.1.0",
99 | "tailwindcss": "^3.4.14",
100 | "ts-loader": "^9.5.1",
101 | "tsconfig-paths-webpack-plugin": "^4.1.0",
102 | "tslib": "^2.8.1",
103 | "typescript": "^5.6.3",
104 | "webpack": "^5.95.0",
105 | "webpack-bundle-analyzer": "^4.10.2",
106 | "webpack-cli": "^5.1.4",
107 | "webpack-dev-server": "^5.1.0",
108 | "rollup": "^2.79.1"
109 | },
110 | "peerDependencies": {
111 | "react": "^18.0.0",
112 | "react-dom": "^18.0.0",
113 | "@osmandvc/react-upload-control": "workspace:*"
114 | },
115 | "peerDependenciesMeta": {
116 | "react": {
117 | "optional": false
118 | },
119 | "react-dom": {
120 | "optional": false
121 | },
122 | "@osmandvc/react-upload-control": {
123 | "optional": false
124 | }
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/packages/components/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/packages/components/rollup.config.js:
--------------------------------------------------------------------------------
1 | import path from "path";
2 | import { terser } from "rollup-plugin-terser";
3 | import resolve from "@rollup/plugin-node-resolve";
4 | import commonjs from "@rollup/plugin-commonjs";
5 | import typescript from "@rollup/plugin-typescript";
6 | import { babel } from "@rollup/plugin-babel";
7 | import postcss from "rollup-plugin-postcss";
8 | import json from "@rollup/plugin-json";
9 |
10 | import pkg from "./package.json";
11 |
12 | export default {
13 | input: "./src/index.ts",
14 | output: [
15 | {
16 | file: path.resolve(__dirname, "dist/index.esm.js"),
17 | format: "esm",
18 | },
19 | ],
20 | external: [...Object.keys(pkg.peerDependencies), "react/jsx-runtime"],
21 | plugins: [
22 | resolve({
23 | extensions: [".ts", ".tsx", ".js", ".jsx"],
24 | }),
25 | commonjs(),
26 | typescript(),
27 | babel({
28 | babelHelpers: "bundled",
29 | exclude: "node_modules/**",
30 | extensions: [".ts", ".tsx", ".js", ".jsx"],
31 | }),
32 | postcss({
33 | config: {
34 | path: "./postcss.config.js",
35 | },
36 | extensions: [".css"],
37 | minimize: true,
38 | inject: {
39 | insertAt: "top",
40 | },
41 | }),
42 | json(),
43 | terser(),
44 | ],
45 | };
46 |
--------------------------------------------------------------------------------
/packages/components/src/actions/FileDeleteAllButton.tsx:
--------------------------------------------------------------------------------
1 | import { useUploadFilesProvider } from "@osmandvc/react-upload-control";
2 | import { Button } from "../ui/button/Button";
3 | import { ResetIcon } from "../ui/icons";
4 |
5 | export const FileDeleteAllButton = () => {
6 | const { files, deleteAllFiles, smStatusIs } = useUploadFilesProvider();
7 |
8 | return (
9 | }
14 | disabled={!files.length || smStatusIs("PROCESSING")}
15 | onClick={deleteAllFiles}
16 | >
17 | Reset
18 |
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/packages/components/src/actions/FileItemActions.tsx:
--------------------------------------------------------------------------------
1 | import { IconCheck } from "@tabler/icons-react";
2 |
3 | import { useUploadFilesProvider } from "@osmandvc/react-upload-control";
4 | import { UploadedFileItemStage } from "../types";
5 | import { Button } from "../ui/button/Button";
6 |
7 | type Props = {
8 | id: string;
9 | stage?: UploadedFileItemStage;
10 | };
11 |
12 | export const FileItemActions = ({ id, stage }: Props) => {
13 | const { deleteFile } = useUploadFilesProvider();
14 |
15 | return (
16 |
17 | {(!stage ||
18 | stage === UploadedFileItemStage.IDLE ||
19 | stage === UploadedFileItemStage.FAILED) && (
20 |
35 | );
36 | };
37 |
--------------------------------------------------------------------------------
/packages/components/src/actions/FileUploadAllButton.tsx:
--------------------------------------------------------------------------------
1 | import { useUploadFilesProvider } from "@osmandvc/react-upload-control";
2 | import { CheckIcon } from "../ui/icons";
3 | import { Button } from "../ui/button/Button";
4 |
5 | export const FileUploadAllButton = () => {
6 | const { uploadAllFiles, files, smStatusIs } = useUploadFilesProvider();
7 |
8 | return (
9 | }
15 | onClick={uploadAllFiles}
16 | >
17 | {smStatusIs("ERROR") ? "Retry" : "Confirm Files"}
18 |
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/packages/components/src/actions/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./FileUploadAllButton";
2 | export * from "./FileDeleteAllButton";
3 | export * from "./FileItemActions";
4 |
--------------------------------------------------------------------------------
/packages/components/src/components/FileUploadContainer.tsx:
--------------------------------------------------------------------------------
1 | import { PropsWithChildren } from "react";
2 | import { cn } from "../lib/cn";
3 |
4 | /**
5 | * The Default Container for the File Upload Control
6 | */
7 |
8 | interface ContainerProps extends PropsWithChildren {
9 | className?: string;
10 | }
11 |
12 | export const FileUploadContainer = ({
13 | className,
14 | children,
15 | }: ContainerProps) => {
16 | return {children}
;
17 | };
18 |
--------------------------------------------------------------------------------
/packages/components/src/components/FileUploadControl.tsx:
--------------------------------------------------------------------------------
1 | import "../styles/tailwind.css";
2 | import { arrayMove } from "@dnd-kit/sortable";
3 | import { cn } from "../lib/cn";
4 | import { DndResult, FileUploadControlProps } from "../types";
5 | import { FileList, FileListActions, FileListContainer } from "./file-list";
6 | import { FileDropLarge, FileDropSmall } from "./file-drop";
7 | import { useUploadFilesProvider } from "@osmandvc/react-upload-control";
8 |
9 | /**
10 | * The Default File-Upload-Control Component with a Drop-Area and a List which displays the Files.
11 | */
12 | export const FileUploadControl = ({
13 | className,
14 | children,
15 | disableCamera,
16 | disableFileSystem,
17 | size = "auto",
18 | }: FileUploadControlProps) => {
19 | const { files, setFiles, smStatusIs } = useUploadFilesProvider();
20 | const hasFiles = !!files.length;
21 |
22 | function handleOnDragEnd(result: DndResult) {
23 | const { source, destination } = result;
24 |
25 | // If dropped outside the list or at the same position, do nothing
26 | if (!destination || source.index === destination.index) {
27 | return;
28 | }
29 |
30 | setFiles((prevFiles) => {
31 | const newFiles = arrayMove(prevFiles, source.index, destination.index);
32 | return newFiles.map((file, index) => ({ ...file, order: index + 1 }));
33 | });
34 | }
35 |
36 | return (
37 |
43 |
52 |
63 | {children}
64 |
65 |
75 | {children}
76 |
77 |
78 |
84 | {hasFiles && (
85 |
86 |
87 |
88 |
89 | )}
90 |
91 |
92 | );
93 | };
94 |
--------------------------------------------------------------------------------
/packages/components/src/components/camera/custom-react-webcam.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | // polyfill based on https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia
4 | (function polyfillGetUserMedia() {
5 | const nav = navigator as any;
6 |
7 | if (typeof window === "undefined") return;
8 |
9 | // Older browsers might not implement mediaDevices at all, so we set an empty object first
10 | if (nav.mediaDevices === undefined) {
11 | nav.mediaDevices = {};
12 | }
13 |
14 | // Some browsers partially implement mediaDevices. We can't just assign an object
15 | // with getUserMedia as it would overwrite existing properties.
16 | // Here, we will just add the getUserMedia property if it's missing.
17 | if (nav.mediaDevices.getUserMedia === undefined) {
18 | nav.mediaDevices.getUserMedia = function (
19 | constraints: any
20 | ): Promise {
21 | // First get ahold of the legacy getUserMedia, if present
22 | const getUserMedia =
23 | nav.getUserMedia ||
24 | nav.webkitGetUserMedia ||
25 | nav.mozGetUserMedia ||
26 | nav.msGetUserMedia;
27 |
28 | // Some browsers just don't implement it - return a rejected promise with an error
29 | // to keep a consistent interface
30 | if (!getUserMedia) {
31 | return Promise.reject(
32 | new Error("getUserMedia is not implemented in this browser")
33 | );
34 | }
35 |
36 | // Otherwise, wrap the call to the old navigator.getUserMedia with a Promise
37 | return new Promise(function (resolve, reject) {
38 | getUserMedia.call(nav, constraints, resolve, reject);
39 | });
40 | };
41 | }
42 | })();
43 |
44 | function hasGetUserMedia() {
45 | return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
46 | }
47 |
48 | interface ScreenshotDimensions {
49 | width: number;
50 | height: number;
51 | }
52 |
53 | interface ChildrenProps {
54 | getScreenshot: (screenshotDimensions?: ScreenshotDimensions) => string | null;
55 | }
56 |
57 | export type WebcamProps = Omit, "ref"> & {
58 | audio: boolean;
59 | audioConstraints?: MediaStreamConstraints["audio"];
60 | forceScreenshotSourceSize: boolean;
61 | imageSmoothing: boolean;
62 | mirrored: boolean;
63 | minScreenshotHeight?: number;
64 | minScreenshotWidth?: number;
65 | onUserMedia: (stream: MediaStream) => void;
66 | onUserMediaError: (error: string | DOMException) => void;
67 | screenshotFormat: "image/webp" | "image/png" | "image/jpeg";
68 | screenshotQuality: number;
69 | videoConstraints?: MediaStreamConstraints["video"];
70 | children?: (childrenProps: ChildrenProps) => JSX.Element;
71 | };
72 |
73 | interface WebcamState {
74 | hasUserMedia: boolean;
75 | src?: string;
76 | }
77 |
78 | export default class Webcam extends React.Component {
79 | static defaultProps = {
80 | audio: false,
81 | forceScreenshotSourceSize: false,
82 | imageSmoothing: true,
83 | mirrored: false,
84 | onUserMedia: () => undefined,
85 | onUserMediaError: () => undefined,
86 | screenshotFormat: "image/jpeg",
87 | screenshotQuality: 0.9,
88 | };
89 |
90 | private canvas: HTMLCanvasElement | null = null;
91 | private ctx: CanvasRenderingContext2D | null = null;
92 | private requestUserMediaId: number = 0;
93 | private unmounted: boolean = false;
94 | private requestedUserMedia: boolean = false;
95 |
96 | stream: MediaStream | null = null;
97 | video: HTMLVideoElement | null = null;
98 |
99 | constructor(props: WebcamProps) {
100 | super(props);
101 | this.state = {
102 | hasUserMedia: false,
103 | };
104 | }
105 |
106 | componentDidMount() {
107 | const { state, props } = this;
108 | this.unmounted = false;
109 |
110 | if (!hasGetUserMedia()) {
111 | props.onUserMediaError("getUserMedia not supported");
112 | return;
113 | }
114 |
115 | if (!state.hasUserMedia && !this.requestedUserMedia) {
116 | this.requestUserMedia();
117 | }
118 |
119 | if (props.children && typeof props.children != "function") {
120 | console.warn("children must be a function");
121 | }
122 | }
123 |
124 | componentDidUpdate(nextProps: WebcamProps) {
125 | const { props } = this;
126 |
127 | if (!hasGetUserMedia()) {
128 | props.onUserMediaError("getUserMedia not supported");
129 | return;
130 | }
131 |
132 | const audioConstraintsChanged =
133 | JSON.stringify(nextProps.audioConstraints) !==
134 | JSON.stringify(props.audioConstraints);
135 | const videoConstraintsChanged =
136 | JSON.stringify(nextProps.videoConstraints) !==
137 | JSON.stringify(props.videoConstraints);
138 | const minScreenshotWidthChanged =
139 | nextProps.minScreenshotWidth !== props.minScreenshotWidth;
140 | const minScreenshotHeightChanged =
141 | nextProps.minScreenshotHeight !== props.minScreenshotHeight;
142 | if (
143 | videoConstraintsChanged ||
144 | minScreenshotWidthChanged ||
145 | minScreenshotHeightChanged
146 | ) {
147 | this.canvas = null;
148 | this.ctx = null;
149 | }
150 | if (audioConstraintsChanged || videoConstraintsChanged) {
151 | this.stopAndCleanup();
152 | this.requestUserMedia();
153 | }
154 | }
155 |
156 | componentWillUnmount() {
157 | const { props } = this;
158 | this.unmounted = true;
159 | this.stopAndCleanup();
160 | }
161 |
162 | private static stopMediaStream(stream: MediaStream | null) {
163 | const st = stream as any;
164 | if (st) {
165 | st.getTracks().forEach((track: any) => {
166 | track.stop();
167 | track.enabled = false;
168 | st?.removeTrack(track);
169 | });
170 | if (st.stop) st.stop();
171 |
172 | /*if (stream.getVideoTracks && stream.getAudioTracks) {
173 | stream.getVideoTracks().map((track) => {
174 | stream.removeTrack(track);
175 | track.stop();
176 | });
177 | stream.getAudioTracks().map((track) => {
178 | stream.removeTrack(track);
179 | track.stop();
180 | });
181 | } else {
182 | (stream as unknown as MediaStreamTrack).stop();
183 | }*/
184 | }
185 | }
186 |
187 | private stopAndCleanup() {
188 | const { state } = this;
189 |
190 | if (state.hasUserMedia) {
191 | Webcam.stopMediaStream(this.stream);
192 |
193 | if (state.src) {
194 | window.URL.revokeObjectURL(state.src);
195 | }
196 |
197 | /*if (this.video) {
198 | this.video.srcObject = null;
199 | }*/
200 | }
201 | }
202 |
203 | getScreenshot(screenshotDimensions?: ScreenshotDimensions) {
204 | const { state, props } = this;
205 |
206 | if (!state.hasUserMedia) return null;
207 |
208 | const canvas = this.getCanvas(screenshotDimensions);
209 |
210 | return (
211 | canvas &&
212 | canvas.toDataURL(props.screenshotFormat, props.screenshotQuality)
213 | );
214 | }
215 |
216 | getCanvas(screenshotDimensions?: ScreenshotDimensions) {
217 | const { state, props } = this;
218 |
219 | if (!this.video) return null;
220 | if (!state.hasUserMedia || !this.video.videoHeight) return null;
221 |
222 | if (!this.ctx) {
223 | let canvasWidth = this.video.videoWidth;
224 | let canvasHeight = this.video.videoHeight;
225 |
226 | if (!this.props.forceScreenshotSourceSize) {
227 | const aspectRatio = canvasWidth / canvasHeight;
228 |
229 | canvasWidth = props.minScreenshotWidth || this.video.clientWidth;
230 | canvasHeight = canvasWidth / aspectRatio;
231 |
232 | if (
233 | props.minScreenshotHeight &&
234 | canvasHeight < props.minScreenshotHeight
235 | ) {
236 | canvasHeight = props.minScreenshotHeight;
237 | canvasWidth = canvasHeight * aspectRatio;
238 | }
239 | }
240 |
241 | this.canvas = document.createElement("canvas");
242 | this.canvas.width = screenshotDimensions?.width || canvasWidth;
243 | this.canvas.height = screenshotDimensions?.height || canvasHeight;
244 | this.ctx = this.canvas.getContext("2d");
245 | }
246 |
247 | const { ctx, canvas } = this;
248 |
249 | if (ctx && canvas) {
250 | // adjust the height and width of the canvas to the given dimensions
251 | canvas.width = screenshotDimensions?.width || canvas.width;
252 | canvas.height = screenshotDimensions?.height || canvas.height;
253 |
254 | // mirror the screenshot
255 | if (props.mirrored) {
256 | ctx.translate(canvas.width, 0);
257 | ctx.scale(-1, 1);
258 | }
259 |
260 | ctx.imageSmoothingEnabled = props.imageSmoothing;
261 | ctx.drawImage(
262 | this.video,
263 | 0,
264 | 0,
265 | screenshotDimensions?.width || canvas.width,
266 | screenshotDimensions?.height || canvas.height
267 | );
268 |
269 | // invert mirroring
270 | if (props.mirrored) {
271 | ctx.scale(-1, 1);
272 | ctx.translate(-canvas.width, 0);
273 | }
274 | }
275 |
276 | return canvas;
277 | }
278 |
279 | private requestUserMedia() {
280 | const { props } = this;
281 | this.requestedUserMedia = true;
282 |
283 | const sourceSelected = (
284 | audioConstraints: boolean | MediaTrackConstraints | undefined,
285 | videoConstraints: boolean | MediaTrackConstraints | undefined
286 | ) => {
287 | const constraints: MediaStreamConstraints = {
288 | video:
289 | typeof videoConstraints !== "undefined" ? videoConstraints : true,
290 | };
291 |
292 | if (props.audio) {
293 | constraints.audio =
294 | typeof audioConstraints !== "undefined" ? audioConstraints : true;
295 | }
296 |
297 | this.requestUserMediaId++;
298 | const myRequestUserMediaId = this.requestUserMediaId;
299 |
300 | if (this.stream) return;
301 |
302 | navigator.mediaDevices
303 | .getUserMedia(constraints)
304 | .then((stream) => {
305 | if (
306 | this.unmounted ||
307 | myRequestUserMediaId !== this.requestUserMediaId
308 | ) {
309 | Webcam.stopMediaStream(stream);
310 | } else {
311 | this.handleUserMedia(null, stream);
312 | }
313 | })
314 | .catch((e) => {
315 | this.handleUserMedia(e);
316 | })
317 | .finally(() => {
318 | this.requestedUserMedia = false;
319 | });
320 | };
321 |
322 | if ("mediaDevices" in navigator) {
323 | sourceSelected(props.audioConstraints, props.videoConstraints);
324 | } else {
325 | const optionalSource = (id: string | null) =>
326 | ({ optional: [{ sourceId: id }] } as MediaTrackConstraints);
327 |
328 | const constraintToSourceId = (constraint: any) => {
329 | const { deviceId } = constraint;
330 |
331 | if (typeof deviceId === "string") return deviceId;
332 | if (Array.isArray(deviceId) && deviceId.length > 0) return deviceId[0];
333 | if (typeof deviceId === "object" && deviceId.ideal)
334 | return deviceId.ideal;
335 | return null;
336 | };
337 |
338 | // @ts-ignore: deprecated api
339 | MediaStreamTrack.getSources((sources: any) => {
340 | let audioSource: string | null = null;
341 | let videoSource: string | null = null;
342 |
343 | sources.forEach((source: MediaStreamTrack) => {
344 | if (source.kind === "audio") {
345 | audioSource = source.id;
346 | } else if (source.kind === "video") {
347 | videoSource = source.id;
348 | }
349 | });
350 |
351 | const audioSourceId = constraintToSourceId(props.audioConstraints);
352 | if (audioSourceId) {
353 | audioSource = audioSourceId;
354 | }
355 |
356 | const videoSourceId = constraintToSourceId(props.videoConstraints);
357 | if (videoSourceId) {
358 | videoSource = videoSourceId;
359 | }
360 |
361 | sourceSelected(
362 | optionalSource(audioSource),
363 | optionalSource(videoSource)
364 | );
365 | });
366 | }
367 | }
368 |
369 | private handleUserMedia(err: any, stream?: MediaStream) {
370 | const { props } = this;
371 |
372 | if (err || !stream) {
373 | this.setState({ hasUserMedia: false });
374 | props.onUserMediaError(err);
375 | return;
376 | }
377 |
378 | this.stream = stream;
379 |
380 | try {
381 | if (this.video) {
382 | this.video.srcObject = stream;
383 | }
384 | this.setState({ hasUserMedia: true });
385 | } catch (error) {
386 | this.setState({
387 | hasUserMedia: true,
388 | src: window.URL.createObjectURL(stream as any),
389 | });
390 | }
391 |
392 | props.onUserMedia(stream);
393 | }
394 |
395 | render() {
396 | const { state, props } = this;
397 |
398 | const {
399 | audio,
400 | forceScreenshotSourceSize,
401 | onUserMedia,
402 | onUserMediaError,
403 | screenshotFormat,
404 | screenshotQuality,
405 | minScreenshotWidth,
406 | minScreenshotHeight,
407 | audioConstraints,
408 | videoConstraints,
409 | imageSmoothing,
410 | mirrored,
411 | style = {},
412 | children,
413 | ...rest
414 | } = props;
415 |
416 | const videoStyle = mirrored
417 | ? { ...style, transform: `${style.transform || ""} scaleX(-1)` }
418 | : style;
419 |
420 | const childrenProps: ChildrenProps = {
421 | getScreenshot: this.getScreenshot.bind(this),
422 | };
423 |
424 | return (
425 | <>
426 |