summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/directives/DocumentEditor.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/components/directives/DocumentEditor.tsx')
-rw-r--r--makima/frontend/src/components/directives/DocumentEditor.tsx125
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;