diff --git a/package-lock.json b/package-lock.json index 7fa1431548..b68a02e1a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "redux": "^4.2.1", "redux-thunk": "^2.4.2", "rehype-raw": "^6.1.1", + "rehype-sanitize": "^6.0.0", "use-keyboard-shortcut": "^1.1.6", "xlsx": "^0.18.5" }, @@ -7062,8 +7063,7 @@ "node_modules/@types/unist": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", - "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==", - "dev": true + "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==" }, "node_modules/@types/use-sync-external-store": { "version": "0.0.3", @@ -7531,8 +7531,7 @@ "node_modules/@ungap/structured-clone": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==" }, "node_modules/@vitejs/plugin-react-swc": { "version": "3.6.0", @@ -13129,6 +13128,40 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-sanitize": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.1.tgz", + "integrity": "sha512-IGrgWLuip4O2nq5CugXy4GI2V8kx4sFVy5Hd4vF7AR2gxS0N9s7nEAVUyeMtZKZvzrxVsHt73XdTsno1tClIkQ==", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.2.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-sanitize/node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/hast-util-sanitize/node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-parse5": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-7.1.0.tgz", @@ -20354,6 +20387,27 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-sanitize/node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/rehype-slug": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz", diff --git a/package.json b/package.json index eeb5d36bc2..7cd1659560 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "redux": "^4.2.1", "redux-thunk": "^2.4.2", "rehype-raw": "^6.1.1", + "rehype-sanitize": "^6.0.0", "use-keyboard-shortcut": "^1.1.6", "xlsx": "^0.18.5" }, diff --git a/src/Common/constants.tsx b/src/Common/constants.tsx index 610309057d..2ddc7dae70 100644 --- a/src/Common/constants.tsx +++ b/src/Common/constants.tsx @@ -664,6 +664,11 @@ export const NOTIFICATION_EVENTS: NotificationEvent[] = [ text: "Patient Note Added", icon: "l-notes", }, + { + id: "PATIENT_NOTE_MENTIONED", + text: "Patient Note Mentioned", + icon: "l-at", + }, ]; export const BREATHLESSNESS_LEVEL = [ @@ -745,6 +750,7 @@ export const CONSULTATION_TABS = [ { text: "ABG", desc: "ABG" }, { text: "MEDICINES", desc: "Medicines" }, { text: "FILES", desc: "Files" }, + { text: "DISCUSSION_NOTES_FILES", desc: "Discussion Notes" }, { text: "INVESTIGATIONS", desc: "Investigations" }, { text: "NEUROLOGICAL_MONITORING", desc: "Neuro" }, { text: "VENTILATOR", desc: "Ventilation" }, diff --git a/src/Common/hooks/useAuthUser.ts b/src/Common/hooks/useAuthUser.ts index 78bf93fa8f..3db178e18e 100644 --- a/src/Common/hooks/useAuthUser.ts +++ b/src/Common/hooks/useAuthUser.ts @@ -7,6 +7,7 @@ type SignInReturnType = RequestResult; type AuthContextType = { user: UserModel | undefined; + refetchUser: () => Promise>; signIn: (creds: LoginCredentials) => Promise; signOut: () => Promise; }; diff --git a/src/Components/Common/FilePreviewDialog.tsx b/src/Components/Common/FilePreviewDialog.tsx index 13768a0cf0..bd25b69350 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/RichTextEditor/AudioRecorder.tsx b/src/Components/Common/RichTextEditor/AudioRecorder.tsx new file mode 100644 index 0000000000..caaeb5f928 --- /dev/null +++ b/src/Components/Common/RichTextEditor/AudioRecorder.tsx @@ -0,0 +1,173 @@ +import CareIcon from "../../../CAREUI/icons/CareIcon"; +import useRecorder from "../../../Utils/useRecorder"; +import ButtonV2 from "../components/ButtonV2"; +import { useEffect, useState } from "react"; + +const AudioRecorder = ({ + setFile, + modalOpenForAudio, + setModalOpenForAudio, +}: { + setFile: (file: File) => void; + modalOpenForAudio: boolean; + setModalOpenForAudio: React.Dispatch>; +}) => { + const [audioBlobExists, setAudioBlobExists] = useState(false); + const [resetAudioRecording, setAudioResetRecording] = useState(false); + const [isMicPermission, setIsMicPermission] = useState(true); + const [audioBlob, setAudioBlob] = useState(); + + const handleAudioUpload = () => { + if (!audioBlob) return; + const f = new File([audioBlob], "audio.mp3", { + type: audioBlob.type, + }); + setFile(f); + deleteAudioBlob(); + }; + + useEffect(() => { + if (modalOpenForAudio) { + setAudioBlobExists(false); + setAudioResetRecording(true); + startRecording(); + } + }, [modalOpenForAudio]); + + const [ + audioURL, + isRecording, + startRecording, + stopRecording, + newBlob, + resetRecording, + ] = useRecorder(setIsMicPermission); + const [time, setTime] = useState(0); + useEffect(() => { + setAudioBlob(newBlob); + let interval: number | NodeJS.Timeout | undefined; + if (isRecording) { + interval = setInterval(() => { + setTime((prevTime) => prevTime + 10); + }, 10); + } else { + clearInterval(interval); + setTime(0); + } + if (resetAudioRecording) { + resetRecording(); + setAudioResetRecording(false); + } + return () => clearInterval(interval); + }, [isRecording, newBlob, resetAudioRecording]); + + useEffect(() => { + const checkMicPermission = async () => { + try { + const permissions = await navigator.permissions.query({ + name: "microphone" as PermissionName, + }); + setIsMicPermission(permissions.state === "granted"); + } catch (error) { + setIsMicPermission(false); + } + }; + + checkMicPermission(); + + return () => { + setIsMicPermission(true); + }; + }, []); + + const deleteAudioBlob = () => { + setAudioBlobExists(false); + setAudioResetRecording(true); + setModalOpenForAudio(false); + }; + + return ( +
+ {audioBlobExists && ( +
+ { + deleteAudioBlob(); + }} + > + Delete + +
+ )} +
+
+
+ {isRecording && ( + <> +
+
+ +
+ ( + + {("0" + Math.floor((time / 60000) % 60)).slice(-2)}: + + + {("0" + Math.floor((time / 1000) % 60)).slice(-2)} + + ) +
+ recording... +
+ { + stopRecording(); + setAudioBlobExists(true); + }} + > + + stop + +
+ + )} +
+ {audioURL && audioBlobExists && ( +
+
+ )} +
+ + {!audioBlobExists && !isMicPermission && ( + + + Please allow browser permission before you start speaking + + )} +
+ {audioBlobExists && ( +
+ + + Save + +
+ )} +
+ ); +}; + +export default AudioRecorder; diff --git a/src/Components/Common/RichTextEditor/CameraCaptureModal.tsx b/src/Components/Common/RichTextEditor/CameraCaptureModal.tsx new file mode 100644 index 0000000000..deb6065908 --- /dev/null +++ b/src/Components/Common/RichTextEditor/CameraCaptureModal.tsx @@ -0,0 +1,208 @@ +import Webcam from "react-webcam"; +import useWindowDimensions from "../../../Common/hooks/useWindowDimensions"; +import DialogModal from "../Dialog"; +import { useRef, useState, useCallback } from "react"; +import CareIcon from "../../../CAREUI/icons/CareIcon"; +import ButtonV2, { Submit } from "../components/ButtonV2"; + +const CameraCaptureModal = ({ + open, + onClose, + setFile, +}: { + open: boolean; + onClose: () => void; + setFile: (file: File) => void; +}) => { + 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 [capture, setCapture] = useState(null); + + 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 = () => { + if (!webRef.current) return; + setPreviewImage(webRef.current.getScreenshot()); + const canvas = webRef.current.getCanvas(); + canvas?.toBlob((blob: Blob | null) => { + if (!blob) return; + const extension = blob.type.split("/").pop(); + const myFile = new File([blob], `image.${extension}`, { + type: blob.type, + }); + setCapture(myFile); + }); + }; + + const onUpload = () => { + if (!capture) return; + setFile(capture); + 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 */} +
+
+ + + switch camera + +
+ +
+
+ {!previewImage ? ( + <> +
+ { + captureImage(); + }} + > + + capture + +
+ + ) : ( + <> +
+ { + setPreviewImage(null); + }} + > + retake + + submit +
+ + )} +
+
+ { + setPreviewImage(null); + onClose(); + }} + > + close camera + +
+
+ + ); +}; + +export default CameraCaptureModal; diff --git a/src/Components/Common/RichTextEditor/MarkdownPreview.tsx b/src/Components/Common/RichTextEditor/MarkdownPreview.tsx new file mode 100644 index 0000000000..2a99cf8d2f --- /dev/null +++ b/src/Components/Common/RichTextEditor/MarkdownPreview.tsx @@ -0,0 +1,106 @@ +import React, { useState } from "react"; +import ReactMarkdown from "react-markdown"; +import rehypeRaw from "rehype-raw"; +import { UserBareMinimum } from "../../Users/models"; + +interface CustomLinkProps { + className?: string; + "data-username"?: string; +} + +const UserCard = ({ user }: { user: UserBareMinimum }) => ( +
+
+ {user.first_name[0]} +
+
+

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

+

@{user.username}

+

{user.user_type}

+
+
+); + +const MarkdownPreview = ({ + markdown, + mentioned_users, +}: { + markdown: string; + mentioned_users?: UserBareMinimum[]; +}) => { + const MentionedUsers: Record = {}; + if (mentioned_users) { + mentioned_users.forEach((user) => { + MentionedUsers[user.username] = user; + }); + } + + const processedMarkdown = markdown + .replace(/@(\w+)/g, (_, username) => { + const user = MentionedUsers[username]; + if (user) { + return `@${username}`; + } else { + return `@${username}`; + } + }) + .replace(/~~(.*?)~~/g, (_, text) => `${text}`); + + const CustomLink: React.FC = (props) => { + const [isHovered, setIsHovered] = useState(false); + + if (props["data-username"]) { + const username = props["data-username"]; + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + { + e.stopPropagation(); + }} + className="cursor-pointer rounded bg-blue-100 px-1 font-normal text-slate-800 no-underline" + > + @{username} + + {MentionedUsers[username] && isHovered && ( +
+ +
+
+ )} +
+ ); + } + return ( + e.stopPropagation()} + className="text-blue-500 underline" + /> + ); + }; + + return ( + + {processedMarkdown} + + ); +}; + +export default MarkdownPreview; diff --git a/src/Components/Common/RichTextEditor/MentionDropdown.tsx b/src/Components/Common/RichTextEditor/MentionDropdown.tsx new file mode 100644 index 0000000000..24cbfd61db --- /dev/null +++ b/src/Components/Common/RichTextEditor/MentionDropdown.tsx @@ -0,0 +1,113 @@ +import React, { useEffect, useState, useMemo, useCallback } from "react"; +import routes from "../../../Redux/api"; +import useQuery from "../../../Utils/request/useQuery"; +import useSlug from "../../../Common/hooks/useSlug"; + +interface MentionsDropdownProps { + onSelect: (user: { id: string; username: string }) => void; + position: { top: number; left: number }; + editorRef: React.RefObject; + filter: string; +} + +const MentionsDropdown: React.FC = ({ + onSelect, + position, + editorRef, + filter, +}) => { + const facilityId = useSlug("facility"); + const { data, loading } = useQuery(routes.getFacilityUsers, { + pathParams: { facility_id: facilityId }, + }); + + const users = data?.results || []; + + const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 }); + const [selectedIndex, setSelectedIndex] = useState(null); + + useEffect(() => { + if (editorRef.current) { + setDropdownPosition({ + top: position.top, + left: position.left, + }); + } + }, [position, editorRef]); + + const filteredUsers = useMemo(() => { + return users.filter((user) => + user.username.toLowerCase().startsWith(filter.toLowerCase()), + ); + }, [users, filter]); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === "Enter" && filteredUsers.length > 0) { + event.preventDefault(); + if (selectedIndex !== null) { + onSelect({ + id: filteredUsers[selectedIndex].id.toString(), + username: filteredUsers[selectedIndex].username, + }); + } else { + onSelect({ + id: filteredUsers[0].id.toString(), + username: filteredUsers[0].username, + }); + } + } else if (event.key === "ArrowDown") { + event.preventDefault(); + setSelectedIndex((prevIndex) => { + if (prevIndex === null) return 0; + return Math.min(filteredUsers.length - 1, prevIndex + 1); + }); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setSelectedIndex((prevIndex) => { + if (prevIndex === null) return filteredUsers.length - 1; + return Math.max(0, prevIndex - 1); + }); + } + }, + [filteredUsers, selectedIndex, onSelect], + ); + + useEffect(() => { + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [handleKeyDown]); + + return ( +
+ {loading &&
Loading...
} + {filteredUsers.length > 0 && !loading ? ( + filteredUsers.map((user, index) => ( +
+ onSelect({ id: user.id.toString(), username: user.username }) + } + > + + {user.first_name[0]} + + {user.username} +
+ )) + ) : ( +
No users found
+ )} +
+ ); +}; + +export default MentionsDropdown; diff --git a/src/Components/Common/RichTextEditor/RichTextEditor.tsx b/src/Components/Common/RichTextEditor/RichTextEditor.tsx new file mode 100644 index 0000000000..c7be46a30a --- /dev/null +++ b/src/Components/Common/RichTextEditor/RichTextEditor.tsx @@ -0,0 +1,780 @@ +import React, { + useRef, + useState, + useCallback, + ChangeEvent, + useEffect, +} from "react"; +import MentionsDropdown from "./MentionDropdown"; +import { ExtImage } from "../../../Utils/useFileUpload"; +import imageCompression from "browser-image-compression"; +import DialogModal from "../Dialog"; +import CareIcon from "../../../CAREUI/icons/CareIcon"; +import ButtonV2, { Submit } from "../components/ButtonV2"; +import CameraCaptureModal from "./CameraCaptureModal"; +import AudioRecorder from "./AudioRecorder"; +import request from "../../../Utils/request/request"; +import routes from "../../../Redux/api"; +import uploadFile from "../../../Utils/request/uploadFile"; +import * as Notification from "../../../Utils/Notifications.js"; +import { CreateFileResponse } from "../../Patient/models"; +import MarkdownPreview from "./MarkdownPreview"; + +interface RichTextEditorProps { + initialMarkdown?: string; + onChange: (markdown: string) => void; + onAddNote: () => Promise; + isAuthorized?: boolean; + onRefetch?: () => void; +} + +const lineStyles = { + orderedList: /^\d+\.\s/, + unorderedList: /^-\s/, + quote: /^>\s/, + emptyOrderedList: /^\d+\.\s*$/, + emptyUnorderedList: /^-\s*$/, + emptyQuote: /^>\s*$/, + startWithNumber: /^\d+/, + containsNumber: /\d+/, +}; + +const RichTextEditor: React.FC = ({ + initialMarkdown: markdown = "", + onChange: setMarkdown, + onAddNote, + isAuthorized = true, + onRefetch, +}) => { + const editorRef = useRef(null); + const [showMentions, setShowMentions] = useState(false); + const [mentionPosition, setMentionPosition] = useState({ top: 0, left: 0 }); + + const [mentionFilter, setMentionFilter] = useState(""); + const fileInputRef = useRef(null); + const [showPreview, setShowPreview] = useState(false); + const [modalOpenForCamera, setModalOpenForCamera] = useState(false); + const [modalOpenForAudio, setModalOpenForAudio] = useState(false); + const [linkDialogState, setLinkDialogState] = useState({ + showDialog: false, + url: "", + selectedText: "", + linkText: "", + }); + + const [tempFiles, setTempFiles] = useState([]); + + const insertMarkdown = (prefix: string, suffix: string = prefix) => { + if (!editorRef.current) return; + + const start = editorRef.current.selectionStart; + const end = editorRef.current.selectionEnd; + const text = editorRef.current.value; + + const beforeSelection = text.substring(0, start); + const selection = text.substring(start, end); + const afterSelection = text.substring(end); + + let newText = ""; + let newCursorPosition = 0; + + if (selection) { + newText = `${beforeSelection}${prefix}${selection}${suffix}${afterSelection}`; + newCursorPosition = start + prefix.length + selection.length; + } else { + newText = `${beforeSelection}${prefix}${suffix}${afterSelection}`; + newCursorPosition = start + prefix.length; + } + + setMarkdown(newText); + + // Using setTimeout to ensure the new text is set before we try to move the cursor + setTimeout(() => { + if (editorRef.current) { + editorRef.current.focus(); + editorRef.current.setSelectionRange( + newCursorPosition, + newCursorPosition, + ); + } + }, 0); + }; + + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setShowMentions(false); + setMentionFilter(""); + } + }; + + document.addEventListener("keydown", handleEscape); + return () => { + document.removeEventListener("keydown", handleEscape); + }; + }, []); + + const insertMention = (user: { id: string; username: string }) => { + if (!editorRef.current) return; + + const start = editorRef.current.selectionStart; + const text = editorRef.current.value; + const lastAtSymbolIndex = text.lastIndexOf("@", start - 1); + + const beforeMention = text.substring(0, lastAtSymbolIndex); + const afterMention = text.substring(start); + + const displayMention = `@${user.username}`; + const newMarkdown = `${beforeMention}${displayMention}${afterMention}`; + setMarkdown(newMarkdown); + + editorRef.current.focus(); + const newCursorPosition = lastAtSymbolIndex + displayMention.length; + editorRef.current.setSelectionRange(newCursorPosition, newCursorPosition); + + setShowMentions(false); + setMentionFilter(""); + }; + + const handleInput = useCallback( + (event: React.ChangeEvent) => { + const newMarkdown = event.target.value; + const caretPosition = event.target.selectionStart; + + setMarkdown(newMarkdown); + + const textBeforeCaret = newMarkdown.substring(0, caretPosition); + const lastAtSymbolIndex = textBeforeCaret.lastIndexOf("@"); + + if (lastAtSymbolIndex !== -1) { + const mentionText = textBeforeCaret.substring(lastAtSymbolIndex + 1); + if (mentionText.includes(" ")) return; + setMentionFilter(mentionText); + + if (editorRef.current) { + const { top, left } = getCaretCoordinates( + editorRef.current, + caretPosition, + ); + setMentionPosition({ top: top + 50, left: left + 10 }); + setShowMentions(true); + } + } else { + setShowMentions(false); + } + }, + [], + ); + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + if (!editorRef.current) return; + + const text = editorRef.current.value; + const selectionStart = editorRef.current.selectionStart || 0; + const currentLineStart = text.lastIndexOf("\n", selectionStart - 1) + 1; + const currentLine = text.slice(currentLineStart, selectionStart); + + let newText = text; + let newCursorPos = selectionStart; + + if ( + lineStyles.emptyOrderedList.test(currentLine) || + lineStyles.emptyUnorderedList.test(currentLine) || + lineStyles.emptyQuote.test(currentLine) + ) { + newText = text.slice(0, currentLineStart) + text.slice(selectionStart); + newCursorPos = currentLineStart; + } else { + let newLine = "\n"; + + if (lineStyles.orderedList.test(currentLine)) { + const currentNumber = parseInt( + currentLine.match(lineStyles.startWithNumber)?.[0] || "0", + 10, + ); + newLine += `${currentNumber + 1}. `; + } else if (lineStyles.unorderedList.test(currentLine)) { + newLine += "- "; + } else if (lineStyles.quote.test(currentLine)) { + newLine += "> "; + } + + newText = + text.slice(0, selectionStart) + newLine + text.slice(selectionStart); + newCursorPos = selectionStart + newLine.length; + } + + editorRef.current.value = newText; + editorRef.current.setSelectionRange(newCursorPos, newCursorPos); + setMarkdown(newText); + } + }; + + const handleOrderedList = () => { + if (!editorRef.current) return; + const selectionStart = editorRef.current.selectionStart || 0; + const lineIndex = getCurrentLineIndex(selectionStart); + const currentLine = getCurrentLine(lineIndex); + + let newText = ""; + if (lineStyles.orderedList.test(currentLine)) { + newText = currentLine.replace(lineStyles.orderedList, ""); + } else { + const prevLine = getCurrentLine(lineIndex - 1); + const prevNumber = lineStyles.orderedList + .exec(prevLine)?.[0] + .match(lineStyles.containsNumber)?.[0]; + const nextNumber = prevNumber ? parseInt(prevNumber) + 1 : 1; + newText = `${nextNumber}. ${currentLine}`; + } + + replaceLine(lineIndex, newText); + }; + + const handleUnorderedList = () => { + if (!editorRef.current) return; + const selectionStart = editorRef.current.selectionStart || 0; + const lineIndex = getCurrentLineIndex(selectionStart); + const currentLine = getCurrentLine(lineIndex); + + let newText = ""; + if (lineStyles.unorderedList.test(currentLine)) { + newText = currentLine.replace(lineStyles.unorderedList, ""); + } else { + newText = `- ${currentLine}`; + } + + replaceLine(lineIndex, newText); + }; + + const handleQuote = () => { + if (!editorRef.current) return; + const selectionStart = editorRef.current.selectionStart || 0; + const lineIndex = getCurrentLineIndex(selectionStart); + const currentLine = getCurrentLine(lineIndex); + + let newText = ""; + if (lineStyles.quote.test(currentLine)) { + newText = currentLine.replace(lineStyles.quote, ""); + } else { + newText = `> ${currentLine}`; + } + + replaceLine(lineIndex, newText); + }; + + const getCurrentLine = (lineIndex: number): string => { + if (!editorRef.current) return ""; + const lines = editorRef.current.value.split("\n"); + return lines[lineIndex] || ""; + }; + + const replaceLine = (lineIndex: number, newText: string) => { + if (!editorRef.current) return; + const text = editorRef.current.value; + const lines = text.split("\n"); + + if (lineIndex < 0 || lineIndex >= lines.length) return; + + lines[lineIndex] = newText; + const newValue = lines.join("\n"); + editorRef.current.value = newValue; + setMarkdown(newValue); + + const newLineStart = + lines.slice(0, lineIndex).join("\n").length + (lineIndex > 0 ? 1 : 0); + const newCursorPosition = newLineStart + newText.length; + editorRef.current.setSelectionRange(newCursorPosition, newCursorPosition); + editorRef.current.focus(); + }; + + const getCurrentLineIndex = (cursorPosition: number) => { + const text = editorRef.current?.value || ""; + return text.substring(0, cursorPosition).split("\n").length - 1; + }; + + const formatUrl = (url: string) => { + if (!/^https?:\/\//i.test(url)) { + return `https://${url}`; + } + return url; + }; + const handleLink = () => { + if (!editorRef.current) return; + + const start = editorRef.current.selectionStart; + const end = editorRef.current.selectionEnd; + const text = editorRef.current.value; + + const selectedText = text.substring(start, end); + + setLinkDialogState({ + showDialog: true, + url: "", + linkText: selectedText, + selectedText, + }); + }; + + const handleInsertLink = () => { + if (!editorRef.current) return; + + const { start } = getCaretCoordinates( + editorRef.current, + editorRef.current.selectionStart, + ); + + const text = editorRef.current.value; + + const beforeSelection = text.substring(0, start); + const afterSelection = text.substring( + start + linkDialogState.selectedText.length, + ); + + const markdownLink = `[${linkDialogState.linkText || linkDialogState.url}](${formatUrl(linkDialogState.url)})`; + const newText = `${beforeSelection}${markdownLink}${afterSelection}`; + + setMarkdown(newText); + editorRef.current.focus(); + editorRef.current.setSelectionRange( + start + markdownLink.length, + start + markdownLink.length, + ); + + setLinkDialogState({ + showDialog: false, + url: "", + linkText: "", + selectedText: "", + }); + }; + + const uploadfile = async (data: CreateFileResponse, file: File) => { + const url = data.signed_url; + const internal_name = data.internal_name; + const newFile = new File([file], `${internal_name}`); + return new Promise((resolve, reject) => { + uploadFile( + url, + newFile, + "PUT", + { "Content-Type": file.type }, + async (xhr: XMLHttpRequest) => { + if (xhr.status >= 200 && xhr.status < 300) { + Notification.Success({ + msg: "File Uploaded Successfully", + }); + resolve(); + } else { + Notification.Error({ + msg: "Error Uploading File: " + xhr.statusText, + }); + reject(); + } + }, + null, + () => { + Notification.Error({ + msg: "Error Uploading File: Network Error", + }); + reject(); + }, + ); + }); + }; + + const setFile = (file: File) => { + setTempFiles((prevFiles) => [...prevFiles, file]); + }; + + const handleFileUpload = async (file: File, noteId: string) => { + const category = file.type.includes("audio") ? "AUDIO" : "UNSPECIFIED"; + + const { data } = await request(routes.createUpload, { + body: { + original_name: file.name, + file_type: "NOTES", + name: file.name, + associating_id: noteId, + file_category: category, + mime_type: file.type, + }, + }); + + if (data) { + await uploadfile(data, file); + await markUploadComplete(data, noteId); + } + }; + + const onFileChange = async (e: ChangeEvent) => { + if (!e.target.files?.length) { + return; + } + const f = e.target.files[0]; + const fileName = f.name; + + const ext: string = fileName.split(".")[1]; + + if (ExtImage.includes(ext)) { + const options = { + initialQuality: 0.6, + alwaysKeepResolution: true, + }; + const compressedFile = await imageCompression(f, options); + setTempFiles((prevFiles) => [...prevFiles, compressedFile]); + } else { + setTempFiles((prevFiles) => [...prevFiles, f]); + } + }; + + const markUploadComplete = (data: CreateFileResponse, noteId: string) => { + return request(routes.editUpload, { + body: { upload_completed: true }, + pathParams: { + id: data.id, + fileType: "NOTES", + associatingId: noteId, + }, + }); + }; + + return ( +
+ setModalOpenForCamera(false)} + setFile={setFile} + /> + + + setShowPreview(false)} + > +
+ +
+
+ + {/* toolbar */} +
+ + + +
+ + + +
+ + + + +
+
setShowPreview(true)} + > + + preview +
+
+ + {/* editor */} +
+