From 4b1d608b839769052634b4facc345b891d468926 Mon Sep 17 00:00:00 2001 From: soryu Date: Wed, 29 Apr 2026 01:10:11 +0100 Subject: feat: document-mode directive UI proof of concept (Lexical) (#101) * WIP: heartbeat checkpoint * feat: soryu-co/soryu - makima: Backend: feature flag + goal-edit interrupt messaging * WIP: heartbeat checkpoint * WIP: heartbeat checkpoint * feat: soryu-co/soryu - makima: Frontend: Lexical document editor with step blocks, context menu, countdown --- .../src/components/directives/DocumentEditor.tsx | 664 +++++++++++++++++++++ 1 file changed, 664 insertions(+) create mode 100644 makima/frontend/src/components/directives/DocumentEditor.tsx (limited to 'makima/frontend/src/components/directives/DocumentEditor.tsx') diff --git a/makima/frontend/src/components/directives/DocumentEditor.tsx b/makima/frontend/src/components/directives/DocumentEditor.tsx new file mode 100644 index 0000000..40fccf1 --- /dev/null +++ b/makima/frontend/src/components/directives/DocumentEditor.tsx @@ -0,0 +1,664 @@ +/** + * 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 }; -- cgit v1.2.3