import { useCallback, useRef, useState } 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 { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'; import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'; import { HeadingNode } from '@lexical/rich-text'; import { ListNode, ListItemNode } from '@lexical/list'; import { LinkNode } from '@lexical/link'; import { $getRoot, $createParagraphNode, $createTextNode, type EditorState, type LexicalEditor, } from 'lexical'; import { $createHeadingNode } from '@lexical/rich-text'; import { StepsDiagramNode, $isStepsDiagramNode, $createStepsDiagramNode } from './nodes/StepsDiagramNode'; import { ContractBlockNode, $isContractBlockNode } from './nodes/ContractBlockNode'; import editorTheme from './EditorTheme'; import AutoSavePlugin from './AutoSavePlugin'; import ContextMenu, { type ContextMenuAction } from './ContextMenu'; import './DocumentEditor.css'; import './nodes/StepsDiagram.css'; import './nodes/ContractBlock.css'; interface DocumentEditorProps { directiveId: string; title: string; goal: string; status: string; prBranch?: string | null; onGoalChange?: (newGoal: string) => void; onTitleChange?: (newTitle: string) => void; onCleanup?: () => void; onCreatePr?: () => void; onPlanOrders?: () => void; onTogglePause?: () => void; readOnly?: boolean; } function buildInitialEditorState(directiveId: string, title: string, goal: string) { return () => { const root = $getRoot(); // Title as H1 const heading = $createHeadingNode('h1'); heading.append($createTextNode(title)); root.append(heading); // Goal as paragraph(s), split by newlines const lines = goal.split('\n'); for (const line of lines) { const paragraph = $createParagraphNode(); if (line.trim()) { paragraph.append($createTextNode(line)); } root.append(paragraph); } // Insert steps diagram node after the goal content const stepsNode = $createStepsDiagramNode(directiveId); root.append(stepsNode); // Add a trailing paragraph so the user can type below the diagram const trailingParagraph = $createParagraphNode(); root.append(trailingParagraph); }; } function onError(error: Error) { console.error('[DocumentEditor] Lexical error:', error); } export default function DocumentEditor({ directiveId, title, goal, status, prBranch, onGoalChange, onTitleChange, onCleanup, onCreatePr, onPlanOrders, onTogglePause, readOnly = false, }: DocumentEditorProps) { const editorRef = useRef(null); const latestGoalRef = useRef(goal); const latestTitleRef = useRef(title); // Context menu state const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null); const initialConfig = { namespace: `DocumentEditor-${directiveId}`, theme: editorTheme, editorState: buildInitialEditorState(directiveId, title, goal), nodes: [HeadingNode, ListNode, ListItemNode, LinkNode, StepsDiagramNode, ContractBlockNode], onError, editable: !readOnly, }; const handleChange = useCallback( (_editorState: EditorState, editor: LexicalEditor) => { editorRef.current = editor; editor.getEditorState().read(() => { const root = $getRoot(); const children = root.getChildren(); let newTitle = ''; const goalLines: string[] = []; for (let i = 0; i < children.length; i++) { const child = children[i]; // Skip decorator nodes (steps diagram, contract blocks) when extracting text if ($isStepsDiagramNode(child)) continue; if ($isContractBlockNode(child)) continue; const text = child.getTextContent(); if (i === 0 && child.getType() === 'heading') { newTitle = text; } else { goalLines.push(text); } } const newGoal = goalLines.join('\n'); if (newTitle !== latestTitleRef.current) { latestTitleRef.current = newTitle; onTitleChange?.(newTitle); } if (newGoal !== latestGoalRef.current) { latestGoalRef.current = newGoal; onGoalChange?.(newGoal); } }); }, [onGoalChange, onTitleChange] ); const getContent = useCallback(() => { return latestGoalRef.current; }, []); const handleAutoSave = useCallback( (content: string) => { onGoalChange?.(content); }, [onGoalChange] ); // Context menu handler const handleContextMenu = useCallback( (e: React.MouseEvent) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY }); }, [] ); const closeCtxMenu = useCallback(() => setCtxMenu(null), []); const isPaused = status === 'paused'; const isIdle = status === 'idle'; const ctxActions: ContextMenuAction[] = [ { label: 'Clean Up', icon: '\uD83E\uDDF9', // broom disabled: !isIdle, onClick: () => onCleanup?.(), }, { label: 'Update PR', icon: '\uD83D\uDD00', // shuffle disabled: !prBranch, onClick: () => onCreatePr?.(), }, { label: 'Plan Orders', icon: '\uD83D\uDCCB', // clipboard onClick: () => onPlanOrders?.(), }, { label: isPaused ? 'Resume Directive' : 'Pause Directive', icon: isPaused ? '\u25B6' : '\u23F8', onClick: () => onTogglePause?.(), }, ]; return (
} placeholder={
Start writing...
} ErrorBoundary={LexicalErrorBoundary} />
{!readOnly && ( )}
{ctxMenu && ( )}
); }