summaryrefslogblamecommitdiff
path: root/frontend/src/components/document/DocumentEditor.tsx
blob: d50c0933bb396c8f10314954bd5e803012614472 (plain) (tree)








































































































































































































































                                                                                                          
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>
  );
}