diff options
Diffstat (limited to 'makima/frontend/src/components/directives/DocumentEditor.tsx')
| -rw-r--r-- | makima/frontend/src/components/directives/DocumentEditor.tsx | 125 |
1 files changed, 101 insertions, 24 deletions
diff --git a/makima/frontend/src/components/directives/DocumentEditor.tsx b/makima/frontend/src/components/directives/DocumentEditor.tsx index 08e4d73..981cab1 100644 --- a/makima/frontend/src/components/directives/DocumentEditor.tsx +++ b/makima/frontend/src/components/directives/DocumentEditor.tsx @@ -176,6 +176,32 @@ function appendInlineMarkdownTo(parent: ElementNode, markdown: string): void { } /** + * Walk the editor root and serialise every non-decorator paragraph between + * the H1 title and the end of the document into the goal markdown. This + * captures user typing wherever it lands — the goal paragraph proper, but + * also any extra paragraphs the user inserted above or below the StepsBlock. + * Previously we read only `children[1]` and lost anything outside it. + */ +function serializeGoalFromRoot(root: ElementNode): string { + const parts: string[] = []; + const children = root.getChildren(); + for (let i = 0; i < children.length; i++) { + const node = children[i]; + // Skip the H1 title (always at index 0 by construction). + if (i === 0 && node.getType() === "heading") continue; + // Skip the StepsBlock decorator and any other non-element nodes. + if ($isStepsBlockNode(node)) continue; + if (!$isElementNode(node)) continue; + parts.push(serializeInlineMarkdown(node)); + } + // Trim trailing empties; collapse runs of >2 blank lines. + return parts + .join("\n\n") + .replace(/\n{3,}/g, "\n\n") + .replace(/^\s+|\s+$/g, ""); +} + +/** * Walk the goal paragraph's children and emit inline markdown. Only TextNodes * are emitted — anything else falls back to its plain text content. We always * wrap in the most specific marker pair available so the round-trip is stable. @@ -296,20 +322,28 @@ function SeedContentPlugin({ heading.append($createTextNode(directive.title)); root.append(heading); - // Paragraph: goal (editable). The persisted goal may contain inline - // markdown — parse it into formatted TextNodes so users see their - // bold/italic/code formatting on load. - const goalPara = $createParagraphNode(); - if (initialGoal.length > 0) { - appendInlineMarkdownTo(goalPara, initialGoal); + // Goal body. The persisted goal may contain multiple paragraphs + // (separated by blank lines) and inline markdown — split by blank + // lines so each block becomes its own ParagraphNode, and parse the + // inline formatting per paragraph. Always emit at least one + // paragraph so users have a place to type even when the goal is + // empty. + const blocks = + initialGoal.length > 0 ? initialGoal.split(/\n{2,}/) : [""]; + for (const block of blocks) { + const p = $createParagraphNode(); + if (block.length > 0) { + appendInlineMarkdownTo(p, block); + } + root.append(p); } - 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. + // the steps block. The trailing area is also captured as goal + // content by serializeGoalFromRoot, so any typing here is preserved. root.append($createParagraphNode()); }, { tag: "history-merge" }, @@ -388,15 +422,11 @@ function GoalChangePlugin({ ignoreSelectionChange onChange={(editorState) => { 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 || !$isElementNode(goalNode)) return; - // Serialize the goal paragraph as INLINE MARKDOWN so bold/italic/code - // formatting round-trips through `directive.goal` (a plain TEXT - // column on the backend). - onGoalChange(serializeInlineMarkdown(goalNode)); + // Walk the WHOLE root (minus title H1 and StepsBlock decorator) so + // typing anywhere in the document body is captured. Previously we + // only read children[1] and silently discarded edits placed in the + // trailing area below the StepsBlock. + onGoalChange(serializeGoalFromRoot($getRoot())); }); }} /> @@ -873,6 +903,12 @@ export function DocumentEditor({ const tickRef = useRef<number | null>(null); const deadlineRef = useRef<number>(0); const editorRef = useRef<LexicalEditor | null>(null); + // Tracks the most recent value the backend was asked to save. Once a poll + // confirms `directive.goal === lastSavedValueRef.current` AND + // `pendingGoalRef.current` still matches (i.e. user hasn't typed more), + // the draft is safe to drop from localStorage. Until then we keep the + // draft so an interrupted save doesn't lose user content. + const lastSavedValueRef = useRef<string | null>(null); // Timestamp of the most recent localStorage draft write — drives the // "Draft saved Xs ago" indicator so users can SEE that drafts are working. const [draftSavedAt, setDraftSavedAt] = useState<number | null>(null); @@ -922,17 +958,38 @@ export function DocumentEditor({ }, [directive.goal]); const fireSave = useCallback(async () => { - const next = pendingGoalRef.current; + // Read DIRECTLY from the editor so we don't trust pendingGoalRef alone. + // If the OnChangePlugin missed an event for any reason, this still + // captures the user's current document state. + let next = pendingGoalRef.current; + const editor = editorRef.current; + if (editor) { + editor.getEditorState().read(() => { + next = serializeGoalFromRoot($getRoot()); + }); + } + pendingGoalRef.current = next; + + // DEFENSE IN DEPTH: write the draft to localStorage BEFORE talking to + // the backend. If the save errors, the page closes mid-flight, or the + // network drops, the user's content survives in the draft. + try { + window.localStorage.setItem(DRAFT_KEY(directive.id), next); + setDraftSavedAt(Date.now()); + } catch (err) { + // eslint-disable-next-line no-console + console.warn("[makima] pre-save draft flush failed", err); + } + cancelTimers(); setSaveState("saving"); try { await onUpdateGoal(next); + lastSavedValueRef.current = next; setSaveState("saved"); - try { - window.localStorage.removeItem(DRAFT_KEY(directive.id)); - } catch { - /* ignore */ - } + // NOTE: we deliberately do NOT clear localStorage here. The roundtrip + // effect below clears it once the polled directive.goal confirms our + // save persisted AND the user hasn't kept typing past it. window.setTimeout(() => { // Only fade if no new edit has reopened a pending state in the meantime. setSaveState((s) => (s === "saved" ? "idle" : s)); @@ -943,10 +1000,30 @@ export function DocumentEditor({ setSaveState("error"); window.setTimeout(() => { setSaveState((s) => (s === "error" ? "idle" : s)); - }, 2500); + }, 4000); } }, [onUpdateGoal, directive.id]); + // Roundtrip-confirmed draft cleanup. Only drops the localStorage draft + // when the polled directive.goal matches what we just saved AND the + // user hasn't typed anything new in the meantime. Keeps the draft alive + // through every "we hit save but the page reloads before the poll lands" + // edge case. + useEffect(() => { + if ( + lastSavedValueRef.current !== null && + directive.goal === lastSavedValueRef.current && + pendingGoalRef.current === lastSavedValueRef.current + ) { + try { + window.localStorage.removeItem(DRAFT_KEY(directive.id)); + } catch { + /* ignore */ + } + lastSavedValueRef.current = null; + } + }, [directive.goal, directive.id]); + const startOrExtendCountdown = useCallback(() => { cancelTimers(); deadlineRef.current = Date.now() + countdownMs; |
