diff options
Diffstat (limited to 'makima/frontend/src/components')
| -rw-r--r-- | makima/frontend/src/components/directives/DocumentEditor.tsx | 282 |
1 files changed, 236 insertions, 46 deletions
diff --git a/makima/frontend/src/components/directives/DocumentEditor.tsx b/makima/frontend/src/components/directives/DocumentEditor.tsx index 661665c..0d6a391 100644 --- a/makima/frontend/src/components/directives/DocumentEditor.tsx +++ b/makima/frontend/src/components/directives/DocumentEditor.tsx @@ -69,8 +69,6 @@ import { */ const COUNTDOWN_FRESH_MS = 60_000; const COUNTDOWN_RUNNING_MS = 10_000; -/** The countdown bar only appears once we're inside this many ms from firing. */ -const BAR_VISIBLE_MS = 10_000; const SAVED_TOAST_MS = 1200; /** * Drafts are written synchronously to localStorage on every keystroke. We used @@ -722,9 +720,66 @@ function EditorContextMenu({ // Countdown bar // ============================================================================= +/** + * Compact circular countdown indicator. The ring fills (clockwise from 12 + * o'clock) as `progress` approaches 1. Uses `currentColor` so the parent's + * text tone drives both the rest of the row and the ring. + */ +function CircularTimer({ + progress, + size = 14, + stroke = 2, + className, + title, +}: { + progress: number; + size?: number; + stroke?: number; + className?: string; + title?: string; +}) { + const r = (size - stroke) / 2; + const c = 2 * Math.PI * r; + const dash = Math.max(0, Math.min(1, progress)) * c; + return ( + <svg + width={size} + height={size} + viewBox={`0 0 ${size} ${size}`} + className={className} + aria-hidden + > + {title ? <title>{title}</title> : null} + <circle + cx={size / 2} + cy={size / 2} + r={r} + fill="none" + stroke="currentColor" + strokeOpacity={0.25} + strokeWidth={stroke} + /> + <circle + cx={size / 2} + cy={size / 2} + r={r} + fill="none" + stroke="currentColor" + strokeWidth={stroke} + strokeDasharray={`${dash} ${c}`} + strokeLinecap="round" + transform={`rotate(-90 ${size / 2} ${size / 2})`} + /> + </svg> + ); +} + interface SaveCountdownBarProps { state: "idle" | "dirty" | "pending" | "saving" | "saved" | "error"; remainingMs: number; + /** Total countdown window (varies by orchestrator state). Used to compute + * the circular timer's fill ratio. */ + countdownMs: number; liveStart: boolean; orchestratorRunning: boolean; draftSavedAt: number | null; @@ -736,6 +791,7 @@ interface SaveCountdownBarProps { function SaveCountdownBar({ state, remainingMs, + countdownMs, liveStart, orchestratorRunning, draftSavedAt, @@ -747,46 +803,65 @@ function SaveCountdownBar({ // observe save state at all times — and to have a "Save now" button they // can hit without waiting for the countdown. - let label: string; - let progressPct = 0; let tone = "border-[rgba(117,170,252,0.3)] text-[#9bc3ff]"; + if (state === "saving" || state === "saved") { + tone = "border-emerald-700 text-emerald-300"; + } else if (state === "error") { + tone = "border-red-700 text-red-300"; + } else if (state === "idle") { + tone = "border-[rgba(117,170,252,0.2)] text-[#7788aa]"; + } + // Left-side primary indicator. For `pending` and `saving`, the circular + // timer is the visual; the text next to it is short and neutral. For + // every other state, we render plain text only. + let primary: React.ReactNode; if (state === "pending") { + const ringProgress = + countdownMs > 0 + ? Math.max(0, Math.min(1, 1 - remainingMs / countdownMs)) + : 0; const seconds = Math.max(0, Math.ceil(remainingMs / 1000)); - // Show ticking countdown in the last 10s, otherwise a quieter label. - if (remainingMs <= BAR_VISIBLE_MS) { + const tipBase = orchestratorRunning + ? `Replans in ${seconds}s — Esc/Undo cancels` + : `Auto-saves in ${seconds}s — Esc/Undo cancels`; + const ringLabel = orchestratorRunning ? "Replan" : "Auto-save"; + primary = ( + <span + className="flex items-center gap-1.5 text-[10px] font-mono flex-1 min-w-0 truncate" + title={tipBase} + > + <CircularTimer + progress={ringProgress} + className="shrink-0" + title={tipBase} + /> + <span className="truncate">{ringLabel}</span> + </span> + ); + } else if (state === "saving") { + primary = ( + <span className="flex items-center gap-1.5 text-[10px] font-mono flex-1 min-w-0 truncate"> + <CircularTimer progress={1} className="shrink-0 animate-pulse" /> + <span className="truncate">Saving…</span> + </span> + ); + } else { + let label: string; + if (state === "dirty") { label = orchestratorRunning - ? `Replanning in ${seconds}s — Esc/Undo cancels.` - : `Saving in ${seconds}s — Esc/Undo cancels.`; - progressPct = Math.max( - 0, - Math.min(100, (1 - remainingMs / BAR_VISIBLE_MS) * 100), - ); + ? "Unsaved changes — saving will replan the contract." + : "Unsaved changes."; + } else if (state === "saved") { + label = "Saved"; + } else if (state === "error") { + label = "Save failed — try again."; } else { - label = "Unsaved changes — auto-save soon."; - progressPct = 0; + label = "Up to date."; } - } else if (state === "dirty") { - label = orchestratorRunning - ? "Unsaved changes — saving will replan the contract." - : "Unsaved changes."; - progressPct = 0; - } 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 if (state === "error") { - label = "Save failed — try again."; - progressPct = 100; - tone = "border-red-700 text-red-300"; - } else { - label = "Up to date."; - progressPct = 0; - tone = "border-[rgba(117,170,252,0.2)] text-[#7788aa]"; + primary = ( + <span className="text-[10px] font-mono flex-1 truncate">{label}</span> + ); } // Right-side "Draft saved Xs ago" stamp — re-renders on a 1Hz ticker so @@ -801,14 +876,8 @@ function SaveCountdownBar({ className={`shrink-0 border-t border-dashed ${tone} bg-[#0a1628]`} data-makima-countdown={state} > - <div className="h-0.5 bg-[#10203a]"> - <div - className="h-full bg-[#75aafc] transition-[width] duration-100 ease-linear" - style={{ width: `${progressPct}%` }} - /> - </div> <div className="flex items-center gap-3 px-4 py-1.5"> - <span className="text-[10px] font-mono flex-1 truncate">{label}</span> + {primary} {draftLabel && ( <span @@ -877,6 +946,19 @@ export interface DocumentEditorProps { onCleanup: () => Promise<void> | void; onCreatePR: () => Promise<void> | void; onPickUpOrders: () => Promise<unknown> | unknown; + /** + * Whether the body content is currently editable. Locked (non-`draft`) + * contracts pass `false`, which puts Lexical into read-only mode, disables + * the autosave state machine, and surfaces the "Unlock to edit" affordance. + */ + editable: boolean; + /** + * Invoked when the user clicks the inline "Unlock" affordance shown above + * the body for non-`draft` contracts. The caller is expected to flip the + * contract status back to `draft` (typically via `unlockDirectiveContract`) + * and refresh the document so this component receives `editable=true`. + */ + onRequestUnlock: () => void | Promise<void>; } type SaveState = "idle" | "dirty" | "pending" | "saving" | "saved" | "error"; @@ -890,8 +972,14 @@ export function DocumentEditor({ onCleanup, onCreatePR, onPickUpOrders, + editable, + onRequestUnlock, }: DocumentEditorProps) { // ---- Lexical config ---------------------------------------------------- + // NOTE: `editable` is only read by Lexical at composer mount time. Since + // we re-key the composer by `documentId` (not status), we ALSO mirror the + // `editable` prop into the live editor via <EditorEditableSync> below on + // every change. const initialConfig = useMemo( () => ({ // Re-key the composer when the directive id changes so we get a clean @@ -904,8 +992,9 @@ export function DocumentEditor({ }, nodes: [HeadingNode, ListNode, ListItemNode, StepsBlockNode], theme: editorTheme, - editable: true, + editable, }), + // eslint-disable-next-line react-hooks/exhaustive-deps [documentId], ); @@ -1148,6 +1237,12 @@ export function DocumentEditor({ const handleGoalChange = useCallback( (goal: string) => { + // When the body is locked (contract is not in `draft`), Lexical's + // setEditable(false) blocks user input, but the OnChangePlugin can + // still fire for programmatic seeds. Ignore everything in that case + // so we don't kick the autosave state machine. + if (!editable) return; + pendingGoalRef.current = goal; // 1. Always persist work-in-progress to localStorage IMMEDIATELY so @@ -1185,9 +1280,21 @@ export function DocumentEditor({ setSaveState("dirty"); } }, - [body, documentId, liveStart, saveState, startOrExtendCountdown], + [body, documentId, editable, liveStart, saveState, startOrExtendCountdown], ); + // When the body becomes locked (contract status leaves `draft`), tear + // down any in-flight autosave: timers off, state back to idle. The + // existing per-keystroke localStorage draft remains untouched — it'll + // re-hydrate the editor if/when the user unlocks again. + useEffect(() => { + if (!editable) { + cancelTimers(); + setSaveState("idle"); + setRemainingMs(countdownMs); + } + }, [editable, countdownMs]); + // ---- Right-click context menu ----------------------------------------- const [menu, setMenu] = useState<{ x: number; y: number } | null>(null); @@ -1196,6 +1303,35 @@ export function DocumentEditor({ setMenu({ x: e.clientX, y: e.clientY }); }, []); + // ---- Unlock affordance state ----------------------------------------- + // When the contract is locked (non-`draft`), the body shows an inline + // banner with an Unlock button that calls back into the parent. We + // surface any error from the unlock API inline rather than throwing — + // mirrors the inline error pattern in ContractHeader. + const [unlockBusy, setUnlockBusy] = useState(false); + const [unlockError, setUnlockError] = useState<string | null>(null); + + // Clear any stale unlock error once the body becomes editable again. + useEffect(() => { + if (editable) { + setUnlockError(null); + setUnlockBusy(false); + } + }, [editable]); + + const handleUnlockClick = useCallback(async () => { + if (unlockBusy) return; + setUnlockBusy(true); + setUnlockError(null); + try { + await onRequestUnlock(); + } catch (e) { + setUnlockError(e instanceof Error ? e.message : "Unlock failed"); + } finally { + setUnlockBusy(false); + } + }, [onRequestUnlock, unlockBusy]); + // ---- Render ------------------------------------------------------------ return ( <div className="flex flex-col h-full overflow-hidden"> @@ -1203,12 +1339,19 @@ export function DocumentEditor({ <LexicalComposer key={documentId} initialConfig={initialConfig}> {/* Capture the editor ref via a tiny inline plugin */} <EditorRefCapture editorRef={editorRef} /> + {/* Keep Lexical's editable flag in sync with the parent prop. */} + <EditorEditableSync editable={editable} /> <SeedContentPlugin documentId={documentId} title={title} body={body} onDraftRestored={(draft) => { pendingGoalRef.current = draft; + // Don't kick the autosave state machine if the body is + // currently locked — the lock-watching useEffect would just + // tear it back down on the next render, and we don't want + // a flash of "pending" state under a locked banner. + if (!editable) return; if (liveStart) { startOrExtendCountdown(); } else { @@ -1228,10 +1371,33 @@ export function DocumentEditor({ <FloatingFormatToolbar /> <div - className="flex-1 overflow-auto" + className={`flex-1 overflow-auto ${!editable ? "bg-[rgba(120,80,0,0.04)]" : ""}`} onContextMenu={handleContextMenu} > <div className="max-w-3xl mx-auto px-8 py-10"> + {!editable && ( + <div + className="mb-4 flex items-center gap-3 border border-amber-700/60 bg-amber-900/10 rounded px-3 py-2 text-[11px] font-mono text-amber-300" + data-makima-locked-banner + > + <span className="flex-1"> + Locked — unlock to edit. + </span> + {unlockError && ( + <span className="text-red-400" title={unlockError}> + {unlockError} + </span> + )} + <button + type="button" + onClick={() => void handleUnlockClick()} + disabled={unlockBusy} + className="px-2 py-0.5 border border-amber-700/60 rounded text-amber-200 hover:text-white hover:bg-amber-900/30 disabled:opacity-50 disabled:cursor-not-allowed uppercase tracking-wide" + > + {unlockBusy ? "Unlocking…" : "Unlock"} + </button> + </div> + )} <RichTextPlugin contentEditable={ <ContentEditable @@ -1241,7 +1407,16 @@ export function DocumentEditor({ Describe the contract's goal… </div> } - 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" + // When locked, dim the text and switch the caret to the + // default arrow so the body visually reads as read-only. + // Lexical's setEditable(false) already blocks input; this + // is the cosmetic layer. + style={!editable ? { cursor: "default" } : undefined} + className={`outline-none font-mono text-[13px] leading-relaxed [&_.makima-doc-h1]:text-[24px] [&_.makima-doc-h1]:font-medium [&_.makima-doc-h1]:mb-3 [&_.makima-doc-h1]:tracking-tight [&_.makima-doc-paragraph]:my-2 [&_.makima-doc-paragraph]:text-[13px] relative ${ + editable + ? "text-[#dbe7ff] [&_.makima-doc-h1]:text-white [&_.makima-doc-paragraph]:text-[#c0d0e0]" + : "text-[#7a8aa0] [&_.makima-doc-h1]:text-[#a8b8d0] [&_.makima-doc-paragraph]:text-[#7a8aa0] select-text" + }`} /> } ErrorBoundary={LexicalErrorBoundary} @@ -1254,6 +1429,7 @@ export function DocumentEditor({ <SaveCountdownBar state={saveState} remainingMs={remainingMs} + countdownMs={countdownMs} liveStart={liveStart} orchestratorRunning={orchestratorRunning} draftSavedAt={draftSavedAt} @@ -1303,6 +1479,20 @@ function EditorRefCapture({ return null; } +/** + * Mirrors the `editable` prop onto the live Lexical editor instance. Lexical + * only reads `editable` from the initial config at mount; this plugin keeps + * the runtime state in sync as the parent's contract status flips between + * `draft` (editable) and the locked statuses (read-only). + */ +function EditorEditableSync({ editable }: { editable: boolean }) { + const [editor] = useLexicalComposerContext(); + useEffect(() => { + editor.setEditable(editable); + }, [editor, editable]); + 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 }; |
