diff --git a/package-lock.json b/package-lock.json index 93346b978e6..a049f0727cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,6 +58,7 @@ "redux-thunk": "^2.4.2", "rehype-raw": "^6.1.1", "rescript-webapi": "^0.8.0", + "turndown": "^7.2.0", "use-keyboard-shortcut": "^1.1.6", "uuid": "^9.0.1", "xlsx": "^0.18.5" @@ -83,6 +84,7 @@ "@types/react-google-recaptcha": "^2.1.9", "@types/react-qr-reader": "^2.1.7", "@types/react-transition-group": "^4.4.10", + "@types/turndown": "^5.0.4", "@typescript-eslint/eslint-plugin": "^5.61.0", "@typescript-eslint/parser": "^5.61.0", "@vitejs/plugin-react-swc": "^3.6.0", @@ -3141,6 +3143,11 @@ "react": ">=16" } }, + "node_modules/@mixmark-io/domino": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", + "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==" + }, "node_modules/@ndelangen/get-tarball": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/@ndelangen/get-tarball/-/get-tarball-3.0.9.tgz", @@ -6619,6 +6626,12 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "dev": true }, + "node_modules/@types/turndown": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.4.tgz", + "integrity": "sha512-28GI33lCCkU4SGH1GvjDhFgOVr+Tym4PXGBIU1buJUa6xQolniPArtUT+kv42RR2N9MsMLInkr904Aq+ESHBJg==", + "dev": true + }, "node_modules/@types/unist": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", @@ -23601,6 +23614,14 @@ "node": "*" } }, + "node_modules/turndown": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.0.tgz", + "integrity": "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A==", + "dependencies": { + "@mixmark-io/domino": "^2.2.0" + } + }, "node_modules/tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", diff --git a/package.json b/package.json index 59fd5cd89e8..5a658d6d510 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "redux-thunk": "^2.4.2", "rehype-raw": "^6.1.1", "rescript-webapi": "^0.8.0", + "turndown": "^7.2.0", "use-keyboard-shortcut": "^1.1.6", "uuid": "^9.0.1", "xlsx": "^0.18.5" @@ -123,6 +124,7 @@ "@types/react-google-recaptcha": "^2.1.9", "@types/react-qr-reader": "^2.1.7", "@types/react-transition-group": "^4.4.10", + "@types/turndown": "^5.0.4", "@typescript-eslint/eslint-plugin": "^5.61.0", "@typescript-eslint/parser": "^5.61.0", "@vitejs/plugin-react-swc": "^3.6.0", diff --git a/src/Components/Common/FilePreviewDialog.tsx b/src/Components/Common/FilePreviewDialog.tsx index 3f55621e8c7..a8f455bc070 100644 --- a/src/Components/Common/FilePreviewDialog.tsx +++ b/src/Components/Common/FilePreviewDialog.tsx @@ -33,16 +33,17 @@ type FilePreviewProps = { }; const previewExtensions = [ - ".html", - ".htm", - ".pdf", - ".mp4", - ".webm", - ".jpg", - ".jpeg", - ".png", - ".gif", - ".webp", + "html", + "htm", + "pdf", + "mp4", + "mp3", + "webm", + "jpg", + "jpeg", + "png", + "gif", + "webp", ]; const FilePreviewDialog = (props: FilePreviewProps) => { diff --git a/src/Components/Common/MarkdownPreview.tsx b/src/Components/Common/MarkdownPreview.tsx new file mode 100644 index 00000000000..5f065d3e199 --- /dev/null +++ b/src/Components/Common/MarkdownPreview.tsx @@ -0,0 +1,88 @@ +import React, { useEffect, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import rehypeRaw from "rehype-raw"; +import { UserModel } from "../Users/models"; +import useQuery from "../../Utils/request/useQuery"; +import routes from "../../Redux/api"; + +interface UserCardProps { + user: UserModel; +} + +const UserCard: React.FC = ({ user }) => ( +
+
+ {user.first_name[0]} +
+
+

+ {user.first_name} {user.last_name} +

+

@{user.username}

+

{user.user_type}

