/** * DocumentEditor — the Lexical-based document body for Document Mode. * * Layout (top to bottom): * - Read-only H1 with the directive's title. * - Editable paragraph with the directive's goal. Editing the goal triggers * a 3-second countdown bar at the bottom of the editor; if the timer * expires, we call updateGoal(). Esc / ⌘Z cancels; further typing extends. * - A custom non-editable StepsBlock decorator node showing each step. * * Right-click anywhere in the editor opens a custom context menu offering * the three directive-level actions: Clean Up, Update PR, Plan Orders. * * Live updates: * - The directive prop is updated by the parent's useDirective polling. * - StepsBlockContextProvider wraps the LexicalComposer so that the steps * block (a Lexical DecoratorNode) sees fresh data on every render * without us having to mutate the editor state. */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { $createParagraphNode, $createTextNode, $getRoot, $isElementNode, COMMAND_PRIORITY_LOW, KEY_ESCAPE_COMMAND, UNDO_COMMAND, type LexicalEditor, } from "lexical"; import { $createHeadingNode, HeadingNode } from "@lexical/rich-text"; import { ListNode, ListItemNode } from "@lexical/list"; 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 { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import type { DirectiveWithSteps } from "../../lib/api"; import { $createStepsBlockNode, $isStepsBlockNode, StepsBlockNode, StepsBlockContextProvider, } from "./StepsBlockNode"; // ============================================================================= // Constants // ============================================================================= const SAVE_COUNTDOWN_MS = 3000; const SAVED_TOAST_MS = 1200; // ============================================================================= // Editor theme — minimal, just enough so the rich-text plugin has something to // hang class names on. We rely on our own typography otherwise. // ============================================================================= const editorTheme = { paragraph: "makima-doc-paragraph", heading: { h1: "makima-doc-h1", h2: "makima-doc-h2", }, text: { bold: "font-bold", italic: "italic", underline: "underline", }, }; // ============================================================================= // Plugins // ============================================================================= /** * (Re)builds the editor's root content from the directive whenever the * directive ID changes. We keep this controlled so that switching documents * resets the editor cleanly. * * We deliberately do NOT re-seed on every directive update — only on id * change — so the user's in-flight goal edits aren't trampled by a poll that * happens mid-keystroke. */ function SeedContentPlugin({ directive, }: { directive: DirectiveWithSteps; }) { const [editor] = useLexicalComposerContext(); const seededIdRef = useRef(null); useEffect(() => { if (seededIdRef.current === directive.id) return; seededIdRef.current = directive.id; editor.update( () => { const root = $getRoot(); root.clear(); // H1: title (read-only — see ReadOnlyTitlePlugin). const heading = $createHeadingNode("h1"); heading.append($createTextNode(directive.title)); root.append(heading); // Paragraph: goal (editable). const goalPara = $createParagraphNode(); if (directive.goal.length > 0) { goalPara.append($createTextNode(directive.goal)); } root.append(goalPara); // Steps block (decorator — non-editable). root.append($createStepsBlockNode()); // Trailing empty paragraph so the cursor has somewhere to land below // the steps block. root.append($createParagraphNode()); }, { tag: "history-merge" }, ); }, [editor, directive.id, directive.title, directive.goal]); return null; } /** * Prevents edits to the H1 (title) node. The title is meant to feel like a * file name — clicking it shows a caret, but typing is no-op'd. We watch the * editor's update stream and, if the H1's text drifts from the seed value, * revert it on the next microtask so the user sees the change get rejected * rather than silently swallowed. */ function ReadOnlyTitlePlugin({ title }: { title: string }) { const [editor] = useLexicalComposerContext(); useEffect(() => { let reverting = false; return editor.registerUpdateListener(({ editorState }) => { if (reverting) return; editorState.read(() => { const root = $getRoot(); const first = root.getFirstChild(); if (!first || first.getType() !== "heading") return; const text = first.getTextContent(); if (text === title) return; reverting = true; queueMicrotask(() => { editor.update( () => { const r = $getRoot(); const h = r.getFirstChild(); if (!h || h.getType() !== "heading") { reverting = false; return; } if ($isElementNode(h) && h.getTextContent() !== title) { h.getChildren().forEach((c) => c.remove()); if (title.length > 0) { h.append($createTextNode(title)); } } reverting = false; }, { tag: "history-merge" }, ); }); }); }); }, [editor, title]); return null; } /** * Watches the goal paragraph (the second top-level child) and reports its * current text to the parent on every change. Kept separate from the * countdown bar so the bar is purely a UI concern. */ function GoalChangePlugin({ onGoalChange, }: { onGoalChange: (goal: string) => void; }) { return ( { editorState.read(() => { const root = $getRoot(); const children = root.getChildren(); // The goal lives at index 1 (after the H1 title). const goalNode = children[1]; if (!goalNode) return; onGoalChange(goalNode.getTextContent()); }); }} /> ); } /** * Wires Lexical commands into UI callbacks. We forward Esc presses (used to * cancel the countdown) and Undo (⌘/Ctrl-Z) without intercepting them. */ function CountdownKeyBridge({ onEsc, onUndo, }: { onEsc: () => void; onUndo: () => void; }) { const [editor] = useLexicalComposerContext(); useEffect(() => { const unEsc = editor.registerCommand( KEY_ESCAPE_COMMAND, () => { onEsc(); return false; // don't consume; let other handlers run }, COMMAND_PRIORITY_LOW, ); const unUndo = editor.registerCommand( UNDO_COMMAND, () => { onUndo(); return false; }, COMMAND_PRIORITY_LOW, ); return () => { unEsc(); unUndo(); }; }, [editor, onEsc, onUndo]); return null; } // ============================================================================= // Right-click context menu // ============================================================================= interface EditorContextMenuProps { x: number; y: number; onClose: () => void; onCleanup: () => void; onUpdatePR: () => void; onPlanOrders: () => void; } function EditorContextMenu({ x, y, onClose, onCleanup, onUpdatePR, onPlanOrders, }: EditorContextMenuProps) { const ref = useRef(null); useEffect(() => { const handleClick = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) onClose(); }; const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; document.addEventListener("mousedown", handleClick); document.addEventListener("keydown", handleKey); return () => { document.removeEventListener("mousedown", handleClick); document.removeEventListener("keydown", handleKey); }; }, [onClose]); // Clamp into viewport. useEffect(() => { if (!ref.current) return; const rect = ref.current.getBoundingClientRect(); const vw = window.innerWidth; const vh = window.innerHeight; if (rect.right > vw) ref.current.style.left = `${x - rect.width}px`; if (rect.bottom > vh) ref.current.style.top = `${y - rect.height}px`; }, [x, y]); const item = "w-full px-3 py-1.5 text-left text-xs font-mono text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)] flex items-center gap-2"; return (
Document
); } // ============================================================================= // Countdown bar // ============================================================================= interface SaveCountdownBarProps { state: "idle" | "pending" | "saving" | "saved" | "error"; remainingMs: number; totalMs: number; onCancel: () => void; } function SaveCountdownBar({ state, remainingMs, totalMs, onCancel, }: SaveCountdownBarProps) { if (state === "idle") return null; let label: string; let progressPct = 0; let tone = "border-[rgba(117,170,252,0.3)] text-[#9bc3ff]"; if (state === "pending") { const seconds = Math.max(0, Math.ceil(remainingMs / 1000)); label = `Saving goal in ${seconds}s — press Esc or Undo to cancel.`; progressPct = Math.max(0, Math.min(100, (1 - remainingMs / totalMs) * 100)); } else if (state === "saving") { label = "Saving…"; progressPct = 100; tone = "border-emerald-700 text-emerald-300"; } else if (state === "saved") { label = "Saved"; progressPct = 100; tone = "border-emerald-700 text-emerald-300"; } else { label = "Save failed — try again."; progressPct = 100; tone = "border-red-700 text-red-300"; } return (
{label} {state === "pending" && ( )}
); } // ============================================================================= // Main component // ============================================================================= export interface DocumentEditorProps { directive: DirectiveWithSteps; onUpdateGoal: (goal: string) => Promise | void; onCleanup: () => Promise | void; onCreatePR: () => Promise | void; onPickUpOrders: () => Promise | unknown; } type SaveState = "idle" | "pending" | "saving" | "saved" | "error"; export function DocumentEditor({ directive, onUpdateGoal, onCleanup, onCreatePR, onPickUpOrders, }: DocumentEditorProps) { // ---- Lexical config ---------------------------------------------------- const initialConfig = useMemo( () => ({ // Re-key the composer when the directive id changes so we get a clean // editor state per document. We do this via the `key` prop on // below as well. namespace: `makima-doc-${directive.id}`, onError: (err: Error) => { // eslint-disable-next-line no-console console.error("[DocumentEditor]", err); }, nodes: [HeadingNode, ListNode, ListItemNode, StepsBlockNode], theme: editorTheme, editable: true, }), [directive.id], ); // ---- Goal auto-save state machine -------------------------------------- const [saveState, setSaveState] = useState("idle"); const [remainingMs, setRemainingMs] = useState(SAVE_COUNTDOWN_MS); const pendingGoalRef = useRef(directive.goal); const timerRef = useRef(null); const tickRef = useRef(null); const deadlineRef = useRef(0); const editorRef = useRef(null); // Reset state when switching directives. useEffect(() => { pendingGoalRef.current = directive.goal; cancelTimers(); setSaveState("idle"); setRemainingMs(SAVE_COUNTDOWN_MS); // eslint-disable-next-line react-hooks/exhaustive-deps }, [directive.id]); // If the persisted goal updated externally and matches the pending goal, // settle the bar. useEffect(() => { if (saveState === "pending" && pendingGoalRef.current === directive.goal) { cancelTimers(); setSaveState("idle"); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [directive.goal]); function cancelTimers() { if (timerRef.current != null) { window.clearTimeout(timerRef.current); timerRef.current = null; } if (tickRef.current != null) { window.clearInterval(tickRef.current); tickRef.current = null; } } const fireSave = useCallback(async () => { const next = pendingGoalRef.current; cancelTimers(); setSaveState("saving"); try { await onUpdateGoal(next); setSaveState("saved"); window.setTimeout(() => { // Only fade if no new edit has reopened a pending state in the meantime. setSaveState((s) => (s === "saved" ? "idle" : s)); }, SAVED_TOAST_MS); } catch (e) { // eslint-disable-next-line no-console console.error("Failed to save goal", e); setSaveState("error"); window.setTimeout(() => { setSaveState((s) => (s === "error" ? "idle" : s)); }, 2500); } }, [onUpdateGoal]); const startOrExtendCountdown = useCallback(() => { cancelTimers(); deadlineRef.current = Date.now() + SAVE_COUNTDOWN_MS; setSaveState("pending"); setRemainingMs(SAVE_COUNTDOWN_MS); tickRef.current = window.setInterval(() => { const remaining = Math.max(0, deadlineRef.current - Date.now()); setRemainingMs(remaining); if (remaining <= 0 && tickRef.current != null) { window.clearInterval(tickRef.current); tickRef.current = null; } }, 100); timerRef.current = window.setTimeout(() => { void fireSave(); }, SAVE_COUNTDOWN_MS); }, [fireSave]); const cancelCountdown = useCallback(() => { if (saveState !== "pending") return; cancelTimers(); pendingGoalRef.current = directive.goal; // reset pending edit setSaveState("idle"); setRemainingMs(SAVE_COUNTDOWN_MS); // Also revert the editor's goal paragraph back to the persisted value, so // the user sees the rollback. const editor = editorRef.current; if (editor) { editor.update( () => { const root = $getRoot(); const goalNode = root.getChildren()[1]; if (!goalNode || !$isElementNode(goalNode)) return; goalNode.getChildren().forEach((c) => c.remove()); if (directive.goal.length > 0) { goalNode.append($createTextNode(directive.goal)); } }, { tag: "history-merge" }, ); } }, [directive.goal, saveState]); // Cleanup on unmount. useEffect(() => { return cancelTimers; }, []); const handleGoalChange = useCallback( (goal: string) => { pendingGoalRef.current = goal; if (goal === directive.goal) { // Edit reverted — cancel the countdown (if any). if (saveState === "pending") { cancelTimers(); setSaveState("idle"); } return; } startOrExtendCountdown(); }, [directive.goal, saveState, startOrExtendCountdown], ); // ---- Right-click context menu ----------------------------------------- const [menu, setMenu] = useState<{ x: number; y: number } | null>(null); const handleContextMenu = useCallback((e: React.MouseEvent) => { e.preventDefault(); setMenu({ x: e.clientX, y: e.clientY }); }, []); // ---- Render ------------------------------------------------------------ return (
{/* Capture the editor ref via a tiny inline plugin */}
Describe the directive's goal…
} className="outline-none font-mono text-[13px] leading-relaxed text-[#dbe7ff] [&_.makima-doc-h1]:text-[24px] [&_.makima-doc-h1]:font-medium [&_.makima-doc-h1]:text-white [&_.makima-doc-h1]:mb-3 [&_.makima-doc-h1]:tracking-tight [&_.makima-doc-paragraph]:my-2 [&_.makima-doc-paragraph]:text-[13px] [&_.makima-doc-paragraph]:text-[#c0d0e0] relative" /> } ErrorBoundary={LexicalErrorBoundary} />
{menu && ( setMenu(null)} onCleanup={() => { void onCleanup(); }} onUpdatePR={() => { void onCreatePR(); }} onPlanOrders={() => { void onPickUpOrders(); }} /> )}
); } /** * Tiny plugin that stashes the LexicalEditor instance on a ref so the parent * component can issue updates from outside (e.g. to revert the goal on cancel). */ function EditorRefCapture({ editorRef, }: { editorRef: React.MutableRefObject; }) { const [editor] = useLexicalComposerContext(); useEffect(() => { editorRef.current = editor; return () => { if (editorRef.current === editor) { editorRef.current = null; } }; }, [editor, editorRef]); return null; } // Re-export the steps-block helpers so consumers can include the node class // in their own initial configs if needed. export { $createStepsBlockNode, $isStepsBlockNode, StepsBlockNode };