diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 52153a1e..160f144f 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -21,6 +21,7 @@ import { type MemoDetail, type MemoRevision, type MemoSummary, + type MemoUpdateInput, type Notebook, type NotebookCreateInput, type Resource, @@ -1454,8 +1455,20 @@ app.patch("/api/v1/memos/:id", zValidator("json", MemoUpdateSchema), async (c) = return denied; } - const id = c.req.param("id"); - const input = c.req.valid("json"); + return updateMemoFromInput(c, c.req.param("id"), c.req.valid("json")); +}); + +app.post("/api/v1/memos/:id/save", zValidator("json", MemoUpdateSchema), async (c) => { + const denied = requireScopes(c, "write:memos"); + + if (denied) { + return denied; + } + + return updateMemoFromInput(c, c.req.param("id"), c.req.valid("json")); +}); + +const updateMemoFromInput = async (c: AppContext, id: string, input: MemoUpdateInput) => { const actor = getAuditActor(c); const actorLabel = getActorLabel(c); const current = await getMemoDetailRow(c.env.DB, id); @@ -1553,7 +1566,7 @@ app.patch("/api/v1/memos/:id", zValidator("json", MemoUpdateSchema), async (c) = ]); return c.json({ memo: await getMemoDetail(c.env.DB, id) }); -}); +}; app.delete("/api/v1/memos/:id", async (c) => { const denied = requireScopes(c, "write:memos"); diff --git a/apps/web/src/components/MobileStandaloneTiptapEditor.tsx b/apps/web/src/components/MobileStandaloneTiptapEditor.tsx index 2c97f20e..82b22cb3 100644 --- a/apps/web/src/components/MobileStandaloneTiptapEditor.tsx +++ b/apps/web/src/components/MobileStandaloneTiptapEditor.tsx @@ -75,6 +75,7 @@ export const MobileStandaloneTiptapEditor = ({ const initialFocusTimerRef = useRef(null); const currentSavePromiseRef = useRef | null>(null); const lastSavedSnapshotRef = useRef(""); + const backgroundSavePendingRef = useRef(false); const imageInputRef = useRef(null); const setSaveStateStable = useCallback((nextState: MobileEditorSaveState) => { @@ -242,6 +243,119 @@ export const MobileStandaloneTiptapEditor = ({ tagsTextRef.current = tagsText; }, [tagsText]); + const clearPersistedDrafts = useCallback(async (memoId: string) => { + if (draftKey) { + localStorage.removeItem(draftKey); + } + + await Promise.all([ + localDb.drafts.delete(memoId), + localDb.syncQueue.delete(getMemoUpdateQueueId(memoId)), + ]); + }, [draftKey]); + + const buildSavePayload = useCallback((currentMemo: MemoDetail) => ({ + expectedRevision: currentMemo.revision, + title: titleRef.current, + contentJson: contentJsonRef.current, + tags: parseMobileEditorTags(tagsTextRef.current), + }), []); + + const applySavedMemo = useCallback(async (savedMemo: MemoDetail, savedSnapshot: string) => { + memoRef.current = savedMemo; + setMemo(savedMemo); + backgroundSavePendingRef.current = false; + lastSavedSnapshotRef.current = savedSnapshot; + + if (currentSnapshot() === savedSnapshot) { + dirtyRef.current = false; + await clearPersistedDrafts(savedMemo.id); + setSaveStateStable("saved"); + window.setTimeout(() => { + if (!dirtyRef.current && !savingRef.current && !leavingRef.current) { + setSaveStateStable("idle"); + } + }, 1200); + return; + } + + dirtyRef.current = true; + persistLocalDraft(); + setSaveStateStable("dirty"); + }, [clearPersistedDrafts, currentSnapshot, persistLocalDraft, setSaveStateStable]); + + const sendBackgroundSave = useCallback(() => { + const currentMemo = memoRef.current; + if (!currentMemo || currentMemo.isDeleted || !dirtyRef.current) { + return false; + } + + const path = `/api/v1/memos/${encodeURIComponent(currentMemo.id)}/save`; + const body = JSON.stringify(buildSavePayload(currentMemo)); + const snapshot = currentSnapshot(); + + if (typeof navigator.sendBeacon === "function") { + const accepted = navigator.sendBeacon(path, new Blob([body], { type: "application/json" })); + + if (accepted) { + backgroundSavePendingRef.current = true; + void Promise.all([ + localDb.drafts.delete(currentMemo.id), + localDb.syncQueue.delete(getMemoUpdateQueueId(currentMemo.id)), + ]); + return true; + } + } + + void requestMobileEditorJson(path, { + method: "POST", + keepalive: true, + body, + }) + .then((data) => applySavedMemo(data.memo, snapshot)) + .catch(() => { + persistLocalDraft(); + setSaveStateStable("local-draft"); + }); + + return true; + }, [applySavedMemo, buildSavePayload, currentSnapshot, persistLocalDraft, setSaveStateStable]); + + const reconcileBackgroundSave = useCallback(async () => { + const currentMemo = memoRef.current; + if (!currentMemo || !backgroundSavePendingRef.current) { + return; + } + + try { + const data = await requestMobileEditorJson(`/api/v1/memos/${encodeURIComponent(currentMemo.id)}`); + const remoteSnapshot = JSON.stringify({ + title: data.memo.title || "", + tagsText: Array.isArray(data.memo.tags) ? data.memo.tags.join(", ") : "", + contentJson: normalizeMobileEditorDoc(data.memo), + }); + + memoRef.current = data.memo; + setMemo(data.memo); + backgroundSavePendingRef.current = false; + + if (remoteSnapshot === currentSnapshot()) { + lastSavedSnapshotRef.current = remoteSnapshot; + dirtyRef.current = false; + await clearPersistedDrafts(data.memo.id); + setSaveStateStable("saved"); + return; + } + + dirtyRef.current = true; + persistLocalDraft(); + void saveNowRef.current(); + } catch { + persistLocalDraft(); + setSaveStateStable("local-draft"); + } + }, [clearPersistedDrafts, currentSnapshot, persistLocalDraft, setSaveStateStable]); + const saveNow = useCallback( async ({ keepalive = false }: { keepalive?: boolean } = {}) => { const currentMemo = memoRef.current; @@ -268,35 +382,13 @@ export const MobileStandaloneTiptapEditor = ({ setError(null); currentSavePromiseRef.current = (async () => { - const nextContentJson = contentJsonRef.current; const data = await requestMobileEditorJson(`/api/v1/memos/${encodeURIComponent(currentMemo.id)}`, { method: "PATCH", keepalive, - body: JSON.stringify({ - expectedRevision: currentMemo.revision, - title: titleRef.current, - contentJson: nextContentJson, - tags: parseMobileEditorTags(tagsTextRef.current), - }), + body: JSON.stringify(buildSavePayload(currentMemo)), }); - memoRef.current = data.memo; - setMemo(data.memo); - lastSavedSnapshotRef.current = currentSnapshot(); - dirtyRef.current = false; - if (draftKey) { - localStorage.removeItem(draftKey); - } - await Promise.all([ - localDb.drafts.delete(data.memo.id), - localDb.syncQueue.delete(getMemoUpdateQueueId(data.memo.id)), - ]); - setSaveStateStable("saved"); - window.setTimeout(() => { - if (!dirtyRef.current && !savingRef.current && !leavingRef.current) { - setSaveStateStable("idle"); - } - }, 1200); + await applySavedMemo(data.memo, snapshot); return true; })(); @@ -318,7 +410,7 @@ export const MobileStandaloneTiptapEditor = ({ currentSavePromiseRef.current = null; } }, - [currentSnapshot, draftKey, persistLocalDraft, setSaveStateStable] + [applySavedMemo, buildSavePayload, currentSnapshot, persistLocalDraft, setSaveStateStable] ); useEffect(() => { @@ -583,17 +675,22 @@ export const MobileStandaloneTiptapEditor = ({ const handlePageHide = () => { if (dirtyRef.current) { persistLocalDraft(); - void saveNow({ keepalive: true }); + if (!sendBackgroundSave()) { + void saveNow({ keepalive: true }); + } } }; const handleVisibilityChange = () => { if (document.visibilityState === "hidden" && dirtyRef.current) { persistLocalDraft(); - void saveNow({ keepalive: true }); + if (!sendBackgroundSave()) { + void saveNow({ keepalive: true }); + } return; } if (document.visibilityState === "visible") { + void reconcileBackgroundSave(); void syncQueuedChanges({ onSynced: (syncedMemo) => { if (syncedMemo.id !== memoRef.current?.id) { @@ -648,7 +745,7 @@ export const MobileStandaloneTiptapEditor = ({ window.clearTimeout(initialFocusTimerRef.current); } }; - }, [currentSnapshot, draftKey, leavePage, persistLocalDraft, saveNow, setSaveStateStable]); + }, [currentSnapshot, draftKey, leavePage, persistLocalDraft, reconcileBackgroundSave, saveNow, sendBackgroundSave, setSaveStateStable]); const saveLabel = getMobileEditorSaveLabel(saveState); const statusClassName = getMobileEditorStatusClassName(saveState);