+
+
+); + +const MarkdownPreview: React.FC<{ markdown: string }> = ({ markdown }) => { + const [userCache, setUserCache] = useState>({}); + const facilityId = "81092ced-8720-44cb-b4c5-3f0ad0540153"; + + const { data: facilityUsers } = useQuery(routes.getFacilityUsers, { + pathParams: { facility_id: facilityId }, + }); + + useEffect(() => { + if (facilityUsers?.results) { + const newCache: Record = {}; + facilityUsers.results.forEach((user) => { + if (user.username) { + newCache[user.username] = user as UserModel; + } + }); + setUserCache(newCache); + } + }, [facilityUsers]); + + const processedMarkdown = markdown + .replace( + /!\[mention_user\]\(user_id:(\d+), username:([^)]+)\)/g, + (_, userId, username) => + `@${username}`, + ) + .replace(/~(.*?)~/g, (_, text) => `${text}`); + + const CustomLink: React.FC = (props) => { + if (props.className?.includes("user-mention")) { + const username = props["data-username"]; + return ( + + + {userCache[username] && ( +
+ +
+ )} +
+ ); + } + return ; + }; + + return ( + + {processedMarkdown} + + ); +}; + +export default MarkdownPreview; diff --git a/src/Components/Common/MentionDropdown.tsx b/src/Components/Common/MentionDropdown.tsx new file mode 100644 index 00000000000..f51ca1befa2 --- /dev/null +++ b/src/Components/Common/MentionDropdown.tsx @@ -0,0 +1,33 @@ +import routes from "../../Redux/api"; +import useQuery from "../../Utils/request/useQuery"; + +const MentionsDropdown: React.FC<{ + onSelect: (user: any) => void; + position: { top: number; left: number }; +}> = ({ onSelect, position }) => { + const facilityId = "81092ced-8720-44cb-b4c5-3f0ad0540153"; + const { data } = useQuery(routes.getFacilityUsers, { + pathParams: { facility_id: facilityId }, + }); + + const users = data?.results || []; + + return ( +
+ {users.map((user) => ( +
onSelect(user)} + > + {user.username} +
+ ))} +
+ ); +}; + +export default MentionsDropdown; diff --git a/src/Components/Common/RichTextEditor.tsx b/src/Components/Common/RichTextEditor.tsx new file mode 100644 index 00000000000..62254a7d9b9 --- /dev/null +++ b/src/Components/Common/RichTextEditor.tsx @@ -0,0 +1,1191 @@ +import { useRef, useReducer, useEffect, useState, useCallback } from "react"; +import { + FaBold, + FaItalic, + FaListOl, + FaListUl, + FaLink, + FaUnlink, + FaStrikethrough, + FaQuoteRight, + FaFile, +} from "react-icons/fa"; +import { FaCamera } from "react-icons/fa6"; +import { AiFillAudio } from "react-icons/ai"; +import { RxCross2 } from "react-icons/rx"; +import { GoMention } from "react-icons/go"; +import { MdAttachFile } from "react-icons/md"; +import TurndownService from "turndown"; +import MentionsDropdown from "./MentionDropdown"; +import { ExtImage, StateInterface } from "../Patient/FileUpload"; +import imageCompression from "browser-image-compression"; +import { CreateFileResponse, FileUploadModel } from "../Patient/models"; +import uploadFile from "../../Utils/request/uploadFile"; +import * as Notification from "../../Utils/Notifications.js"; +import request from "../../Utils/request/request"; +import routes from "../../Redux/api"; +import FilePreviewDialog from "./FilePreviewDialog"; +import DialogModal from "./Dialog"; +import CareIcon from "../../CAREUI/icons/CareIcon"; +import Webcam from "react-webcam"; +import ButtonV2, { Submit } from "./components/ButtonV2"; +import useWindowDimensions from "../../Common/hooks/useWindowDimensions"; +import useRecorder from "../../Utils/useRecorder"; + +interface RichTextEditorProps { + initialMarkdown?: string; + onChange: (markdown: string) => void; +} + +interface EditorState { + isBoldActive: boolean; + isItalicActive: boolean; + isStrikethroughActive: boolean; + isQuoteActive: boolean; + isUnorderedListActive: boolean; + isOrderedListActive: boolean; +} + +type EditorAction = + | { type: "SET_BOLD_ACTIVE"; payload: boolean } + | { type: "SET_ITALIC_ACTIVE"; payload: boolean } + | { type: "SET_STRIKETHROUGH_ACTIVE"; payload: boolean } + | { type: "SET_QUOTE_ACTIVE"; payload: boolean } + | { type: "SET_UNORDERED_LIST_ACTIVE"; payload: boolean } + | { type: "SET_ORDERED_LIST_ACTIVE"; payload: boolean } + | { type: "UPDATE_ALL"; payload: Partial }; + +const initialState: EditorState = { + isBoldActive: false, + isItalicActive: false, + isStrikethroughActive: false, + isQuoteActive: false, + isUnorderedListActive: false, + isOrderedListActive: false, +}; + +function editorReducer(state: EditorState, action: EditorAction): EditorState { + switch (action.type) { + case "SET_BOLD_ACTIVE": + return { ...state, isBoldActive: action.payload }; + case "SET_ITALIC_ACTIVE": + return { ...state, isItalicActive: action.payload }; + case "SET_STRIKETHROUGH_ACTIVE": + return { ...state, isStrikethroughActive: action.payload }; + case "SET_QUOTE_ACTIVE": + return { ...state, isQuoteActive: action.payload }; + case "SET_UNORDERED_LIST_ACTIVE": + return { ...state, isUnorderedListActive: action.payload }; + case "SET_ORDERED_LIST_ACTIVE": + return { ...state, isOrderedListActive: action.payload }; + case "UPDATE_ALL": + return { ...state, ...action.payload }; + default: + return state; + } +} + +const RichTextEditor: React.FC = ({ + // initialMarkdown = "", + onChange, +}) => { + const [state, dispatch] = useReducer(editorReducer, initialState); + const editorRef = useRef(null); + + const [showMentions, setShowMentions] = useState(false); + const [mentionPosition, setMentionPosition] = useState({ top: 0, left: 0 }); + const lastCaretPosition = useRef(null); + + const [file, setFile] = useState(null); + const fileInputRef = useRef(null); + + const [modalOpenForCamera, setModalOpenForCamera] = useState(false); + const [modalOpenForAudio, setModalOpenForAudio] = useState(false); + + useEffect(() => { + document.addEventListener("selectionchange", handleSelectionChange); + return () => { + document.removeEventListener("selectionchange", handleSelectionChange); + }; + }, []); + + const handleSelectionChange = () => { + const selection = window.getSelection(); + if (!selection || !selection.rangeCount) return; + + const isBold = + isParentTag(selection.focusNode, "STRONG") || + isParentTag(selection.focusNode, "B"); + const isItalic = + isParentTag(selection.focusNode, "EM") || + isParentTag(selection.focusNode, "I"); + const isQuote = isParentTag(selection.focusNode, "BLOCKQUOTE"); + + const listNode = findParentNode(selection.anchorNode, ["UL", "OL"]); + const isUnorderedListActive = listNode?.nodeName === "UL" ?? false; + const isOrderedListActive = + (listNode && listNode.nodeName === "OL") ?? false; + const isStrikethrough = + isParentTag(selection.focusNode, "S") || + isParentTag(selection.focusNode, "DEL"); + + dispatch({ + type: "UPDATE_ALL", + payload: { + isBoldActive: isBold, + isItalicActive: isItalic, + isQuoteActive: isQuote, + isUnorderedListActive, + isOrderedListActive, + isStrikethroughActive: isStrikethrough, + }, + }); + }; + + const isParentTag = (node: Node | null, tagName: string) => { + while (node) { + if (node.nodeName === tagName) { + return true; + } + node = node.parentNode; + } + return false; + }; + + const findParentNode = ( + node: Node | null, + tagNames: string[], + ): HTMLElement | null => { + while (node && node.parentNode) { + node = node.parentNode; + if (node && tagNames.includes(node.nodeName)) { + return node as HTMLElement; + } + } + return null; + }; + + const applyStyle = (style: "b" | "i" | "s") => { + const selection = window.getSelection(); + if (!selection || !selection.rangeCount) return; + + const range = selection.getRangeAt(0); + + const node = document.createElement(style); + node.appendChild(range.cloneContents()); + range.deleteContents(); + range.insertNode(node); + + saveState(); + }; + + const toggleList = (listType: "ul" | "ol") => { + const selection = window.getSelection(); + if (!selection || !selection.rangeCount) return; + + const range = selection.getRangeAt(0); + const listNode = findParentNode(range.startContainer, ["UL", "OL"]); + + if (listNode && listNode.nodeName === listType.toUpperCase()) { + const parentList = listNode.parentNode; + Array.from(listNode.childNodes).forEach((item) => { + const newListItem = document.createElement("li"); + newListItem.textContent = item.textContent; + if (parentList) { + parentList.insertBefore(newListItem, listNode); + } + }); + if (parentList) { + parentList.removeChild(listNode); + } + } else { + const list = document.createElement(listType === "ul" ? "ul" : "ol"); + const listItem = document.createElement("li"); + listItem.appendChild(range.cloneContents()); + list.appendChild(listItem); + range.deleteContents(); + range.insertNode(list); + + const br = document.createElement("br"); + if (list.parentNode) { + list.parentNode.insertBefore(br, list.nextSibling); + } + } + + saveState(); + }; + + const applyQuote = () => { + const selection = window.getSelection(); + if (!selection || !selection.rangeCount) return; + + const range = selection.getRangeAt(0); + const blockquote = document.createElement("blockquote"); + blockquote.appendChild(range.extractContents()); + range.insertNode(blockquote); + + const br = document.createElement("br"); + if (blockquote.parentNode) { + blockquote.parentNode.insertBefore(br, blockquote.nextSibling); + } + + saveState(); + }; + + const handleLink = () => { + const userLink = prompt("Enter a URL"); + if (userLink) { + const selection = window.getSelection(); + if (!selection || !selection.rangeCount) return; + + const range = selection.getRangeAt(0); + const anchor = document.createElement("a"); + anchor.href = userLink.startsWith("http") + ? userLink + : `http://${userLink}`; + anchor.textContent = range.toString(); + + range.deleteContents(); + range.insertNode(anchor); + saveState(); + } + }; + + const handleUnlink = () => { + const selection = window.getSelection(); + if (!selection || !selection.rangeCount) return; + + const range = selection.getRangeAt(0); + const container = range.startContainer.parentNode as HTMLElement; + if (container && container.tagName === "A") { + const parent = container.parentNode; + while (container.firstChild) { + if (parent) { + parent.insertBefore(container.firstChild, container); + } + } + if (parent) { + parent.removeChild(container); + } + } + saveState(); + }; + + const handleInput = useCallback((event: React.FormEvent) => { + const target = event.target as HTMLDivElement; + const text = target.textContent || ""; + const lastChar = text[text.length - 1]; + + if (lastChar === "@") { + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + setMentionPosition({ + top: rect.bottom + window.scrollY, + left: rect.left + window.scrollX, + }); + setShowMentions(true); + lastCaretPosition.current = range.cloneRange(); + } + } else { + setShowMentions(false); + } + + saveState(); + }, []); + + const insertMention = (user: { id: string; username: string }) => { + if (lastCaretPosition.current) { + const range = lastCaretPosition.current; + range.setStart(range.startContainer, range.startOffset - 1); + range.deleteContents(); + + const mentionNode = document.createElement("a"); + mentionNode.contentEditable = "false"; + mentionNode.className = + "bg-blue-100 px-1 rounded no-underline text-slate-800 font-normal hover:underline"; + mentionNode.textContent = `@${user.username}`; + mentionNode.setAttribute("data-user-id", user.id); + + mentionNode.onmouseover = () => { + console.log(user); + }; + + range.insertNode(mentionNode); + + const newRange = document.createRange(); + newRange.setStartAfter(mentionNode); + newRange.collapse(true); + + const selection = window.getSelection(); + if (selection) { + selection.removeAllRanges(); + selection.addRange(newRange); + } + + setShowMentions(false); + saveState(); + } + }; + + const saveState = () => { + const turndownService = new TurndownService(); + turndownService.addRule("strikethrough", { + filter: ["del", "s"], + replacement: (content) => `~${content}~`, + }); + turndownService.addRule("mentions", { + filter: (node) => { + return node.nodeName === "A" && node.hasAttribute("data-user-id"); + }, + replacement: (content, node) => { + const userId = (node as HTMLElement).getAttribute("data-user-id"); + const username = content.replace("@", ""); + return `![mention_user](user_id:${userId}, username:${username})`; + }, + }); + turndownService.addRule("div", { + filter: "div", + replacement: function (content) { + return content + "\n"; + }, + }); + + const htmlContent = editorRef.current?.innerHTML || ""; + const markdownText = turndownService.turndown(htmlContent); + onChange(markdownText); + }; + + const onFileChange = (e: any): any => { + if (!e.target.files?.length) { + return; + } + const f = e.target.files[0]; + const fileName = f.name; + setFile(e.target.files[0]); + + const ext: string = fileName.split(".")[1]; + + if (ExtImage.includes(ext)) { + const options = { + initialQuality: 0.6, + alwaysKeepResolution: true, + }; + imageCompression(f, options).then((compressedFile: File) => { + setFile(compressedFile); + }); + return; + } + setFile(f); + }; + + return ( +
+ {/* camera capture model */} + setModalOpenForCamera(false)} + setFile={setFile} + /> + + {/* audio recording */} + + + {/* toolbar */} +
+
+ + + +
+ +
+ + +
+ + + +
+ + +
+ +
+ +
+ + + + + +
+
+ + {/* editor */} +
+ + + + {showMentions && ( + + )} +
+ ); +}; + +export default RichTextEditor; + +const FileUpload = ({ + file, + setFile, +}: { + file: File | null; + setFile: React.Dispatch>; +}) => { + const [file_state, setFileState] = useState({ + open: false, + isImage: false, + name: "", + extension: "", + zoom: 4, + isZoomInDisabled: false, + isZoomOutDisabled: false, + rotation: 0, + }); + const [fileUrl, setFileUrl] = useState(""); + const [downloadURL, setDownloadURL] = useState(""); + const [uploadStarted, setUploadStarted] = useState(false); + const [uploadPercent, setUploadPercent] = useState(0); + const [uploadFileError, setUploadFileError] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const file_type = "NOTES"; + const [files, setFiles] = useState([]); + const [noteId, setNoteId] = useState( + "40faecc6-6199-48cd-bc2a-dd9e73b920f9", + ); + + const fetchData = useCallback(async () => { + setIsLoading(true); + + const res = await request(routes.viewUpload, { + query: { + file_type: file_type, + associating_id: noteId, + is_archived: false, + limit: 100, + offset: 0, + }, + }); + + if (res.data) { + setFiles(res.data.results); + } + + setIsLoading(false); + }, [noteId]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const uploadfile = async (data: CreateFileResponse) => { + const url = data.signed_url; + const internal_name = data.internal_name; + const f = file; + if (!f) return; + const newFile = new File([f], `${internal_name}`); + return new Promise((resolve, reject) => { + uploadFile( + url, + newFile, + "PUT", + { "Content-Type": file?.type }, + (xhr: XMLHttpRequest) => { + if (xhr.status >= 200 && xhr.status < 300) { + setUploadStarted(false); + setFile(null); + fetchData(); + Notification.Success({ + msg: "File Uploaded Successfully", + }); + setUploadFileError(""); + resolve(); + } else { + Notification.Error({ + msg: "Error Uploading File: " + xhr.statusText, + }); + setUploadStarted(false); + reject(); + } + }, + setUploadPercent, + () => { + Notification.Error({ + msg: "Error Uploading File: Network Error", + }); + setUploadStarted(false); + reject(); + }, + ); + }); + }; + + const validateFileUpload = () => { + const f = file; + if (f === undefined || f === null) { + setUploadFileError("Please choose a file to upload"); + return false; + } + if (f.size > 10e7) { + setUploadFileError("Maximum size of files is 100 MB"); + return false; + } + return true; + }; + + const markUploadComplete = (data: CreateFileResponse) => { + return request(routes.editUpload, { + body: { upload_completed: true }, + pathParams: { + id: data.id, + fileType: file_type, + associatingId: noteId, + }, + }); + }; + + const handleUpload = async () => { + if (!validateFileUpload()) return; + const f = file; + if (!f) return; + + const category = f.type.includes("audio") ? "AUDIO" : "UNSPECIFIED"; + setUploadStarted(true); + + const { data } = await request(routes.createUpload, { + body: { + original_name: f.name, + file_type: file_type, + name: f.name, + associating_id: noteId, + file_category: category, + mime_type: f.type, + }, + }); + + if (data) { + await uploadfile(data); + await markUploadComplete(data); + await fetchData(); + } + setFile(null); + }; + + useEffect(() => { + if (file) { + handleUpload(); + } + }, [file]); + + const getExtension = (url: string) => { + const extension = url.split("?")[0].split(".").pop(); + return extension ?? ""; + }; + const downloadFileUrl = (url: string) => { + fetch(url) + .then((res) => res.blob()) + .then((blob) => { + setDownloadURL(URL.createObjectURL(blob)); + }); + }; + + const loadFile = async (id: string) => { + setFileUrl(""); + setFileState({ ...file_state, open: true }); + const { data } = await request(routes.retrieveUpload, { + query: { + file_type: file_type, + associating_id: noteId, + }, + pathParams: { id }, + }); + + if (!data) return; + + const signedUrl = data.read_signed_url as string; + const extension = getExtension(signedUrl); + + setFileState({ + ...file_state, + open: true, + name: data.name as string, + extension, + isImage: ExtImage.includes(extension), + }); + downloadFileUrl(signedUrl); + setFileUrl(signedUrl); + }; + + const handleClose = () => { + setDownloadURL(""); + setFileState({ + ...file_state, + open: false, + zoom: 4, + isZoomInDisabled: false, + isZoomOutDisabled: false, + }); + }; + + return ( +
+ +
+ {isLoading ? ( +

Loading...

+ ) : ( + <> + {files.map((file) => ( +
+ +
loadFile(file.id!)} + > + + + {file.name} + +
+
+ ))} + + )} +
+
+ {uploadFileError && ( +

{uploadFileError}

+ )} + {uploadStarted && ( +
+
+
+
+

+ {uploadPercent}% uploaded +

+
+ )} +
+

Note Id:

+ setNoteId(e.target.value)} + /> +
+
+
+ ); +}; + +const CameraCaptureModal = ({ + open, + onClose, + setFile, +}: { + open: boolean; + onClose: () => void; + setFile: React.Dispatch>; +}) => { + const [previewImage, setPreviewImage] = useState(null); + const webRef = useRef(null); + const FACING_MODE_USER = "user"; + const FACING_MODE_ENVIRONMENT = { exact: "environment" }; + const { width } = useWindowDimensions(); + const LaptopScreenBreakpoint = 640; + + const isLaptopScreen = width >= LaptopScreenBreakpoint ? true : false; + + const [facingMode, setFacingMode] = useState<"front" | "rear">("front"); + const videoConstraints = { + width: 1280, + height: 720, + facingMode: + facingMode === "front" ? FACING_MODE_USER : FACING_MODE_ENVIRONMENT, + }; + + const handleSwitchCamera = useCallback(() => { + setFacingMode((prev) => (prev === "front" ? "rear" : "front")); + }, []); + + const captureImage = () => { + setPreviewImage(webRef.current.getScreenshot()); + const canvas = webRef.current.getCanvas(); + canvas?.toBlob((blob: Blob) => { + const extension = blob.type.split("/").pop(); + const myFile = new File([blob], `image.${extension}`, { + type: blob.type, + }); + setFile(myFile); + }); + }; + + const onUpload = () => { + setFile(null); + onClose(); + }; + + return ( + +
+ +
+
+

Camera

+
+ + } + className="max-w-2xl" + onClose={onClose} + > +
+ {!previewImage ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+ + {/* buttons for mobile screens */} +
+
+ {!previewImage ? ( + + switch + + ) : ( + <> + )} +
+
+ {!previewImage ? ( + <> +
+ { + captureImage(); + }} + className="m-2" + > + capture + +
+ + ) : ( + <> +
+ { + setPreviewImage(null); + }} + className="m-2" + > + retake + + + submit + +
+ + )} +
+
+ { + setPreviewImage(null); + onClose(); + }} + className="m-2" + > + close + +
+
+ {/* buttons for laptop screens */} +