-
Notifications
You must be signed in to change notification settings - Fork 0
feat: implement one-on-one meeting notes with Lexical rich text editor #21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import { | ||
| Dialog, | ||
| DialogContent, | ||
| DialogDescription, | ||
| DialogFooter, | ||
| DialogHeader, | ||
| DialogTitle, | ||
| } from "@/components/ui/dialog"; | ||
| import { Button } from "@/components/ui/button"; | ||
|
|
||
| interface DeleteMeetingModalProps { | ||
| isOpen: boolean; | ||
| onClose: () => void; | ||
| onConfirmDelete: () => void; | ||
| isPending?: boolean; | ||
| } | ||
|
|
||
| export const DeleteMeetingModal = ({ | ||
| isOpen, | ||
| onClose, | ||
| onConfirmDelete, | ||
| isPending = false, | ||
| }: DeleteMeetingModalProps) => { | ||
| return ( | ||
| <Dialog open={isOpen} onOpenChange={onClose}> | ||
| <DialogContent className="sm:max-w-md"> | ||
| <DialogHeader> | ||
| <DialogTitle>Delete Meeting</DialogTitle> | ||
| <DialogDescription> | ||
| Are you sure you want to delete this meeting? This action cannot be | ||
| undone. | ||
| </DialogDescription> | ||
| </DialogHeader> | ||
| <DialogFooter> | ||
| <Button variant="outline" onClick={onClose} disabled={isPending}> | ||
| Cancel | ||
| </Button> | ||
| <Button | ||
| variant="destructive" | ||
| onClick={onConfirmDelete} | ||
| disabled={isPending} | ||
| > | ||
| {isPending ? "Deleting..." : "Delete Meeting"} | ||
| </Button> | ||
| </DialogFooter> | ||
| </DialogContent> | ||
| </Dialog> | ||
| ); | ||
| }; |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,195 @@ | ||||||||||||||||||
| import { useEffect } from "react"; | ||||||||||||||||||
| import { LexicalComposer } from "@lexical/react/LexicalComposer"; | ||||||||||||||||||
| import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; | ||||||||||||||||||
| import { ContentEditable } from "@lexical/react/LexicalContentEditable"; | ||||||||||||||||||
| import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; | ||||||||||||||||||
| import { ListPlugin } from "@lexical/react/LexicalListPlugin"; | ||||||||||||||||||
| import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; | ||||||||||||||||||
| import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; | ||||||||||||||||||
| import { ListItemNode, ListNode } from "@lexical/list"; | ||||||||||||||||||
| import { HeadingNode } from "@lexical/rich-text"; | ||||||||||||||||||
| import { | ||||||||||||||||||
| $getSelection, | ||||||||||||||||||
| $isRangeSelection, | ||||||||||||||||||
| FORMAT_TEXT_COMMAND, | ||||||||||||||||||
| type EditorState, | ||||||||||||||||||
| type LexicalEditor as LexicalEditorType, | ||||||||||||||||||
| } from "lexical"; | ||||||||||||||||||
| import { | ||||||||||||||||||
| INSERT_ORDERED_LIST_COMMAND, | ||||||||||||||||||
| INSERT_UNORDERED_LIST_COMMAND, | ||||||||||||||||||
| } from "@lexical/list"; | ||||||||||||||||||
| import { useState, useCallback } from "react"; | ||||||||||||||||||
| import { Button } from "@/components/ui/button"; | ||||||||||||||||||
|
|
||||||||||||||||||
| const theme = { | ||||||||||||||||||
| paragraph: "mb-1", | ||||||||||||||||||
| text: { | ||||||||||||||||||
| bold: "font-bold", | ||||||||||||||||||
| italic: "italic", | ||||||||||||||||||
| underline: "underline", | ||||||||||||||||||
| }, | ||||||||||||||||||
| list: { | ||||||||||||||||||
| nested: { | ||||||||||||||||||
| listitem: "list-none", | ||||||||||||||||||
| }, | ||||||||||||||||||
| ol: "list-decimal ml-4", | ||||||||||||||||||
| ul: "list-disc ml-4", | ||||||||||||||||||
| listitem: "ml-2", | ||||||||||||||||||
| }, | ||||||||||||||||||
| heading: { | ||||||||||||||||||
| h1: "text-2xl font-bold", | ||||||||||||||||||
| h2: "text-xl font-bold", | ||||||||||||||||||
| h3: "text-lg font-bold", | ||||||||||||||||||
| }, | ||||||||||||||||||
| }; | ||||||||||||||||||
|
|
||||||||||||||||||
| function ToolbarPlugin() { | ||||||||||||||||||
| const [editor] = useLexicalComposerContext(); | ||||||||||||||||||
| const [isBold, setIsBold] = useState(false); | ||||||||||||||||||
| const [isItalic, setIsItalic] = useState(false); | ||||||||||||||||||
| const [isUnderline, setIsUnderline] = useState(false); | ||||||||||||||||||
|
|
||||||||||||||||||
| const updateToolbar = useCallback(() => { | ||||||||||||||||||
| const selection = $getSelection(); | ||||||||||||||||||
| if ($isRangeSelection(selection)) { | ||||||||||||||||||
| setIsBold(selection.hasFormat("bold")); | ||||||||||||||||||
| setIsItalic(selection.hasFormat("italic")); | ||||||||||||||||||
| setIsUnderline(selection.hasFormat("underline")); | ||||||||||||||||||
| } | ||||||||||||||||||
| }, []); | ||||||||||||||||||
|
|
||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||
| return editor.registerUpdateListener(({ editorState }) => { | ||||||||||||||||||
| editorState.read(() => { | ||||||||||||||||||
| updateToolbar(); | ||||||||||||||||||
| }); | ||||||||||||||||||
| }); | ||||||||||||||||||
| }, [editor, updateToolbar]); | ||||||||||||||||||
|
|
||||||||||||||||||
| return ( | ||||||||||||||||||
| <div className="flex gap-1 border-b p-1"> | ||||||||||||||||||
| <Button | ||||||||||||||||||
| type="button" | ||||||||||||||||||
| variant={isBold ? "default" : "ghost"} | ||||||||||||||||||
| size="sm" | ||||||||||||||||||
| className="h-8 w-8 p-0 font-bold" | ||||||||||||||||||
| onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold")} | ||||||||||||||||||
| > | ||||||||||||||||||
| B | ||||||||||||||||||
| </Button> | ||||||||||||||||||
| <Button | ||||||||||||||||||
| type="button" | ||||||||||||||||||
| variant={isItalic ? "default" : "ghost"} | ||||||||||||||||||
| size="sm" | ||||||||||||||||||
| className="h-8 w-8 p-0 italic" | ||||||||||||||||||
| onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic")} | ||||||||||||||||||
| > | ||||||||||||||||||
| I | ||||||||||||||||||
| </Button> | ||||||||||||||||||
| <Button | ||||||||||||||||||
| type="button" | ||||||||||||||||||
| variant={isUnderline ? "default" : "ghost"} | ||||||||||||||||||
| size="sm" | ||||||||||||||||||
| className="h-8 w-8 p-0 underline" | ||||||||||||||||||
| onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline")} | ||||||||||||||||||
| > | ||||||||||||||||||
| U | ||||||||||||||||||
| </Button> | ||||||||||||||||||
| <div className="mx-1 w-px bg-border" /> | ||||||||||||||||||
| <Button | ||||||||||||||||||
| type="button" | ||||||||||||||||||
| variant="ghost" | ||||||||||||||||||
| size="sm" | ||||||||||||||||||
| className="h-8 px-2 text-xs" | ||||||||||||||||||
| onClick={() => | ||||||||||||||||||
| editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined) | ||||||||||||||||||
| } | ||||||||||||||||||
| > | ||||||||||||||||||
| • List | ||||||||||||||||||
| </Button> | ||||||||||||||||||
| <Button | ||||||||||||||||||
| type="button" | ||||||||||||||||||
| variant="ghost" | ||||||||||||||||||
| size="sm" | ||||||||||||||||||
| className="h-8 px-2 text-xs" | ||||||||||||||||||
| onClick={() => | ||||||||||||||||||
| editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined) | ||||||||||||||||||
| } | ||||||||||||||||||
| > | ||||||||||||||||||
| 1. List | ||||||||||||||||||
| </Button> | ||||||||||||||||||
| </div> | ||||||||||||||||||
| ); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| function LoadInitialStatePlugin({ | ||||||||||||||||||
| initialState, | ||||||||||||||||||
| }: { | ||||||||||||||||||
| initialState?: string; | ||||||||||||||||||
| }) { | ||||||||||||||||||
| const [editor] = useLexicalComposerContext(); | ||||||||||||||||||
| const [loaded, setLoaded] = useState(false); | ||||||||||||||||||
|
|
||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||
| if (initialState && !loaded) { | ||||||||||||||||||
| try { | ||||||||||||||||||
| const parsed = JSON.parse(initialState); | ||||||||||||||||||
| const editorState = editor.parseEditorState(parsed); | ||||||||||||||||||
| editor.setEditorState(editorState); | ||||||||||||||||||
| setLoaded(true); | ||||||||||||||||||
| } catch { | ||||||||||||||||||
| // Invalid state, ignore | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| }, [editor, initialState, loaded]); | ||||||||||||||||||
|
|
||||||||||||||||||
| return null; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| interface LexicalEditorProps { | ||||||||||||||||||
| onChange: (serializedState: string) => void; | ||||||||||||||||||
| initialState?: string; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| export default function LexicalEditor({ | ||||||||||||||||||
| onChange, | ||||||||||||||||||
| initialState, | ||||||||||||||||||
| }: LexicalEditorProps) { | ||||||||||||||||||
| const initialConfig = { | ||||||||||||||||||
| namespace: "MeetingNotesEditor", | ||||||||||||||||||
| theme, | ||||||||||||||||||
| nodes: [ListNode, ListItemNode, HeadingNode], | ||||||||||||||||||
| onError: (error: Error) => { | ||||||||||||||||||
| console.error(error); | ||||||||||||||||||
| }, | ||||||||||||||||||
| }; | ||||||||||||||||||
|
|
||||||||||||||||||
| const handleChange = ( | ||||||||||||||||||
| editorState: EditorState, | ||||||||||||||||||
| _editor: LexicalEditorType, | ||||||||||||||||||
| ) => { | ||||||||||||||||||
| const json = editorState.toJSON(); | ||||||||||||||||||
| onChange(JSON.stringify(json)); | ||||||||||||||||||
| }; | ||||||||||||||||||
|
|
||||||||||||||||||
| return ( | ||||||||||||||||||
| <LexicalComposer initialConfig={initialConfig}> | ||||||||||||||||||
| <div className="rounded-md border"> | ||||||||||||||||||
| <ToolbarPlugin /> | ||||||||||||||||||
| <div className="relative min-h-[200px]"> | ||||||||||||||||||
| <RichTextPlugin | ||||||||||||||||||
| contentEditable={ | ||||||||||||||||||
| <ContentEditable className="min-h-[200px] p-3 outline-none" /> | ||||||||||||||||||
| } | ||||||||||||||||||
| ErrorBoundary={({ children }) => <>{children}</>} | ||||||||||||||||||
| /> | ||||||||||||||||||
|
Comment on lines
+185
to
+186
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: fd "LexicalEditor.tsx" --type fRepository: fvishw/PMS Length of output: 102 🏁 Script executed: sed -n '175,195p' client/src/components/meetings/LexicalEditor.tsx | cat -nRepository: fvishw/PMS Length of output: 824 🏁 Script executed: head -50 client/src/components/meetings/LexicalEditor.tsx | cat -nRepository: fvishw/PMS Length of output: 1935 🏁 Script executed: rg "LexicalErrorBoundary" --type tsx --type ts -A 2 -B 2Repository: fvishw/PMS Length of output: 81 🏁 Script executed: rg "RichTextPlugin" --type tsx --type ts -A 3 -B 1Repository: fvishw/PMS Length of output: 81 🏁 Script executed: rg "from [\"']@lexical" --type tsx --type ts | head -20Repository: fvishw/PMS Length of output: 81 🌐 Web query:
💡 Result: In Recommended setupimport {LexicalComposer} from "@lexical/react/LexicalComposer";
import {RichTextPlugin} from "@lexical/react/LexicalRichTextPlugin";
import {ContentEditable} from "@lexical/react/LexicalContentEditable";
import {LexicalErrorBoundary} from "@lexical/react/LexicalErrorBoundary";
const initialConfig = {
namespace: "MyEditor",
onError: (error: unknown) => {
console.error(error);
throw error; // optional: surface the error to your app/runtime
},
};
export function Editor() {
return (
<LexicalComposer initialConfig={initialConfig}>
<RichTextPlugin
contentEditable={<ContentEditable />}
placeholder={null}
ErrorBoundary={LexicalErrorBoundary}
/>
</LexicalComposer>
);
}This combines:
If you need a custom boundaryYour Sources: Citations:
Use A fragment wrapper passes through children without catching errors. RichTextPlugin's Suggested fix+import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
@@
- ErrorBoundary={({ children }) => <>{children}</>}
+ ErrorBoundary={LexicalErrorBoundary}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
| </div> | ||||||||||||||||||
| <HistoryPlugin /> | ||||||||||||||||||
| <ListPlugin /> | ||||||||||||||||||
| <OnChangePlugin onChange={handleChange} /> | ||||||||||||||||||
| <LoadInitialStatePlugin initialState={initialState} /> | ||||||||||||||||||
| </div> | ||||||||||||||||||
| </LexicalComposer> | ||||||||||||||||||
| ); | ||||||||||||||||||
| } | ||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: fvishw/PMS
Length of output: 104
🏁 Script executed:
cat -n ./client/src/components/meetings/LexicalEditor.tsx | head -200Repository: fvishw/PMS
Length of output: 6649
🏁 Script executed:
Repository: fvishw/PMS
Length of output: 88
🏁 Script executed:
Repository: fvishw/PMS
Length of output: 2546
🏁 Script executed:
Repository: fvishw/PMS
Length of output: 3285
🏁 Script executed:
Repository: fvishw/PMS
Length of output: 489
🏁 Script executed:
Repository: fvishw/PMS
Length of output: 1439
🏁 Script executed:
Repository: fvishw/PMS
Length of output: 930
🏁 Script executed:
Repository: fvishw/PMS
Length of output: 150
🌐 Web query:
Lexical editor parseEditorState API documentation💡 Result:
parseEditorStateis a LexicalEditor API used to deserialize a serialized editor state back into anEditorState, typically after you previously serialized witheditor.getEditorState().toJSON()andJSON.stringify(...). (npmjs.com)Signature (TypeScript) (as documented in generated Lexical API docs):
editor.parseEditorState(maybeStringifiedEditorState: string): EditorState(tessl.io)Typical usage
(npmjs.com)
Related docs/context
toJSON()) and restorable viaeditor.parseEditorState(...). (deepwiki.com)Citations:
Stale content when
initialStateprop changes becauseloadedflag only checks once.The
loadedboolean prevents the effect from re-running ifinitialStatechanges after the initial load. If the same editor instance is reused for different meeting notes (e.g., switching meetings without closing the dialog), the editor will display the old notes.Additionally,
JSON.parseis unnecessary sinceparseEditorStateexpects a stringified JSON state, not a parsed object.Suggested fix
🤖 Prompt for AI Agents