Created
July 24, 2024 01:04
-
-
Save htlin222/2d7655da0ca662252787c5ee5c002d95 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { | |
useState, | |
useRef, | |
useEffect, | |
useCallback, | |
useMemo, | |
} from "react"; | |
import { | |
Search, | |
Save, | |
Send, | |
PanelLeftClose, | |
PanelRightClose, | |
Menu, | |
} from "lucide-react"; | |
import { Button } from "@/components/ui/button"; | |
import { Input } from "@/components/ui/input"; | |
import { | |
Select, | |
SelectContent, | |
SelectItem, | |
SelectTrigger, | |
SelectValue, | |
} from "@/components/ui/select"; | |
import { Card, CardContent } from "@/components/ui/card"; | |
import { | |
Dialog, | |
DialogContent, | |
DialogHeader, | |
DialogTitle, | |
} from "@/components/ui/dialog"; | |
const MarkdownEditor = () => { | |
const [patients, setPatients] = useState([ | |
{ name: "John Doe", wardNumber: "A101", chartNumber: "12345" }, | |
{ name: "Jane Smith", wardNumber: "B202", chartNumber: "67890" }, | |
{ name: "Bob Johnson", wardNumber: "C303", chartNumber: "11223" }, | |
]); | |
const [snippets, setSnippets] = useState([ | |
"# Heading", | |
"## Subheading", | |
"- List item", | |
]); | |
const [editorContent, setEditorContent] = useState(""); | |
const [snippetSearchTerm, setSnippetSearchTerm] = useState(""); | |
const [patientSearchTerm, setPatientSearchTerm] = useState(""); | |
const [leftPaneVisible, setLeftPaneVisible] = useState(true); | |
const [rightPaneVisible, setRightPaneVisible] = useState(true); | |
const [leftPaneWidth, setLeftPaneWidth] = useState(25); | |
const [rightPaneWidth, setRightPaneWidth] = useState(25); | |
const [author, setAuthor] = useState(""); | |
const [category, setCategory] = useState(""); | |
const [date, setDate] = useState(""); | |
const [time, setTime] = useState(""); | |
const [isMobile, setIsMobile] = useState(false); | |
const [isPatientListOpen, setIsPatientListOpen] = useState(false); | |
const [selectedPatient, setSelectedPatient] = useState(null); | |
const [autocompleteSnippets, setAutocompleteSnippets] = useState([]); | |
const [selectedAutocompleteIndex, setSelectedAutocompleteIndex] = useState(0); | |
const [cursorPosition, setCursorPosition] = useState(0); | |
const [editorHeight, setEditorHeight] = useState("100%"); | |
const textareaRef = useRef(null); | |
const handleSave = useCallback(() => { | |
console.log("Saving content:", editorContent); | |
}, [editorContent]); | |
const handlePublish = useCallback(() => { | |
console.log("Publishing content:", editorContent); | |
}, [editorContent]); | |
const handleEditorChange = useCallback( | |
(e) => { | |
const newContent = e.target.value; | |
setEditorContent(newContent); | |
setCursorPosition(e.target.selectionStart); | |
const lastWord = newContent | |
.slice(0, e.target.selectionStart) | |
.split(/\s/) | |
.pop(); | |
if (lastWord.length >= 1) { | |
const matches = snippets.filter((snippet) => | |
snippet.toLowerCase().startsWith(lastWord.toLowerCase()), | |
); | |
setAutocompleteSnippets(matches); | |
setSelectedAutocompleteIndex(0); | |
} else { | |
setAutocompleteSnippets([]); | |
} | |
}, | |
[snippets], | |
); | |
const insertSnippet = useCallback( | |
(snippet) => { | |
const beforeCursor = editorContent.slice(0, cursorPosition); | |
const afterCursor = editorContent.slice(cursorPosition); | |
const lastWord = beforeCursor.split(/\s/).pop(); | |
const completionPart = snippet.slice(lastWord.length); | |
const newContent = beforeCursor + completionPart + afterCursor; | |
setEditorContent(newContent); | |
setCursorPosition(cursorPosition + completionPart.length); | |
setAutocompleteSnippets([]); | |
}, | |
[editorContent, cursorPosition], | |
); | |
const handleKeyDown = useCallback( | |
(e) => { | |
if (autocompleteSnippets.length > 0) { | |
if (e.key === "Enter") { | |
e.preventDefault(); | |
insertSnippet(autocompleteSnippets[selectedAutocompleteIndex]); | |
} else if (e.key === "Tab") { | |
e.preventDefault(); | |
setSelectedAutocompleteIndex( | |
(prevIndex) => (prevIndex + 1) % autocompleteSnippets.length, | |
); | |
} else if (e.key === "Escape") { | |
setAutocompleteSnippets([]); | |
} | |
} | |
}, | |
[autocompleteSnippets, selectedAutocompleteIndex, insertSnippet], | |
); | |
const handlePatientSearch = useCallback((e) => { | |
setPatientSearchTerm(e.target.value); | |
}, []); | |
const handleSnippetSearch = useCallback((e) => { | |
setSnippetSearchTerm(e.target.value); | |
}, []); | |
const highlightSearchTerm = useCallback((text, searchTerm) => { | |
if (!searchTerm) return text; | |
const parts = text.split(new RegExp(`(${searchTerm})`, "gi")); | |
return parts.map((part, index) => | |
part.toLowerCase() === searchTerm.toLowerCase() ? ( | |
<span | |
key={index} | |
style={{ backgroundColor: "#3D6869", color: "white" }} | |
> | |
{part} | |
</span> | |
) : ( | |
part | |
), | |
); | |
}, []); | |
const filteredSnippets = useMemo( | |
() => | |
snippets.filter((snippet) => | |
snippet.toLowerCase().includes(snippetSearchTerm.toLowerCase()), | |
), | |
[snippets, snippetSearchTerm], | |
); | |
const filteredPatients = useMemo( | |
() => | |
patients.filter( | |
(patient) => | |
patient.name | |
.toLowerCase() | |
.includes(patientSearchTerm.toLowerCase()) || | |
patient.wardNumber | |
.toLowerCase() | |
.includes(patientSearchTerm.toLowerCase()) || | |
patient.chartNumber | |
.toLowerCase() | |
.includes(patientSearchTerm.toLowerCase()), | |
), | |
[patients, patientSearchTerm], | |
); | |
useEffect(() => { | |
const handleResize = () => { | |
setIsMobile(window.innerWidth < 768); | |
}; | |
window.addEventListener("resize", handleResize); | |
handleResize(); | |
return () => window.removeEventListener("resize", handleResize); | |
}, []); | |
useEffect(() => { | |
if (isMobile) { | |
setLeftPaneVisible(false); | |
setRightPaneVisible(false); | |
} | |
}, [isMobile]); | |
useEffect(() => { | |
if (textareaRef.current) { | |
textareaRef.current.setSelectionRange(cursorPosition, cursorPosition); | |
} | |
}, [cursorPosition]); | |
useEffect(() => { | |
const updateEditorHeight = () => { | |
if (isMobile) { | |
const vh = window.innerHeight * 0.01; | |
document.documentElement.style.setProperty("--vh", `${vh}px`); | |
setEditorHeight(`calc(100 * var(--vh, 1vh) - 200px)`); | |
} else { | |
setEditorHeight("100%"); | |
} | |
}; | |
window.addEventListener("resize", updateEditorHeight); | |
updateEditorHeight(); | |
return () => window.removeEventListener("resize", updateEditorHeight); | |
}, [isMobile]); | |
const PatientList = React.memo(({ isDialog = false }) => { | |
const inputRef = useRef(null); | |
useEffect(() => { | |
if (isDialog && inputRef.current) { | |
inputRef.current.focus(); | |
} | |
}, [isDialog]); | |
return ( | |
<div className="space-y-4"> | |
<div className="mb-4 relative"> | |
<Input | |
ref={inputRef} | |
type="text" | |
placeholder="Search patients" | |
value={patientSearchTerm} | |
onChange={handlePatientSearch} | |
className="pl-10" | |
/> | |
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" /> | |
</div> | |
{filteredPatients.map((patient) => ( | |
<Card | |
key={patient.chartNumber} | |
className="cursor-pointer hover:shadow-md transition-shadow hover:bg-gray-100" | |
onClick={() => { | |
setSelectedPatient(patient); | |
if (isDialog) setIsPatientListOpen(false); | |
}} | |
> | |
<CardContent className="p-4"> | |
<h3 className="font-semibold text-lg"> | |
{highlightSearchTerm(patient.name, patientSearchTerm)} | |
</h3> | |
<p className="text-sm text-gray-600"> | |
Ward:{" "} | |
{highlightSearchTerm(patient.wardNumber, patientSearchTerm)} | |
</p> | |
<p className="text-sm text-gray-600"> | |
Chart:{" "} | |
{highlightSearchTerm(patient.chartNumber, patientSearchTerm)} | |
</p> | |
</CardContent> | |
</Card> | |
))} | |
</div> | |
); | |
}); | |
return ( | |
<div className="flex h-screen bg-gray-100"> | |
{leftPaneVisible && !isMobile && ( | |
<div | |
className="bg-white overflow-y-auto relative" | |
style={{ width: `${leftPaneWidth}%` }} | |
> | |
<div className="p-4"> | |
<h2 className="text-xl font-bold mb-4">Patients</h2> | |
<PatientList /> | |
</div> | |
</div> | |
)} | |
<div className="flex-grow flex flex-col"> | |
<div className="bg-white p-4 flex items-center"> | |
{isMobile ? ( | |
<Button onClick={() => setIsPatientListOpen(true)} className="mr-2"> | |
<Menu className="h-4 w-4" /> | |
</Button> | |
) : ( | |
<Button | |
onClick={() => setLeftPaneVisible(!leftPaneVisible)} | |
className="mr-2" | |
> | |
<PanelLeftClose className="h-4 w-4" /> | |
</Button> | |
)} | |
<Select onValueChange={setAuthor} value={author}> | |
<SelectTrigger className="flex-grow mx-2"> | |
<SelectValue placeholder="Select author" /> | |
</SelectTrigger> | |
<SelectContent> | |
<SelectItem value="doctor_a">Doctor A</SelectItem> | |
<SelectItem value="doctor_b">Doctor B</SelectItem> | |
<SelectItem value="doctor_c">Doctor C</SelectItem> | |
</SelectContent> | |
</Select> | |
<Select | |
onValueChange={setCategory} | |
value={category} | |
className="flex-grow mx-2" | |
> | |
<SelectTrigger> | |
<SelectValue placeholder="Select category" /> | |
</SelectTrigger> | |
<SelectContent> | |
<SelectItem value="progress_note">Progress Note</SelectItem> | |
<SelectItem value="admission_note">Admission Note</SelectItem> | |
<SelectItem value="procedure_note">Procedure Note</SelectItem> | |
</SelectContent> | |
</Select> | |
{!isMobile && ( | |
<Button | |
onClick={() => setRightPaneVisible(!rightPaneVisible)} | |
className="ml-2" | |
> | |
<PanelRightClose className="h-4 w-4" /> | |
</Button> | |
)} | |
</div> | |
{selectedPatient && ( | |
<div className="bg-white p-4 border-t border-gray-200"> | |
<h3 className="font-semibold text-lg">{selectedPatient.name}</h3> | |
<p className="text-sm text-gray-600"> | |
Ward: {selectedPatient.wardNumber} | Chart:{" "} | |
{selectedPatient.chartNumber} | |
</p> | |
</div> | |
)} | |
<div className="bg-white p-4 flex items-center"> | |
<Input | |
type="date" | |
className="mr-2" | |
value={date} | |
onChange={(e) => setDate(e.target.value)} | |
/> | |
<Input | |
type="time" | |
value={time} | |
onChange={(e) => setTime(e.target.value)} | |
/> | |
</div> | |
<div className="bg-white p-4 flex gap-2"> | |
<Button onClick={handleSave}> | |
<Save className="mr-2 h-4 w-4" /> Save | |
</Button> | |
<Button onClick={handlePublish}> | |
<Send className="mr-2 h-4 w-4" /> Publish | |
</Button> | |
</div> | |
<div className="relative flex-grow" style={{ height: editorHeight }}> | |
<textarea | |
ref={textareaRef} | |
className="w-full h-full p-4 bg-white resize-none" | |
value={editorContent} | |
onChange={handleEditorChange} | |
onKeyDown={handleKeyDown} | |
placeholder="Write your markdown here..." | |
/> | |
{autocompleteSnippets.length > 0 && ( | |
<div className="absolute bottom-full left-0 bg-white border border-gray-300 rounded-md shadow-lg"> | |
{autocompleteSnippets.map((snippet, index) => ( | |
<div | |
key={index} | |
className={`p-2 cursor-pointer ${index === selectedAutocompleteIndex ? "bg-blue-100" : ""}`} | |
onClick={() => insertSnippet(snippet)} | |
> | |
{snippet} | |
</div> | |
))} | |
</div> | |
)} | |
</div> | |
</div> | |
{rightPaneVisible && !isMobile && ( | |
<div | |
className="bg-white overflow-y-auto relative" | |
style={{ width: `${rightPaneWidth}%` }} | |
> | |
<div className="p-4"> | |
<h2 className="text-xl font-bold mb-4">Snippets</h2> | |
<div className="mb-4 relative"> | |
<Input | |
type="text" | |
placeholder="Search snippets" | |
value={snippetSearchTerm} | |
onChange={handleSnippetSearch} | |
className="pl-10" | |
/> | |
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" /> | |
</div> | |
<ul> | |
{filteredSnippets.map((snippet, index) => ( | |
<li | |
key={index} | |
className="mb-2 cursor-pointer hover:text-blue-500" | |
onClick={() => insertSnippet(snippet)} | |
> | |
{highlightSearchTerm(snippet, snippetSearchTerm)} | |
</li> | |
))} | |
</ul> | |
</div> | |
</div> | |
)} | |
<Dialog open={isPatientListOpen} onOpenChange={setIsPatientListOpen}> | |
<DialogContent> | |
<DialogHeader> | |
<DialogTitle>Select Patient</DialogTitle> | |
</DialogHeader> | |
<PatientList isDialog={true} /> | |
</DialogContent> | |
</Dialog> | |
</div> | |
); | |
}; | |
export default MarkdownEditor; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment