diff --git a/package-lock.json b/package-lock.json index f5edb998e4e..80a16f4ebb8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5149,6 +5149,15 @@ "dev": true, "license": "MIT" }, + "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==", + "dev": true, + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/is-empty": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@types/is-empty/-/is-empty-1.2.3.tgz", @@ -12225,16 +12234,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/mdast-util-mdx-expression/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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, "node_modules/mdast-util-mdx-expression/node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -12320,45 +12319,6 @@ "micromark-util-types": "^2.0.0" } }, - "node_modules/mdast-util-mdx-expression/node_modules/micromark-util-encode": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.0.tgz", - "integrity": "sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/mdast-util-mdx-expression/node_modules/micromark-util-sanitize-uri": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.0.tgz", - "integrity": "sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, "node_modules/mdast-util-mdx-expression/node_modules/unist-util-stringify-position": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", @@ -12398,16 +12358,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/mdast-util-mdx-jsx/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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, "node_modules/mdast-util-mdx-jsx/node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -12574,45 +12524,6 @@ "micromark-util-types": "^2.0.0" } }, - "node_modules/mdast-util-mdx-jsx/node_modules/micromark-util-encode": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.0.tgz", - "integrity": "sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/mdast-util-mdx-jsx/node_modules/micromark-util-sanitize-uri": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.0.tgz", - "integrity": "sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, "node_modules/mdast-util-mdx-jsx/node_modules/parse-entities": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.1.tgz", @@ -12740,45 +12651,6 @@ "micromark-util-types": "^2.0.0" } }, - "node_modules/mdast-util-mdx/node_modules/micromark-util-encode": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.0.tgz", - "integrity": "sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/mdast-util-mdx/node_modules/micromark-util-sanitize-uri": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.0.tgz", - "integrity": "sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, "node_modules/mdast-util-mdx/node_modules/unist-util-stringify-position": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", @@ -12812,16 +12684,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/mdast-util-mdxjs-esm/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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, "node_modules/mdast-util-mdxjs-esm/node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -12907,45 +12769,6 @@ "micromark-util-types": "^2.0.0" } }, - "node_modules/mdast-util-mdxjs-esm/node_modules/micromark-util-encode": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.0.tgz", - "integrity": "sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/mdast-util-mdxjs-esm/node_modules/micromark-util-sanitize-uri": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.0.tgz", - "integrity": "sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, "node_modules/mdast-util-mdxjs-esm/node_modules/unist-util-stringify-position": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", @@ -13510,6 +13333,22 @@ "micromark-util-symbol": "^2.0.0" } }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, "node_modules/micromark-util-events-to-acorn": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/micromark-util-events-to-acorn/-/micromark-util-events-to-acorn-2.0.2.tgz", @@ -13594,6 +13433,27 @@ "micromark-util-types": "^2.0.0" } }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, "node_modules/micromark-util-subtokenize": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.0.1.tgz", @@ -15790,45 +15650,6 @@ "micromark-util-types": "^2.0.0" } }, - "node_modules/remark-parse/node_modules/micromark-util-encode": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.0.tgz", - "integrity": "sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/remark-parse/node_modules/micromark-util-sanitize-uri": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.0.tgz", - "integrity": "sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, "node_modules/remark-parse/node_modules/unist-util-stringify-position": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", diff --git a/public/locale/en.json b/public/locale/en.json index 1159c4ab0dc..a058283a36f 100644 --- a/public/locale/en.json +++ b/public/locale/en.json @@ -30,6 +30,7 @@ "CONSULTATION_TAB__ABDM": "ABDM Records", "CONSULTATION_TAB__ABG": "ABG", "CONSULTATION_TAB__DIALYSIS": "Dialysis", + "CONSULTATION_TAB__DISCUSSION_NOTES_FILES": "Discussion Notes", "CONSULTATION_TAB__FEED": "Feed", "CONSULTATION_TAB__FILES": "Files", "CONSULTATION_TAB__INVESTIGATIONS": "Investigations", diff --git a/src/Utils/request/api.tsx b/src/Utils/request/api.tsx index d6d30dfce85..7a47cbd477b 100644 --- a/src/Utils/request/api.tsx +++ b/src/Utils/request/api.tsx @@ -811,6 +811,11 @@ const routes = { method: "PUT", TRes: Type(), }, + getPatientNote: { + path: "/api/v1/patient/{patientId}/notes/{noteId}/", + method: "GET", + TRes: Type(), + }, getPatientNoteEditHistory: { path: "/api/v1/patient/{patientId}/notes/{noteId}/edits/", method: "GET", @@ -1120,7 +1125,7 @@ const routes = { TBody: Type(), }, - // FileUpload Create + // FileUploads createUpload: { path: "/api/v1/files/", method: "POST", @@ -1144,6 +1149,13 @@ const routes = { TRes: Type(), }, + // Consultation FileUploads + listConsultationFileUploads: { + path: "/api/v1/consultation/{consultation_external_id}/files/", + method: "GET", + TRes: Type>(), + }, + // Investigation listInvestigations: { path: "/api/v1/investigation/", diff --git a/src/common/constants.tsx b/src/common/constants.tsx index 857796d7c88..c9da5d2f39f 100644 --- a/src/common/constants.tsx +++ b/src/common/constants.tsx @@ -671,6 +671,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 = [ diff --git a/src/components/Common/FilePreviewDialog.tsx b/src/components/Common/FilePreviewDialog.tsx index 4b7a07bd0cd..871cbf915a9 100644 --- a/src/components/Common/FilePreviewDialog.tsx +++ b/src/components/Common/FilePreviewDialog.tsx @@ -43,16 +43,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..e4a353fd231 --- /dev/null +++ b/src/components/Common/MarkdownPreview.tsx @@ -0,0 +1,149 @@ +import DOMPurify from "dompurify"; +import { marked } from "marked"; +import { useEffect, useState } from "react"; + +import { UserBareMinimum } from "@/components/Users/models"; + +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 = Object.fromEntries( + mentioned_users?.map((u) => [u.username, u]) ?? [], + ); + + const [hoveredUser, setHoveredUser] = useState(null); + const [hoverPosition, setHoverPosition] = useState<{ + x: number; + y: number; + } | null>(null); + + const renderer = new marked.Renderer(); + renderer.link = function ({ + href, + title, + text, + }: { + href: string; + title?: string | null; + text: string; + }) { + try { + const url = new URL(href); + if (!["http:", "https:"].includes(url.protocol)) { + return text; + } + href = url.toString(); + } catch { + return text; + } + return `${text}`; + }; + + const processedMarkdown = markdown + .replace(/@([a-zA-Z0-9_]{3,30})/g, (_, username) => { + const user = MentionedUsers[username]; + if (user) { + const sanitizedUsername = username.replace(/[<>"'&]/g, ""); + return `@${sanitizedUsername}`; + } else { + return `@${username}`; + } + }) + .replace(/~~(.*?)~~/g, (_, text) => `${text}`); + + const html = marked + .parse(processedMarkdown, { + gfm: true, + breaks: true, + renderer: renderer, + }) + .toString(); + + const sanitizedHtml = DOMPurify.sanitize(html, { + ADD_ATTR: ["target", "rel"], + }); + + useEffect(() => { + const mentionElements = document.querySelectorAll(".mention"); + const listeners: Array<{ + element: Element; + enter: () => void; + leave: () => void; + }> = []; + + mentionElements.forEach((ele) => { + const handleEnter = () => { + const username = ele.getAttribute("data-username"); + if (username) { + setHoveredUser(username); + const rect = ele.getBoundingClientRect(); + setHoverPosition({ + x: rect.left, + y: rect.top, + }); + } + }; + const handleLeave = () => { + setHoveredUser(null); + setHoverPosition(null); + }; + + ele.addEventListener("mouseenter", handleEnter); + ele.addEventListener("mouseleave", handleLeave); + + listeners.push({ + element: ele, + enter: handleEnter, + leave: handleLeave, + }); + }); + + return () => { + listeners.forEach(({ element, enter, leave }) => { + element.removeEventListener("mouseenter", enter); + element.removeEventListener("mouseleave", leave); + }); + }; + }, [sanitizedHtml]); + + return ( +
+
+ {hoveredUser && hoverPosition && MentionedUsers[hoveredUser] && ( +
+ +
+
+ )} +
+ ); +}; + +export default MarkdownPreview; diff --git a/src/components/Common/MentionDropdown.tsx b/src/components/Common/MentionDropdown.tsx new file mode 100644 index 00000000000..c4365242802 --- /dev/null +++ b/src/components/Common/MentionDropdown.tsx @@ -0,0 +1,140 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; + +import useSlug from "@/hooks/useSlug"; + +import routes from "@/Utils/request/api"; +import useQuery from "@/Utils/request/useQuery"; + +interface MentionsDropdownProps { + onSelect: (user: { id: string; username: string }) => void; + position: { top: number; left: number }; + editorRef: React.RefObject; + filter: string; +} + +const KEYS = { + ENTER: "Enter", + ARROW_UP: "ArrowUp", + ARROW_DOWN: "ArrowDown", + ESCAPE: "Escape", +} as const; + +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 (document.activeElement !== editorRef.current) { + return; + } + if (event.key === KEYS.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 === KEYS.ESCAPE) { + event.preventDefault(); + onSelect({ id: "", username: "" }); + } else if (event.key === KEYS.ARROW_DOWN) { + event.preventDefault(); + setSelectedIndex((prevIndex) => { + if (prevIndex === null) return 0; + return Math.min(filteredUsers.length - 1, prevIndex + 1); + }); + } else if (event.key === KEYS.ARROW_UP) { + event.preventDefault(); + setSelectedIndex((prevIndex) => { + if (prevIndex === null) return filteredUsers.length - 1; + return Math.max(0, prevIndex - 1); + }); + } + }, + [filteredUsers, selectedIndex, onSelect, editorRef], + ); + + useEffect(() => { + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [handleKeyDown]); + + return ( +
+ {loading && ( +
+ Loading users... +
+ )} + {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.tsx b/src/components/Common/RichTextEditor.tsx new file mode 100644 index 00000000000..806fe985aae --- /dev/null +++ b/src/components/Common/RichTextEditor.tsx @@ -0,0 +1,766 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; + +import CareIcon from "@/CAREUI/icons/CareIcon"; + +import ButtonV2, { Submit } from "@/components/Common/ButtonV2"; +import DialogModal from "@/components/Common/Dialog"; + +import useFileUpload from "@/hooks/useFileUpload"; + +import { classNames } from "@/Utils/utils"; + +import MarkdownPreview from "./MarkdownPreview"; +import MentionsDropdown from "./MentionDropdown"; + +interface RichTextEditorProps { + initialMarkdown?: string; + onChange: (markdown: string) => void; + onAddNote: () => Promise; + isAuthorized?: boolean; + onRefetch?: () => void; + maxRows?: number; +} + +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, + maxRows, +}) => { + const editorRef = useRef(null); + const [showMentions, setShowMentions] = useState(false); + const [mentionPosition, setMentionPosition] = useState({ top: 0, left: 0 }); + + const [mentionFilter, setMentionFilter] = useState(""); + const [linkDialogState, setLinkDialogState] = useState({ + showDialog: false, + url: "", + selectedText: "", + linkText: "", + }); + + const [isPreviewMode, setIsPreviewMode] = useState(false); + + const fileUpload = useFileUpload({ + type: "NOTES", + category: "UNSPECIFIED", + multiple: true, + allowAllExtensions: true, + }); + + 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 adjustTextareaHeight = useCallback( + (textarea: HTMLTextAreaElement) => { + textarea.style.height = "auto"; + + const style = window.getComputedStyle(textarea); + const borderHeight = + parseInt(style.borderTopWidth) + parseInt(style.borderBottomWidth); + const paddingHeight = + parseInt(style.paddingTop) + parseInt(style.paddingBottom); + + const lineHeight = parseInt(style.lineHeight); + const maxHeight = maxRows + ? lineHeight * maxRows + borderHeight + paddingHeight + : Infinity; + + const newHeight = Math.min( + textarea.scrollHeight + borderHeight, + maxHeight, + ); + textarea.style.height = `${newHeight}px`; + }, + [maxRows], + ); + + const handleInput = useCallback( + (event: React.ChangeEvent) => { + const newMarkdown = event.target.value; + const caretPosition = event.target.selectionStart; + + setMarkdown(newMarkdown); + adjustTextareaHeight(event.target); + + 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); + } + }, + [adjustTextareaHeight], + ); + + useEffect(() => { + if (editorRef.current) { + adjustTextareaHeight(editorRef.current); + } + }, [markdown, adjustTextareaHeight]); + + 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 selectionEnd = editorRef.current.selectionEnd || 0; + const text = editorRef.current.value; + + if (selectionStart === selectionEnd) { + 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); + } else { + const selectedText = text.substring(selectionStart, selectionEnd); + const lines = selectedText.split("\n"); + + let newText = ""; + let currentNumber = 1; + + lines.forEach((line) => { + if (lineStyles.orderedList.test(line)) { + newText += line.replace(lineStyles.orderedList, "") + "\n"; + } else { + newText += `${currentNumber}. ${line}\n`; + currentNumber++; + } + }); + + newText = newText.trimEnd(); + + const beforeSelection = text.substring(0, selectionStart); + const afterSelection = text.substring(selectionEnd); + + const updatedText = beforeSelection + newText + afterSelection; + setMarkdown(updatedText); + } + }; + + const handleUnorderedList = () => { + if (!editorRef.current) return; + const selectionStart = editorRef.current.selectionStart || 0; + const selectionEnd = editorRef.current.selectionEnd || 0; + const text = editorRef.current.value; + + if (selectionStart === selectionEnd) { + 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); + } else { + const selectedText = text.substring(selectionStart, selectionEnd); + const lines = selectedText.split("\n"); + + let newText = ""; + + lines.forEach((line) => { + if (lineStyles.unorderedList.test(line)) { + newText += line.replace(lineStyles.unorderedList, "") + "\n"; + } else { + newText += `- ${line}\n`; + } + }); + + newText = newText.trimEnd(); + + const beforeSelection = text.substring(0, selectionStart); + const afterSelection = text.substring(selectionEnd); + + const updatedText = beforeSelection + newText + afterSelection; + setMarkdown(updatedText); + } + }; + + 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 || !linkDialogState.url.trim()) 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: "", + }); + }; + + return ( +
+ {/* toolbar */} +
+ + + +
+ + + +
+ + + + +
+
+ + +
+
+ + {/* editor/preview */} +
+ {isPreviewMode ? ( +
+ +
+ ) : ( +