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