summaryrefslogtreecommitdiff
path: root/frontend/src/components/document/DocumentEditor.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/components/document/DocumentEditor.tsx')
-rw-r--r--frontend/src/components/document/DocumentEditor.tsx233
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>
+ );
+}
+