refactor(web): extract editor save conflict handling
Move save-failure classification, save chrome, and conflict actions out of EditorPane so queueing, conflict banners, and adopt/copy stay one code path.
This commit is contained in:
1 parent
6d6b1704a0
commit
493fbbd7d4
6 files changed
+343
-150
No files matched your search
@@ -130,13 +130,9 @@ import { createEdgeEverMathematics } from "@edgeever/shared/mathematics";
|
||||
import { codeBlockLowlight, EdgeEverCodeBlock } from "@/lib/code-block";
|
||||
import { compressImageForUpload } from "@/lib/image-compression";
|
||||
import { LOCAL_DATABASE_INTERRUPTED_EVENT, localDb, selectNewestLocalDraft, type MemoUpdateSyncPayload } from "@/lib/local-db";
|
||||
import { LocalDatabaseUnavailableError } from "@/lib/local-database-recovery";
|
||||
import { persistEmergencyDraft, readEmergencyDraft, removeEmergencyDraft } from "@/lib/emergency-draft";
|
||||
import { getMemoUpdateQueueId, isMemoUpdateAlreadyApplied, queueMemoUpdate, shouldQueueMemoSaveError } from "@/lib/sync-queue";
|
||||
import { getMemoUpdateQueueId, isMemoUpdateAlreadyApplied, queueMemoUpdate } from "@/lib/sync-queue";
|
||||
import {
|
||||
formatLocalDraftClipboardText,
|
||||
formatMemoSaveConflictReason,
|
||||
getMemoSaveConflictInfo,
|
||||
getMemoSaveConflictInfoFromQueueItem,
|
||||
} from "@/lib/memo-save-conflict";
|
||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||
@@ -226,6 +222,9 @@ import { createInlineFieldExtension } from "./editor/InlineField";
|
||||
import { createPluginEmbedExtension } from "./editor/PluginEmbed";
|
||||
import { getEditorScrollProgress, restoreEditorScrollProgress } from "./editor/editor-mode-scroll";
|
||||
import { useEditorSaveStatus } from "./editor/useEditorSaveStatus";
|
||||
import { getEditorSaveChrome } from "./editor/editor-save-chrome";
|
||||
import { classifyEditorSaveFailure, shouldLeaveEditorAfterSaveError } from "./editor/editor-save-failure";
|
||||
import { useEditorSaveConflictActions } from "./editor/useEditorSaveConflictActions";
|
||||
import { useEditorNoteSearchController } from "./editor/useEditorNoteSearchController";
|
||||
import { EditorNoteLinkPicker } from "./editor/EditorNoteLinkPicker";
|
||||
import { EditorResourceDialogs } from "./editor/EditorResourceDialogs";
|
||||
@@ -413,8 +412,6 @@ const RichEditorPane = ({
|
||||
setSaveConflictInfo,
|
||||
setSaveState,
|
||||
} = useEditorSaveStatus();
|
||||
const [conflictActionPending, setConflictActionPending] = useState<"adopt" | "copy" | null>(null);
|
||||
const [conflictActionMessage, setConflictActionMessage] = useState<string | null>(null);
|
||||
const [storageSaveError, setStorageSaveError] = useState(false);
|
||||
const [hydratedEditorMemoId, setHydratedEditorMemoId] = useState<string | null>(null);
|
||||
|
||||
@@ -2864,32 +2861,31 @@ const RichEditorPane = ({
|
||||
setSaveState("idle");
|
||||
},
|
||||
onError: async (error) => {
|
||||
if (error instanceof LocalDatabaseUnavailableError) {
|
||||
const failure = classifyEditorSaveFailure(error);
|
||||
if (failure.kind === "storage") {
|
||||
setStorageSaveError(true);
|
||||
setSaveConflictInfo(null);
|
||||
setSaveState("error");
|
||||
return;
|
||||
}
|
||||
setStorageSaveError(false);
|
||||
const sourceError = error instanceof MemoSaveRequestError ? error.originalError : error;
|
||||
const conflictInfo = getMemoSaveConflictInfo(sourceError);
|
||||
|
||||
if (conflictInfo) {
|
||||
setSaveConflictInfo(conflictInfo);
|
||||
if (failure.kind === "conflict") {
|
||||
setSaveConflictInfo(failure.conflict);
|
||||
setSaveState("conflict");
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof MemoSaveRequestError && shouldQueueMemoSaveError(sourceError)) {
|
||||
await queueMemoUpdate(error.payload);
|
||||
if (failure.kind === "queue") {
|
||||
await queueMemoUpdate(failure.payload);
|
||||
await localDb.drafts.put({
|
||||
memoId: error.payload.memoId,
|
||||
title: error.payload.title,
|
||||
tagsText: error.tagsText,
|
||||
contentJson: error.payload.contentJson,
|
||||
memoId: failure.payload.memoId,
|
||||
title: failure.payload.title,
|
||||
tagsText: failure.tagsText,
|
||||
contentJson: failure.payload.contentJson,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
removeEmergencyDraft(error.payload.memoId);
|
||||
removeEmergencyDraft(failure.payload.memoId);
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
setSaveConflictInfo(null);
|
||||
@@ -3273,19 +3269,6 @@ const RichEditorPane = ({
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [dirtyVersion, editor, hasUnsavedChanges, memo, mutateSave, saveMutationPending, saveState, useMobilePlainTextEditor]);
|
||||
|
||||
// Must stay above early returns so hook order never changes across loading/empty/editor states.
|
||||
const saveConflictReason = useMemo(
|
||||
() => (saveState === "conflict" ? formatMemoSaveConflictReason(t, saveConflictInfo) : null),
|
||||
[saveConflictInfo, saveState, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (saveState !== "conflict") {
|
||||
setConflictActionPending(null);
|
||||
setConflictActionMessage(null);
|
||||
}
|
||||
}, [saveState]);
|
||||
|
||||
const getLocalDraftMarkdown = useCallback(() => {
|
||||
if (useMobilePlainTextEditor) {
|
||||
return getMobilePlainTextValue();
|
||||
@@ -3307,108 +3290,83 @@ const RichEditorPane = ({
|
||||
useMobilePlainTextEditor,
|
||||
]);
|
||||
|
||||
const handleCopyLocalDraft = useCallback(async () => {
|
||||
if (conflictActionPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
setConflictActionPending("copy");
|
||||
setConflictActionMessage(null);
|
||||
try {
|
||||
const text = formatLocalDraftClipboardText({
|
||||
title,
|
||||
tags: parseTagsText(tagsText),
|
||||
contentMarkdown: getLocalDraftMarkdown(),
|
||||
});
|
||||
const copied = await copyTextToClipboard(text);
|
||||
if (!copied) {
|
||||
setConflictActionMessage(t("editor.saveState.conflictCopyDraftFailed"));
|
||||
return;
|
||||
}
|
||||
setConflictActionMessage(t("editor.saveState.conflictCopyDraftDone"));
|
||||
window.setTimeout(() => {
|
||||
setConflictActionMessage((current) =>
|
||||
current === t("editor.saveState.conflictCopyDraftDone") ? null : current
|
||||
);
|
||||
}, 2000);
|
||||
} catch {
|
||||
setConflictActionMessage(t("editor.saveState.conflictCopyDraftFailed"));
|
||||
} finally {
|
||||
setConflictActionPending(null);
|
||||
}
|
||||
}, [conflictActionPending, getLocalDraftMarkdown, t, tagsText, title]);
|
||||
|
||||
const handleAdoptCloudAndReload = useCallback(async () => {
|
||||
const adoptCloudMemo = useCallback(async () => {
|
||||
const currentMemo = memoRef.current;
|
||||
if (!currentMemo || conflictActionPending === "adopt") {
|
||||
if (!currentMemo) {
|
||||
return;
|
||||
}
|
||||
|
||||
setConflictActionPending("adopt");
|
||||
setConflictActionMessage(null);
|
||||
try {
|
||||
const { memo: remoteMemo } = await repository.adoptCloudMemo(currentMemo.id);
|
||||
await onSaved(remoteMemo);
|
||||
const { memo: remoteMemo } = await repository.adoptCloudMemo(currentMemo.id);
|
||||
await onSaved(remoteMemo);
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
setSaveConflictInfo(null);
|
||||
setSaveState("idle");
|
||||
setConflictActionMessage(null);
|
||||
setHasUnsavedChanges(false);
|
||||
setSaveConflictInfo(null);
|
||||
setSaveState("idle");
|
||||
|
||||
const nextTitle = getEditableMemoTitle(remoteMemo.title);
|
||||
const nextTagsText = remoteMemo.tags.join(", ");
|
||||
const nextContent = resolveMemoContentDoc(remoteMemo.contentJson, remoteMemo.contentMarkdown);
|
||||
const nextMarkdown = remoteMemo.contentMarkdown || docToMarkdown(nextContent);
|
||||
const nextTitle = getEditableMemoTitle(remoteMemo.title);
|
||||
const nextTagsText = remoteMemo.tags.join(", ");
|
||||
const nextContent = resolveMemoContentDoc(remoteMemo.contentJson, remoteMemo.contentMarkdown);
|
||||
const nextMarkdown = remoteMemo.contentMarkdown || docToMarkdown(nextContent);
|
||||
|
||||
memoRef.current = remoteMemo;
|
||||
editSessionRef.current = null;
|
||||
hydratedMemoIdRef.current = remoteMemo.id;
|
||||
setHydratedEditorMemoId(remoteMemo.id);
|
||||
editingMemoIdRef.current = remoteMemo.id;
|
||||
appliedEditorSourceKeyRef.current = `memo:${remoteMemo.id}:${remoteMemo.revision}:${remoteMemo.updatedAt}:${remoteMemo.contentHash}:${nextTitle}:${nextTagsText}:${nextMarkdown}`;
|
||||
memoRef.current = remoteMemo;
|
||||
editSessionRef.current = null;
|
||||
hydratedMemoIdRef.current = remoteMemo.id;
|
||||
setHydratedEditorMemoId(remoteMemo.id);
|
||||
editingMemoIdRef.current = remoteMemo.id;
|
||||
appliedEditorSourceKeyRef.current = `memo:${remoteMemo.id}:${remoteMemo.revision}:${remoteMemo.updatedAt}:${remoteMemo.contentHash}:${nextTitle}:${nextTagsText}:${nextMarkdown}`;
|
||||
|
||||
setTitle(nextTitle);
|
||||
setTagsText(nextTagsText);
|
||||
setMobilePlainText(nextMarkdown);
|
||||
setMarkdownSource(nextMarkdown);
|
||||
markdownModeSnapshotRef.current = isMarkdownMode
|
||||
? createMarkdownModeSnapshot(remoteMemo.id, nextContent, nextMarkdown)
|
||||
: null;
|
||||
setMobilePlainTextElementValue(mobileTextAreaRef.current, nextMarkdown);
|
||||
setTitle(nextTitle);
|
||||
setTagsText(nextTagsText);
|
||||
setMobilePlainText(nextMarkdown);
|
||||
setMarkdownSource(nextMarkdown);
|
||||
markdownModeSnapshotRef.current = isMarkdownMode
|
||||
? createMarkdownModeSnapshot(remoteMemo.id, nextContent, nextMarkdown)
|
||||
: null;
|
||||
setMobilePlainTextElementValue(mobileTextAreaRef.current, nextMarkdown);
|
||||
|
||||
const currentEditor = editorRef.current;
|
||||
if (isEditorReady(currentEditor)) {
|
||||
hydratingRef.current = true;
|
||||
try {
|
||||
currentEditor.commands.setContent(nextContent);
|
||||
} catch (err) {
|
||||
console.error("Failed to apply cloud memo after conflict resolve:", err);
|
||||
currentEditor.commands.setContent(markdownToDoc(nextMarkdown));
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
hydratingRef.current = false;
|
||||
}, 0);
|
||||
const currentEditor = editorRef.current;
|
||||
if (isEditorReady(currentEditor)) {
|
||||
hydratingRef.current = true;
|
||||
try {
|
||||
currentEditor.commands.setContent(nextContent);
|
||||
} catch (err) {
|
||||
console.error("Failed to apply cloud memo after conflict resolve:", err);
|
||||
currentEditor.commands.setContent(markdownToDoc(nextMarkdown));
|
||||
}
|
||||
|
||||
if (requiresLocalEditSession(remoteMemo)) {
|
||||
editSessionRef.current = createLocalEditSession(remoteMemo);
|
||||
} else {
|
||||
void api.createMemoEditSession(remoteMemo.id).then((response) => {
|
||||
if (editingMemoIdRef.current !== remoteMemo.id) return;
|
||||
editSessionRef.current = response.editSession;
|
||||
}).catch(() => {
|
||||
if (editingMemoIdRef.current !== remoteMemo.id) return;
|
||||
editSessionRef.current = createLocalEditSession(remoteMemo);
|
||||
});
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["memo", remoteMemo.id] });
|
||||
} catch {
|
||||
setConflictActionMessage(t("editor.saveState.conflictAdoptFailed"));
|
||||
} finally {
|
||||
setConflictActionPending(null);
|
||||
window.setTimeout(() => {
|
||||
hydratingRef.current = false;
|
||||
}, 0);
|
||||
}
|
||||
}, [conflictActionPending, isMarkdownMode, onSaved, queryClient, repository, t]);
|
||||
|
||||
if (requiresLocalEditSession(remoteMemo)) {
|
||||
editSessionRef.current = createLocalEditSession(remoteMemo);
|
||||
} else {
|
||||
void api.createMemoEditSession(remoteMemo.id).then((response) => {
|
||||
if (editingMemoIdRef.current !== remoteMemo.id) return;
|
||||
editSessionRef.current = response.editSession;
|
||||
}).catch(() => {
|
||||
if (editingMemoIdRef.current !== remoteMemo.id) return;
|
||||
editSessionRef.current = createLocalEditSession(remoteMemo);
|
||||
});
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["memo", remoteMemo.id] });
|
||||
}, [isMarkdownMode, onSaved, queryClient, repository]);
|
||||
|
||||
const {
|
||||
conflictActionMessage,
|
||||
conflictActionPending,
|
||||
handleAdoptCloudAndReload,
|
||||
handleCopyLocalDraft,
|
||||
saveConflictReason,
|
||||
} = useEditorSaveConflictActions({
|
||||
adoptCloudMemo,
|
||||
getLocalDraftMarkdown,
|
||||
saveConflictInfo,
|
||||
saveState,
|
||||
tagsText,
|
||||
title,
|
||||
});
|
||||
|
||||
if (isSelectionMode) {
|
||||
return (
|
||||
@@ -3443,29 +3401,11 @@ const RichEditorPane = ({
|
||||
);
|
||||
}
|
||||
|
||||
const saveLabel =
|
||||
saveState === "saving"
|
||||
? t("editor.saveState.saving")
|
||||
: saveState === "saved"
|
||||
? t("editor.saveState.saved")
|
||||
: saveState === "queued"
|
||||
? t("editor.saveState.queued")
|
||||
: saveState === "conflict"
|
||||
? t("editor.saveState.conflict")
|
||||
: saveState === "error"
|
||||
? t("editor.saveState.error")
|
||||
: hasUnsavedChanges
|
||||
? t("editor.saveState.unsaved")
|
||||
: t("editor.saveState.saved");
|
||||
|
||||
const saveStateClassName =
|
||||
saveState === "error" || saveState === "conflict"
|
||||
? "bg-rose-50 text-rose-700"
|
||||
: saveState === "queued"
|
||||
? "bg-slate-50 text-slate-400"
|
||||
: saveState === "saving" || hasUnsavedChanges
|
||||
? "bg-emerald-50 text-emerald-700"
|
||||
: "bg-slate-100 text-slate-500";
|
||||
const { saveLabel, saveStateClassName } = getEditorSaveChrome({
|
||||
hasUnsavedChanges,
|
||||
saveState,
|
||||
t,
|
||||
});
|
||||
|
||||
const imageUploadLabel =
|
||||
imageUploadState === "error"
|
||||
@@ -3577,8 +3517,7 @@ const RichEditorPane = ({
|
||||
onBackToList();
|
||||
},
|
||||
onError: (error) => {
|
||||
const sourceError = error instanceof MemoSaveRequestError ? error.originalError : error;
|
||||
if (error instanceof MemoSaveRequestError && shouldQueueMemoSaveError(sourceError)) {
|
||||
if (shouldLeaveEditorAfterSaveError(error)) {
|
||||
onMobileDefaultEditConsumed();
|
||||
onBackToList();
|
||||
}
|
||||
@@ -3601,8 +3540,7 @@ const RichEditorPane = ({
|
||||
setMobileToolbarOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
const sourceError = error instanceof MemoSaveRequestError ? error.originalError : error;
|
||||
if (error instanceof MemoSaveRequestError && shouldQueueMemoSaveError(sourceError)) {
|
||||
if (shouldLeaveEditorAfterSaveError(error)) {
|
||||
onMobileDefaultEditConsumed();
|
||||
setIsMobileEditing(false);
|
||||
setMobileToolbarOpen(false);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { getEditorSaveChrome } from "./editor-save-chrome.ts";
|
||||
|
||||
const t = (key) => key;
|
||||
|
||||
describe("getEditorSaveChrome", () => {
|
||||
test("keeps conflict chrome over unsaved edits", () => {
|
||||
expect(getEditorSaveChrome({ saveState: "conflict", hasUnsavedChanges: true, t })).toEqual({
|
||||
saveLabel: "editor.saveState.conflict",
|
||||
saveStateClassName: "bg-rose-50 text-rose-700",
|
||||
});
|
||||
});
|
||||
|
||||
test("shows queued chrome while the outbox holds the save", () => {
|
||||
expect(getEditorSaveChrome({ saveState: "queued", hasUnsavedChanges: false, t }).saveLabel).toBe(
|
||||
"editor.saveState.queued",
|
||||
);
|
||||
});
|
||||
|
||||
test("marks dirty idle saves as unsaved", () => {
|
||||
expect(getEditorSaveChrome({ saveState: "idle", hasUnsavedChanges: true, t }).saveLabel).toBe(
|
||||
"editor.saveState.unsaved",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { EditorSavePhase } from "./useEditorSaveStatus";
|
||||
|
||||
type Translate = (key: string) => string;
|
||||
|
||||
export const getEditorSaveChrome = ({
|
||||
saveState,
|
||||
hasUnsavedChanges,
|
||||
t,
|
||||
}: {
|
||||
saveState: EditorSavePhase;
|
||||
hasUnsavedChanges: boolean;
|
||||
t: Translate;
|
||||
}) => {
|
||||
const saveLabel =
|
||||
saveState === "saving"
|
||||
? t("editor.saveState.saving")
|
||||
: saveState === "saved"
|
||||
? t("editor.saveState.saved")
|
||||
: saveState === "queued"
|
||||
? t("editor.saveState.queued")
|
||||
: saveState === "conflict"
|
||||
? t("editor.saveState.conflict")
|
||||
: saveState === "error"
|
||||
? t("editor.saveState.error")
|
||||
: hasUnsavedChanges
|
||||
? t("editor.saveState.unsaved")
|
||||
: t("editor.saveState.saved");
|
||||
|
||||
const saveStateClassName =
|
||||
saveState === "error" || saveState === "conflict"
|
||||
? "bg-rose-50 text-rose-700"
|
||||
: saveState === "queued"
|
||||
? "bg-slate-50 text-slate-400"
|
||||
: saveState === "saving" || hasUnsavedChanges
|
||||
? "bg-emerald-50 text-emerald-700"
|
||||
: "bg-slate-100 text-slate-500";
|
||||
|
||||
return { saveLabel, saveStateClassName };
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { ApiRequestError } from "@edgeever/client";
|
||||
import { LocalDatabaseUnavailableError } from "@/lib/local-database-recovery";
|
||||
import { MemoSaveRequestError } from "./editor-pane-helpers.ts";
|
||||
import { classifyEditorSaveFailure, shouldLeaveEditorAfterSaveError } from "./editor-save-failure.ts";
|
||||
|
||||
const queuedPayload = {
|
||||
memoId: "memo-1",
|
||||
expectedRevision: 1,
|
||||
expectedContentHash: "base",
|
||||
editSessionId: "session-1",
|
||||
title: "Draft",
|
||||
contentJson: { type: "doc", content: [] },
|
||||
contentMarkdown: "hi",
|
||||
tags: [],
|
||||
};
|
||||
|
||||
describe("classifyEditorSaveFailure", () => {
|
||||
test("treats local storage outages as a retryable storage failure", () => {
|
||||
expect(classifyEditorSaveFailure(new LocalDatabaseUnavailableError(new Error("idb")))).toEqual({
|
||||
kind: "storage",
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps revision conflicts visible instead of queueing them", () => {
|
||||
const error = new ApiRequestError("Conflict", 409, "revision_conflict", {
|
||||
expectedRevision: 3,
|
||||
currentRevision: 4,
|
||||
});
|
||||
expect(classifyEditorSaveFailure(error)).toEqual({
|
||||
kind: "conflict",
|
||||
conflict: {
|
||||
code: "revision_conflict",
|
||||
details: {
|
||||
expectedRevision: 3,
|
||||
currentRevision: 4,
|
||||
expectedContentHash: undefined,
|
||||
currentContentHash: undefined,
|
||||
source: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(shouldLeaveEditorAfterSaveError(error)).toBe(false);
|
||||
});
|
||||
|
||||
test("queues network-like save failures so leaving the editor can proceed", () => {
|
||||
const error = new MemoSaveRequestError(new TypeError("Failed to fetch"), queuedPayload, "tag");
|
||||
expect(classifyEditorSaveFailure(error)).toMatchObject({
|
||||
kind: "queue",
|
||||
tagsText: "tag",
|
||||
payload: queuedPayload,
|
||||
});
|
||||
expect(shouldLeaveEditorAfterSaveError(error)).toBe(true);
|
||||
});
|
||||
|
||||
test("falls back to a generic error for unknown failures", () => {
|
||||
expect(classifyEditorSaveFailure(new Error("boom"))).toEqual({ kind: "error" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { LocalDatabaseUnavailableError } from "@/lib/local-database-recovery";
|
||||
import { getMemoSaveConflictInfo, type MemoSaveConflictInfo } from "@/lib/memo-save-conflict";
|
||||
import { shouldQueueMemoSaveError } from "@/lib/sync-queue";
|
||||
import type { MemoUpdateSyncPayload } from "@/lib/local-db";
|
||||
import { MemoSaveRequestError } from "./editor-pane-helpers";
|
||||
|
||||
export type EditorSaveFailure =
|
||||
| { kind: "storage" }
|
||||
| { kind: "conflict"; conflict: MemoSaveConflictInfo }
|
||||
| { kind: "queue"; payload: MemoUpdateSyncPayload; tagsText: string }
|
||||
| { kind: "error" };
|
||||
|
||||
export const classifyEditorSaveFailure = (error: unknown): EditorSaveFailure => {
|
||||
if (error instanceof LocalDatabaseUnavailableError) {
|
||||
return { kind: "storage" };
|
||||
}
|
||||
|
||||
const sourceError = error instanceof MemoSaveRequestError ? error.originalError : error;
|
||||
const conflict = getMemoSaveConflictInfo(sourceError);
|
||||
if (conflict) {
|
||||
return { kind: "conflict", conflict };
|
||||
}
|
||||
|
||||
if (error instanceof MemoSaveRequestError && shouldQueueMemoSaveError(sourceError)) {
|
||||
return { kind: "queue", payload: error.payload, tagsText: error.tagsText };
|
||||
}
|
||||
|
||||
return { kind: "error" };
|
||||
};
|
||||
|
||||
export const shouldLeaveEditorAfterSaveError = (error: unknown) =>
|
||||
classifyEditorSaveFailure(error).kind === "queue";
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||
import { parseTagsText } from "@/lib/utils";
|
||||
import {
|
||||
formatLocalDraftClipboardText,
|
||||
formatMemoSaveConflictReason,
|
||||
type MemoSaveConflictInfo,
|
||||
} from "@/lib/memo-save-conflict";
|
||||
import type { EditorSavePhase } from "./useEditorSaveStatus";
|
||||
|
||||
export type EditorConflictAction = "adopt" | "copy";
|
||||
|
||||
export const useEditorSaveConflictActions = ({
|
||||
saveState,
|
||||
saveConflictInfo,
|
||||
title,
|
||||
tagsText,
|
||||
getLocalDraftMarkdown,
|
||||
adoptCloudMemo,
|
||||
}: {
|
||||
saveState: EditorSavePhase;
|
||||
saveConflictInfo: MemoSaveConflictInfo | null;
|
||||
title: string;
|
||||
tagsText: string;
|
||||
getLocalDraftMarkdown: () => string;
|
||||
adoptCloudMemo: () => Promise<void>;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [conflictActionPending, setConflictActionPending] = useState<EditorConflictAction | null>(null);
|
||||
const [conflictActionMessage, setConflictActionMessage] = useState<string | null>(null);
|
||||
|
||||
const saveConflictReason = useMemo(
|
||||
() => (saveState === "conflict" ? formatMemoSaveConflictReason(t, saveConflictInfo) : null),
|
||||
[saveConflictInfo, saveState, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (saveState !== "conflict") {
|
||||
setConflictActionPending(null);
|
||||
setConflictActionMessage(null);
|
||||
}
|
||||
}, [saveState]);
|
||||
|
||||
const handleCopyLocalDraft = useCallback(async () => {
|
||||
if (conflictActionPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
setConflictActionPending("copy");
|
||||
setConflictActionMessage(null);
|
||||
try {
|
||||
const text = formatLocalDraftClipboardText({
|
||||
title,
|
||||
tags: parseTagsText(tagsText),
|
||||
contentMarkdown: getLocalDraftMarkdown(),
|
||||
});
|
||||
const copied = await copyTextToClipboard(text);
|
||||
if (!copied) {
|
||||
setConflictActionMessage(t("editor.saveState.conflictCopyDraftFailed"));
|
||||
return;
|
||||
}
|
||||
setConflictActionMessage(t("editor.saveState.conflictCopyDraftDone"));
|
||||
window.setTimeout(() => {
|
||||
setConflictActionMessage((current) =>
|
||||
current === t("editor.saveState.conflictCopyDraftDone") ? null : current
|
||||
);
|
||||
}, 2000);
|
||||
} catch {
|
||||
setConflictActionMessage(t("editor.saveState.conflictCopyDraftFailed"));
|
||||
} finally {
|
||||
setConflictActionPending(null);
|
||||
}
|
||||
}, [conflictActionPending, getLocalDraftMarkdown, t, tagsText, title]);
|
||||
|
||||
const handleAdoptCloudAndReload = useCallback(async () => {
|
||||
if (conflictActionPending === "adopt") {
|
||||
return;
|
||||
}
|
||||
|
||||
setConflictActionPending("adopt");
|
||||
setConflictActionMessage(null);
|
||||
try {
|
||||
await adoptCloudMemo();
|
||||
setConflictActionMessage(null);
|
||||
} catch {
|
||||
setConflictActionMessage(t("editor.saveState.conflictAdoptFailed"));
|
||||
} finally {
|
||||
setConflictActionPending(null);
|
||||
}
|
||||
}, [adoptCloudMemo, conflictActionPending, t]);
|
||||
|
||||
return {
|
||||
conflictActionMessage,
|
||||
conflictActionPending,
|
||||
handleAdoptCloudAndReload,
|
||||
handleCopyLocalDraft,
|
||||
saveConflictReason,
|
||||
};
|
||||
};
|
||||
Reference in new issue
Block a user