diff options
Diffstat (limited to 'frontend/src/components/document/DocumentEditor.tsx')
| -rw-r--r-- | frontend/src/components/document/DocumentEditor.tsx | 233 |
1 files changed, 233 insertions, 0 deletions
diff --git a/frontend/src/components/document/DocumentEditor.tsx b/frontend/src/components/document/DocumentEditor.tsx new file mode 100644 index 0000000..d50c093 --- /dev/null +++ b/frontend/src/components/document/DocumentEditor.tsx @@ -0,0 +1,233 @@ +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 editorTheme from './EditorTheme'; +import AutoSavePlugin from './AutoSavePlugin'; +import ContextMenu, { type ContextMenuAction } from './ContextMenu'; +import './DocumentEditor.css'; +import './nodes/StepsDiagram.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<LexicalEditor | null>(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], + 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 the steps diagram node when extracting text + if ($isStepsDiagramNode(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 ( + <div className="document-editor-container" onContextMenu={handleContextMenu}> + <LexicalComposer initialConfig={initialConfig}> + <div className="doc-editor-input"> + <RichTextPlugin + contentEditable={ + <ContentEditable className="doc-editor-content-editable doc-editor-root" /> + } + placeholder={ + <div className="doc-editor-placeholder">Start writing...</div> + } + ErrorBoundary={LexicalErrorBoundary} + /> + </div> + <HistoryPlugin /> + <OnChangePlugin onChange={handleChange} /> + {!readOnly && ( + <AutoSavePlugin + onAutoSave={handleAutoSave} + getContent={getContent} + enabled={!readOnly} + /> + )} + </LexicalComposer> + + {ctxMenu && ( + <ContextMenu + x={ctxMenu.x} + y={ctxMenu.y} + actions={ctxActions} + dividerAfter={[2]} + onClose={closeCtxMenu} + /> + )} + </div> + ); +} + |
