Move standalone Tiptap editor into reusable component

This commit is contained in:
yoma committed 2026-07-08 09:23:45 +08:00
1 parent 89171dc861
commit de0b802e7f
2 files changed
+635 -633

No files matched your search

@@ -0,0 +1,632 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Image from "@tiptap/extension-image";
import Placeholder from "@tiptap/extension-placeholder";
import { docToMarkdown, emptyDoc, type MemoDetail, type Notebook, type TiptapDoc } from "@edgeever/shared";
import {
MobileEditorFallback,
MobileEditorHeader,
MobileEditorNotebookButton,
MobileEditorNotebookSheet,
MobileEditorToolbar,
} from "@/components/MobileStandaloneEditorParts";
import { getNotebookMoveOptions } from "@/lib/app-helpers";
import { compressImageForUpload } from "@/lib/image-compression";
import {
DEFAULT_MOBILE_EDITOR_MEMO_TITLE,
MOBILE_EDITOR_AUTO_SAVE_DELAY_MS,
MOBILE_EDITOR_INITIAL_FOCUS_DELAY_MS,
MOBILE_EDITOR_LEAVE_SAVE_TIMEOUT_MS,
getMobileEditorDraftKey,
getMobileEditorParams,
getMobileEditorSaveLabel,
getMobileEditorStatusClassName,
normalizeMobileEditorDoc,
parseMobileEditorTags,
requestMobileEditorJson,
safeMobileEditorReturnPath,
uploadMobileEditorResource,
type MobileEditorDraft,
type MobileEditorMemoResponse,
type MobileEditorSaveState,
} from "@/lib/mobile-editor-standalone";
type ListNotebooksResponse = {
notebooks: Notebook[];
};
export const MobileStandaloneTiptapEditor = () => {
const params = useMemo(() => getMobileEditorParams(), []);
const memoId = params.get("memoId");
const returnTo = safeMobileEditorReturnPath(params.get("returnTo"));
const draftKey = getMobileEditorDraftKey(memoId);
const [memo, setMemo] = useState<MemoDetail | null>(null);
const memoRef = useRef<MemoDetail | null>(null);
const [notebooks, setNotebooks] = useState<Notebook[]>([]);
const [notebookUpdatePending, setNotebookUpdatePending] = useState(false);
const [notebookSheetOpen, setNotebookSheetOpen] = useState(false);
const [title, setTitle] = useState("");
const titleRef = useRef("");
const [tagsText, setTagsText] = useState("");
const tagsTextRef = useRef("");
const contentJsonRef = useRef<TiptapDoc>(emptyDoc());
const [saveState, setSaveState] = useState<MobileEditorSaveState>("loading");
const saveStateRef = useRef<MobileEditorSaveState>("loading");
const [error, setError] = useState<string | null>(null);
const notebookOptions = useMemo(() => getNotebookMoveOptions(notebooks), [notebooks]);
const [, setToolbarVersion] = useState(0);
const dirtyRef = useRef(false);
const leavingRef = useRef(false);
const savingRef = useRef(false);
const saveTimerRef = useRef<number | null>(null);
const initialFocusTimerRef = useRef<number | null>(null);
const currentSavePromiseRef = useRef<Promise<boolean> | null>(null);
const lastSavedSnapshotRef = useRef("");
const imageInputRef = useRef<HTMLInputElement | null>(null);
const setSaveStateStable = useCallback((nextState: MobileEditorSaveState) => {
if (saveStateRef.current === nextState) {
return;
}
saveStateRef.current = nextState;
setSaveState(nextState);
}, []);
const currentSnapshot = useCallback(
() =>
JSON.stringify({
title: titleRef.current,
tagsText: tagsTextRef.current,
contentJson: contentJsonRef.current,
}),
[]
);
const persistLocalDraft = useCallback(() => {
if (!draftKey) {
return;
}
const draft: MobileEditorDraft = {
title: titleRef.current,
tagsText: tagsTextRef.current,
contentJson: contentJsonRef.current,
updatedAt: new Date().toISOString(),
};
localStorage.setItem(draftKey, JSON.stringify(draft));
}, [draftKey]);
const readLocalDraft = useCallback((): MobileEditorDraft | null => {
if (!draftKey) {
return null;
}
try {
const raw = localStorage.getItem(draftKey);
return raw ? (JSON.parse(raw) as MobileEditorDraft) : null;
} catch {
return null;
}
}, [draftKey]);
const editor = useEditor({
extensions: [
StarterKit,
Image.configure({
allowBase64: false,
inline: false,
}),
Placeholder.configure({
placeholder: "开始记录...",
}),
],
content: emptyDoc(),
editorProps: {
attributes: {
class: "edgeever-mobile-tiptap-content",
autocapitalize: "sentences",
autocomplete: "on",
autocorrect: "on",
inputmode: "text",
spellcheck: "true",
},
},
onUpdate: ({ editor: activeEditor }) => {
contentJsonRef.current = activeEditor.getJSON() as TiptapDoc;
dirtyRef.current = true;
persistLocalDraft();
if (saveStateRef.current !== "dirty") {
setSaveStateStable("dirty");
}
if (saveTimerRef.current !== null) {
window.clearTimeout(saveTimerRef.current);
}
saveTimerRef.current = window.setTimeout(() => {
saveTimerRef.current = null;
void saveNowRef.current();
}, MOBILE_EDITOR_AUTO_SAVE_DELAY_MS);
},
});
const saveNowRef = useRef<({ keepalive }?: { keepalive?: boolean }) => Promise<boolean>>(async () => false);
useEffect(() => {
if (!editor) {
return;
}
const refreshToolbar = () => setToolbarVersion((version) => version + 1);
editor.on("selectionUpdate", refreshToolbar);
editor.on("transaction", refreshToolbar);
editor.on("focus", refreshToolbar);
editor.on("blur", refreshToolbar);
return () => {
editor.off("selectionUpdate", refreshToolbar);
editor.off("transaction", refreshToolbar);
editor.off("focus", refreshToolbar);
editor.off("blur", refreshToolbar);
};
}, [editor]);
useEffect(() => {
memoRef.current = memo;
}, [memo]);
useEffect(() => {
titleRef.current = title;
}, [title]);
useEffect(() => {
tagsTextRef.current = tagsText;
}, [tagsText]);
const saveNow = useCallback(
async ({ keepalive = false }: { keepalive?: boolean } = {}) => {
const currentMemo = memoRef.current;
if (!currentMemo) {
return false;
}
if (savingRef.current) {
return currentSavePromiseRef.current ?? false;
}
const snapshot = currentSnapshot();
if (!dirtyRef.current && snapshot === lastSavedSnapshotRef.current) {
return true;
}
if (saveTimerRef.current !== null) {
window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
savingRef.current = true;
setSaveStateStable("saving");
setError(null);
currentSavePromiseRef.current = (async () => {
const nextContentJson = contentJsonRef.current;
const data = await requestMobileEditorJson<MobileEditorMemoResponse>(`/api/v1/memos/${encodeURIComponent(currentMemo.id)}`, {
method: "PATCH",
keepalive,
body: JSON.stringify({
expectedRevision: currentMemo.revision,
title: titleRef.current,
contentJson: nextContentJson,
tags: parseMobileEditorTags(tagsTextRef.current),
}),
});
memoRef.current = data.memo;
setMemo(data.memo);
lastSavedSnapshotRef.current = currentSnapshot();
dirtyRef.current = false;
if (draftKey) {
localStorage.removeItem(draftKey);
}
setSaveStateStable("saved");
window.setTimeout(() => {
if (!dirtyRef.current && !savingRef.current && !leavingRef.current) {
setSaveStateStable("idle");
}
}, 1200);
return true;
})();
try {
return await currentSavePromiseRef.current;
} catch (saveError) {
persistLocalDraft();
setError(saveError instanceof Error ? saveError.message : "保存失败,已保留本地草稿");
setSaveStateStable("error");
return false;
} finally {
savingRef.current = false;
currentSavePromiseRef.current = null;
}
},
[currentSnapshot, draftKey, persistLocalDraft, setSaveStateStable]
);
useEffect(() => {
saveNowRef.current = saveNow;
}, [saveNow]);
const scheduleMetadataSave = useCallback(() => {
dirtyRef.current = true;
persistLocalDraft();
setSaveStateStable("dirty");
if (saveTimerRef.current !== null) {
window.clearTimeout(saveTimerRef.current);
}
saveTimerRef.current = window.setTimeout(() => {
saveTimerRef.current = null;
void saveNow();
}, MOBILE_EDITOR_AUTO_SAVE_DELAY_MS);
}, [persistLocalDraft, saveNow, setSaveStateStable]);
const navigateBack = useCallback(() => {
window.location.replace(returnTo);
}, [returnTo]);
const leavePage = useCallback(async () => {
if (leavingRef.current) {
return;
}
leavingRef.current = true;
persistLocalDraft();
if (saveTimerRef.current !== null) {
window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
setSaveStateStable("leaving");
await Promise.race([
saveNow({ keepalive: true }),
new Promise((resolve) => window.setTimeout(resolve, MOBILE_EDITOR_LEAVE_SAVE_TIMEOUT_MS)),
]);
navigateBack();
}, [navigateBack, persistLocalDraft, saveNow, setSaveStateStable]);
const handleTitleChange = (nextTitle: string) => {
setTitle(nextTitle);
titleRef.current = nextTitle;
scheduleMetadataSave();
};
const handleTagsChange = (nextTagsText: string) => {
setTagsText(nextTagsText);
tagsTextRef.current = nextTagsText;
scheduleMetadataSave();
};
const handleNotebookChange = async (nextNotebookId: string) => {
const currentMemo = memoRef.current;
if (!currentMemo || !nextNotebookId || nextNotebookId === currentMemo.notebookId || notebookUpdatePending) {
return;
}
setNotebookUpdatePending(true);
setSaveStateStable("saving");
setError(null);
try {
if (dirtyRef.current) {
const saved = await saveNow();
if (!saved) {
return;
}
}
const sourceMemo = memoRef.current;
if (!sourceMemo || nextNotebookId === sourceMemo.notebookId) {
return;
}
const data = await requestMobileEditorJson<MobileEditorMemoResponse>(`/api/v1/memos/${encodeURIComponent(sourceMemo.id)}`, {
method: "PATCH",
body: JSON.stringify({
expectedRevision: sourceMemo.revision,
notebookId: nextNotebookId,
}),
});
memoRef.current = data.memo;
setMemo(data.memo);
setSaveStateStable("saved");
window.setTimeout(() => {
if (!dirtyRef.current && !savingRef.current && !leavingRef.current) {
setSaveStateStable("idle");
}
}, 1200);
} catch (notebookError) {
setError(notebookError instanceof Error ? notebookError.message : "切换笔记本失败");
setSaveStateStable("error");
} finally {
setNotebookUpdatePending(false);
setNotebookSheetOpen(false);
}
};
const handleImageUpload = async (file?: File | null) => {
const currentMemo = memoRef.current;
if (!currentMemo || !editor || !file) {
return;
}
setError(null);
setSaveStateStable("compressing");
try {
const uploadFile = (await compressImageForUpload(file)).file;
setSaveStateStable("uploading");
const { resource } = await uploadMobileEditorResource(currentMemo.id, uploadFile);
editor
.chain()
.focus()
.setImage({
src: resource.url,
alt: file.name,
title: file.name,
})
.run();
} catch (uploadError) {
setError(uploadError instanceof Error ? uploadError.message : "图片上传失败");
setSaveStateStable("error");
}
};
const focusEditorAfterLoad = useCallback(() => {
if (!editor) {
return;
}
if (initialFocusTimerRef.current !== null) {
window.clearTimeout(initialFocusTimerRef.current);
}
initialFocusTimerRef.current = window.setTimeout(() => {
initialFocusTimerRef.current = null;
if (leavingRef.current || editor.isDestroyed) {
return;
}
const activeElement = document.activeElement;
if (activeElement && activeElement !== document.body && activeElement !== document.documentElement) {
return;
}
editor.commands.focus("end");
}, MOBILE_EDITOR_INITIAL_FOCUS_DELAY_MS);
}, [editor]);
useEffect(() => {
let cancelled = false;
void requestMobileEditorJson<ListNotebooksResponse>("/api/v1/notebooks")
.then((data) => {
if (!cancelled) {
setNotebooks(data.notebooks);
}
})
.catch(() => {
if (!cancelled) {
setNotebooks([]);
}
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!memoId || !editor) {
if (!memoId) {
setError("缺少 memoId");
setSaveStateStable("error");
}
return;
}
let cancelled = false;
void (async () => {
try {
const data = await requestMobileEditorJson<MobileEditorMemoResponse>(`/api/v1/memos/${encodeURIComponent(memoId)}`);
if (cancelled) {
return;
}
const nextTitle = data.memo.title || "";
const nextTagsText = Array.isArray(data.memo.tags) ? data.memo.tags.join(", ") : "";
const nextContentJson = normalizeMobileEditorDoc(data.memo);
const draft = readLocalDraft();
const useDraft = Boolean(draft && Date.parse(draft.updatedAt || "") >= Date.parse(data.memo.updatedAt || ""));
setMemo(data.memo);
if (useDraft && draft) {
setTitle(draft.title || "");
titleRef.current = draft.title || "";
setTagsText(draft.tagsText || "");
tagsTextRef.current = draft.tagsText || "";
contentJsonRef.current = draft.contentJson || emptyDoc();
editor.commands.setContent(contentJsonRef.current, { emitUpdate: false });
dirtyRef.current = true;
setSaveStateStable("local-draft");
scheduleMetadataSave();
focusEditorAfterLoad();
} else {
setTitle(nextTitle);
titleRef.current = nextTitle;
setTagsText(nextTagsText);
tagsTextRef.current = nextTagsText;
contentJsonRef.current = nextContentJson;
editor.commands.setContent(nextContentJson, { emitUpdate: false });
lastSavedSnapshotRef.current = JSON.stringify({
title: nextTitle,
tagsText: nextTagsText,
contentJson: nextContentJson,
});
dirtyRef.current = false;
setSaveStateStable("idle");
focusEditorAfterLoad();
}
} catch (loadError) {
if (cancelled) {
return;
}
setError(loadError instanceof Error ? loadError.message : "加载失败");
setSaveStateStable("error");
}
})();
return () => {
cancelled = true;
if (initialFocusTimerRef.current !== null) {
window.clearTimeout(initialFocusTimerRef.current);
initialFocusTimerRef.current = null;
}
};
}, [editor, focusEditorAfterLoad, memoId, readLocalDraft, scheduleMetadataSave, setSaveStateStable]);
useEffect(() => {
window.history.replaceState({ edgeeverMobileEditor: true }, "");
window.history.pushState({ edgeeverMobileEditorBackGuard: true }, "");
const handlePopState = () => {
void leavePage();
};
const handlePageHide = () => {
if (dirtyRef.current) {
persistLocalDraft();
void saveNow({ keepalive: true });
}
};
const handleVisibilityChange = () => {
if (document.visibilityState === "hidden" && dirtyRef.current) {
persistLocalDraft();
void saveNow({ keepalive: true });
}
};
window.addEventListener("popstate", handlePopState);
window.addEventListener("pagehide", handlePageHide);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.removeEventListener("popstate", handlePopState);
window.removeEventListener("pagehide", handlePageHide);
document.removeEventListener("visibilitychange", handleVisibilityChange);
if (saveTimerRef.current !== null) {
window.clearTimeout(saveTimerRef.current);
}
if (initialFocusTimerRef.current !== null) {
window.clearTimeout(initialFocusTimerRef.current);
}
};
}, [leavePage, persistLocalDraft, saveNow]);
const saveLabel = getMobileEditorSaveLabel(saveState);
const statusClassName = getMobileEditorStatusClassName(saveState);
const editorActionDisabled =
!memo || !editor || saveState === "loading" || saveState === "compressing" || saveState === "uploading" || saveState === "leaving";
const currentNotebookLabel =
notebookOptions.find((notebook) => notebook.id === memo?.notebookId)?.name ?? (notebookOptions.length === 0 ? "等待分类" : "笔记本");
const fallbackMarkdown = memo ? docToMarkdown(contentJsonRef.current) : "";
const runEditorCommand = (command: () => boolean) => {
if (editorActionDisabled || !editor) {
return;
}
command();
editor.commands.focus();
};
return (
<div className="mobile-editor-shell">
<MobileEditorHeader saveLabel={saveLabel} statusClassName={statusClassName} saveState={saveState} onLeave={() => void leavePage()} />
<main className="mobile-editor-main">
{error && <div className="mobile-editor-error">{error}</div>}
<input
className="mobile-editor-title"
value={title}
autoComplete="on"
autoCorrect="on"
inputMode="text"
placeholder={DEFAULT_MOBILE_EDITOR_MEMO_TITLE}
onChange={(event) => handleTitleChange(event.target.value)}
/>
<div className="mobile-editor-meta-row">
<MobileEditorNotebookButton
label={currentNotebookLabel}
disabled={!memo || notebookUpdatePending || saveState === "loading" || notebookOptions.length === 0}
onOpen={() => setNotebookSheetOpen(true)}
/>
<input
className="mobile-editor-tags"
value={tagsText}
autoComplete="on"
autoCorrect="on"
inputMode="text"
placeholder="添加标签,用逗号分隔"
onChange={(event) => handleTagsChange(event.target.value)}
/>
</div>
<MobileEditorToolbar
disabled={editorActionDisabled}
boldActive={Boolean(editor?.isActive("bold"))}
bulletListActive={Boolean(editor?.isActive("bulletList"))}
blockquoteActive={Boolean(editor?.isActive("blockquote"))}
onPickImage={() => imageInputRef.current?.click()}
onToggleBold={() => runEditorCommand(() => editor?.chain().focus().toggleBold().run() ?? false)}
onToggleBulletList={() => runEditorCommand(() => editor?.chain().focus().toggleBulletList().run() ?? false)}
onToggleBlockquote={() => runEditorCommand(() => editor?.chain().focus().toggleBlockquote().run() ?? false)}
onSetHorizontalRule={() => runEditorCommand(() => editor?.chain().focus().setHorizontalRule().run() ?? false)}
/>
<input
ref={imageInputRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp,image/avif"
hidden
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
void handleImageUpload(file);
}}
/>
{notebookSheetOpen && (
<MobileEditorNotebookSheet
options={notebookOptions}
selectedNotebookId={memo?.notebookId}
updating={notebookUpdatePending}
onClose={() => setNotebookSheetOpen(false)}
onSelect={(notebookId) => void handleNotebookChange(notebookId)}
/>
)}
<div className="edgeever-mobile-tiptap-editor">
<EditorContent editor={editor} />
</div>
{saveState === "error" && fallbackMarkdown && <MobileEditorFallback markdown={fallbackMarkdown} />}
</main>
</div>
);
};
+3 -633
View File
@@ -1,638 +1,8 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import React from "react";
import { createRoot } from "react-dom/client";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Image from "@tiptap/extension-image";
import Placeholder from "@tiptap/extension-placeholder";
import { docToMarkdown, emptyDoc, type MemoDetail, type Notebook, type TiptapDoc } from "@edgeever/shared";
import {
MobileEditorFallback,
MobileEditorHeader,
MobileEditorNotebookButton,
MobileEditorNotebookSheet,
MobileEditorToolbar,
} from "@/components/MobileStandaloneEditorParts";
import { getNotebookMoveOptions } from "@/lib/app-helpers";
import { compressImageForUpload } from "@/lib/image-compression";
import {
DEFAULT_MOBILE_EDITOR_MEMO_TITLE,
MOBILE_EDITOR_AUTO_SAVE_DELAY_MS,
MOBILE_EDITOR_INITIAL_FOCUS_DELAY_MS,
MOBILE_EDITOR_LEAVE_SAVE_TIMEOUT_MS,
getMobileEditorDraftKey,
getMobileEditorParams,
getMobileEditorSaveLabel,
getMobileEditorStatusClassName,
normalizeMobileEditorDoc,
parseMobileEditorTags,
requestMobileEditorJson,
safeMobileEditorReturnPath,
uploadMobileEditorResource,
type MobileEditorDraft,
type MobileEditorMemoResponse,
type MobileEditorSaveState,
} from "@/lib/mobile-editor-standalone";
import { MobileStandaloneTiptapEditor } from "@/components/MobileStandaloneTiptapEditor";
import "./styles/mobile-markdown-editor.css";
type ListNotebooksResponse = {
notebooks: Notebook[];
};
const MobileTiptapEditorApp = () => {
const params = useMemo(() => getMobileEditorParams(), []);
const memoId = params.get("memoId");
const returnTo = safeMobileEditorReturnPath(params.get("returnTo"));
const draftKey = getMobileEditorDraftKey(memoId);
const [memo, setMemo] = useState<MemoDetail | null>(null);
const memoRef = useRef<MemoDetail | null>(null);
const [notebooks, setNotebooks] = useState<Notebook[]>([]);
const [notebookUpdatePending, setNotebookUpdatePending] = useState(false);
const [notebookSheetOpen, setNotebookSheetOpen] = useState(false);
const [title, setTitle] = useState("");
const titleRef = useRef("");
const [tagsText, setTagsText] = useState("");
const tagsTextRef = useRef("");
const contentJsonRef = useRef<TiptapDoc>(emptyDoc());
const [saveState, setSaveState] = useState<MobileEditorSaveState>("loading");
const saveStateRef = useRef<MobileEditorSaveState>("loading");
const [error, setError] = useState<string | null>(null);
const notebookOptions = useMemo(() => getNotebookMoveOptions(notebooks), [notebooks]);
const [, setToolbarVersion] = useState(0);
const dirtyRef = useRef(false);
const leavingRef = useRef(false);
const savingRef = useRef(false);
const saveTimerRef = useRef<number | null>(null);
const initialFocusTimerRef = useRef<number | null>(null);
const currentSavePromiseRef = useRef<Promise<boolean> | null>(null);
const lastSavedSnapshotRef = useRef("");
const imageInputRef = useRef<HTMLInputElement | null>(null);
const setSaveStateStable = useCallback((nextState: MobileEditorSaveState) => {
if (saveStateRef.current === nextState) {
return;
}
saveStateRef.current = nextState;
setSaveState(nextState);
}, []);
const currentSnapshot = useCallback(
() =>
JSON.stringify({
title: titleRef.current,
tagsText: tagsTextRef.current,
contentJson: contentJsonRef.current,
}),
[]
);
const persistLocalDraft = useCallback(() => {
if (!draftKey) {
return;
}
const draft: MobileEditorDraft = {
title: titleRef.current,
tagsText: tagsTextRef.current,
contentJson: contentJsonRef.current,
updatedAt: new Date().toISOString(),
};
localStorage.setItem(draftKey, JSON.stringify(draft));
}, [draftKey]);
const readLocalDraft = useCallback((): MobileEditorDraft | null => {
if (!draftKey) {
return null;
}
try {
const raw = localStorage.getItem(draftKey);
return raw ? (JSON.parse(raw) as MobileEditorDraft) : null;
} catch {
return null;
}
}, [draftKey]);
const editor = useEditor({
extensions: [
StarterKit,
Image.configure({
allowBase64: false,
inline: false,
}),
Placeholder.configure({
placeholder: "开始记录...",
}),
],
content: emptyDoc(),
editorProps: {
attributes: {
class: "edgeever-mobile-tiptap-content",
autocapitalize: "sentences",
autocomplete: "on",
autocorrect: "on",
inputmode: "text",
spellcheck: "true",
},
},
onUpdate: ({ editor: activeEditor }) => {
contentJsonRef.current = activeEditor.getJSON() as TiptapDoc;
dirtyRef.current = true;
persistLocalDraft();
if (saveStateRef.current !== "dirty") {
setSaveStateStable("dirty");
}
if (saveTimerRef.current !== null) {
window.clearTimeout(saveTimerRef.current);
}
saveTimerRef.current = window.setTimeout(() => {
saveTimerRef.current = null;
void saveNowRef.current();
}, MOBILE_EDITOR_AUTO_SAVE_DELAY_MS);
},
});
const saveNowRef = useRef<({ keepalive }?: { keepalive?: boolean }) => Promise<boolean>>(async () => false);
useEffect(() => {
if (!editor) {
return;
}
const refreshToolbar = () => setToolbarVersion((version) => version + 1);
editor.on("selectionUpdate", refreshToolbar);
editor.on("transaction", refreshToolbar);
editor.on("focus", refreshToolbar);
editor.on("blur", refreshToolbar);
return () => {
editor.off("selectionUpdate", refreshToolbar);
editor.off("transaction", refreshToolbar);
editor.off("focus", refreshToolbar);
editor.off("blur", refreshToolbar);
};
}, [editor]);
useEffect(() => {
memoRef.current = memo;
}, [memo]);
useEffect(() => {
titleRef.current = title;
}, [title]);
useEffect(() => {
tagsTextRef.current = tagsText;
}, [tagsText]);
const saveNow = useCallback(
async ({ keepalive = false }: { keepalive?: boolean } = {}) => {
const currentMemo = memoRef.current;
if (!currentMemo) {
return false;
}
if (savingRef.current) {
return currentSavePromiseRef.current ?? false;
}
const snapshot = currentSnapshot();
if (!dirtyRef.current && snapshot === lastSavedSnapshotRef.current) {
return true;
}
if (saveTimerRef.current !== null) {
window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
savingRef.current = true;
setSaveStateStable("saving");
setError(null);
currentSavePromiseRef.current = (async () => {
const nextContentJson = contentJsonRef.current;
const data = await requestMobileEditorJson<MobileEditorMemoResponse>(`/api/v1/memos/${encodeURIComponent(currentMemo.id)}`, {
method: "PATCH",
keepalive,
body: JSON.stringify({
expectedRevision: currentMemo.revision,
title: titleRef.current,
contentJson: nextContentJson,
tags: parseMobileEditorTags(tagsTextRef.current),
}),
});
memoRef.current = data.memo;
setMemo(data.memo);
lastSavedSnapshotRef.current = currentSnapshot();
dirtyRef.current = false;
if (draftKey) {
localStorage.removeItem(draftKey);
}
setSaveStateStable("saved");
window.setTimeout(() => {
if (!dirtyRef.current && !savingRef.current && !leavingRef.current) {
setSaveStateStable("idle");
}
}, 1200);
return true;
})();
try {
return await currentSavePromiseRef.current;
} catch (saveError) {
persistLocalDraft();
setError(saveError instanceof Error ? saveError.message : "保存失败,已保留本地草稿");
setSaveStateStable("error");
return false;
} finally {
savingRef.current = false;
currentSavePromiseRef.current = null;
}
},
[currentSnapshot, draftKey, persistLocalDraft, setSaveStateStable]
);
useEffect(() => {
saveNowRef.current = saveNow;
}, [saveNow]);
const scheduleMetadataSave = useCallback(() => {
dirtyRef.current = true;
persistLocalDraft();
setSaveStateStable("dirty");
if (saveTimerRef.current !== null) {
window.clearTimeout(saveTimerRef.current);
}
saveTimerRef.current = window.setTimeout(() => {
saveTimerRef.current = null;
void saveNow();
}, MOBILE_EDITOR_AUTO_SAVE_DELAY_MS);
}, [persistLocalDraft, saveNow, setSaveStateStable]);
const navigateBack = useCallback(() => {
window.location.replace(returnTo);
}, [returnTo]);
const leavePage = useCallback(async () => {
if (leavingRef.current) {
return;
}
leavingRef.current = true;
persistLocalDraft();
if (saveTimerRef.current !== null) {
window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
setSaveStateStable("leaving");
await Promise.race([
saveNow({ keepalive: true }),
new Promise((resolve) => window.setTimeout(resolve, MOBILE_EDITOR_LEAVE_SAVE_TIMEOUT_MS)),
]);
navigateBack();
}, [navigateBack, persistLocalDraft, saveNow, setSaveStateStable]);
const handleTitleChange = (nextTitle: string) => {
setTitle(nextTitle);
titleRef.current = nextTitle;
scheduleMetadataSave();
};
const handleTagsChange = (nextTagsText: string) => {
setTagsText(nextTagsText);
tagsTextRef.current = nextTagsText;
scheduleMetadataSave();
};
const handleNotebookChange = async (nextNotebookId: string) => {
const currentMemo = memoRef.current;
if (!currentMemo || !nextNotebookId || nextNotebookId === currentMemo.notebookId || notebookUpdatePending) {
return;
}
setNotebookUpdatePending(true);
setSaveStateStable("saving");
setError(null);
try {
if (dirtyRef.current) {
const saved = await saveNow();
if (!saved) {
return;
}
}
const sourceMemo = memoRef.current;
if (!sourceMemo || nextNotebookId === sourceMemo.notebookId) {
return;
}
const data = await requestMobileEditorJson<MobileEditorMemoResponse>(`/api/v1/memos/${encodeURIComponent(sourceMemo.id)}`, {
method: "PATCH",
body: JSON.stringify({
expectedRevision: sourceMemo.revision,
notebookId: nextNotebookId,
}),
});
memoRef.current = data.memo;
setMemo(data.memo);
setSaveStateStable("saved");
window.setTimeout(() => {
if (!dirtyRef.current && !savingRef.current && !leavingRef.current) {
setSaveStateStable("idle");
}
}, 1200);
} catch (notebookError) {
setError(notebookError instanceof Error ? notebookError.message : "切换笔记本失败");
setSaveStateStable("error");
} finally {
setNotebookUpdatePending(false);
setNotebookSheetOpen(false);
}
};
const handleImageUpload = async (file?: File | null) => {
const currentMemo = memoRef.current;
if (!currentMemo || !editor || !file) {
return;
}
setError(null);
setSaveStateStable("compressing");
try {
const uploadFile = (await compressImageForUpload(file)).file;
setSaveStateStable("uploading");
const { resource } = await uploadMobileEditorResource(currentMemo.id, uploadFile);
editor
.chain()
.focus()
.setImage({
src: resource.url,
alt: file.name,
title: file.name,
})
.run();
} catch (uploadError) {
setError(uploadError instanceof Error ? uploadError.message : "图片上传失败");
setSaveStateStable("error");
}
};
const focusEditorAfterLoad = useCallback(() => {
if (!editor) {
return;
}
if (initialFocusTimerRef.current !== null) {
window.clearTimeout(initialFocusTimerRef.current);
}
initialFocusTimerRef.current = window.setTimeout(() => {
initialFocusTimerRef.current = null;
if (leavingRef.current || editor.isDestroyed) {
return;
}
const activeElement = document.activeElement;
if (activeElement && activeElement !== document.body && activeElement !== document.documentElement) {
return;
}
editor.commands.focus("end");
}, MOBILE_EDITOR_INITIAL_FOCUS_DELAY_MS);
}, [editor]);
useEffect(() => {
let cancelled = false;
void requestMobileEditorJson<ListNotebooksResponse>("/api/v1/notebooks")
.then((data) => {
if (!cancelled) {
setNotebooks(data.notebooks);
}
})
.catch(() => {
if (!cancelled) {
setNotebooks([]);
}
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!memoId || !editor) {
if (!memoId) {
setError("缺少 memoId");
setSaveStateStable("error");
}
return;
}
let cancelled = false;
void (async () => {
try {
const data = await requestMobileEditorJson<MobileEditorMemoResponse>(`/api/v1/memos/${encodeURIComponent(memoId)}`);
if (cancelled) {
return;
}
const nextTitle = data.memo.title || "";
const nextTagsText = Array.isArray(data.memo.tags) ? data.memo.tags.join(", ") : "";
const nextContentJson = normalizeMobileEditorDoc(data.memo);
const draft = readLocalDraft();
const useDraft = Boolean(draft && Date.parse(draft.updatedAt || "") >= Date.parse(data.memo.updatedAt || ""));
setMemo(data.memo);
if (useDraft && draft) {
setTitle(draft.title || "");
titleRef.current = draft.title || "";
setTagsText(draft.tagsText || "");
tagsTextRef.current = draft.tagsText || "";
contentJsonRef.current = draft.contentJson || emptyDoc();
editor.commands.setContent(contentJsonRef.current, { emitUpdate: false });
dirtyRef.current = true;
setSaveStateStable("local-draft");
scheduleMetadataSave();
focusEditorAfterLoad();
} else {
setTitle(nextTitle);
titleRef.current = nextTitle;
setTagsText(nextTagsText);
tagsTextRef.current = nextTagsText;
contentJsonRef.current = nextContentJson;
editor.commands.setContent(nextContentJson, { emitUpdate: false });
lastSavedSnapshotRef.current = JSON.stringify({
title: nextTitle,
tagsText: nextTagsText,
contentJson: nextContentJson,
});
dirtyRef.current = false;
setSaveStateStable("idle");
focusEditorAfterLoad();
}
} catch (loadError) {
if (cancelled) {
return;
}
setError(loadError instanceof Error ? loadError.message : "加载失败");
setSaveStateStable("error");
}
})();
return () => {
cancelled = true;
if (initialFocusTimerRef.current !== null) {
window.clearTimeout(initialFocusTimerRef.current);
initialFocusTimerRef.current = null;
}
};
}, [editor, focusEditorAfterLoad, memoId, readLocalDraft, scheduleMetadataSave, setSaveStateStable]);
useEffect(() => {
window.history.replaceState({ edgeeverMobileEditor: true }, "");
window.history.pushState({ edgeeverMobileEditorBackGuard: true }, "");
const handlePopState = () => {
void leavePage();
};
const handlePageHide = () => {
if (dirtyRef.current) {
persistLocalDraft();
void saveNow({ keepalive: true });
}
};
const handleVisibilityChange = () => {
if (document.visibilityState === "hidden" && dirtyRef.current) {
persistLocalDraft();
void saveNow({ keepalive: true });
}
};
window.addEventListener("popstate", handlePopState);
window.addEventListener("pagehide", handlePageHide);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.removeEventListener("popstate", handlePopState);
window.removeEventListener("pagehide", handlePageHide);
document.removeEventListener("visibilitychange", handleVisibilityChange);
if (saveTimerRef.current !== null) {
window.clearTimeout(saveTimerRef.current);
}
if (initialFocusTimerRef.current !== null) {
window.clearTimeout(initialFocusTimerRef.current);
}
};
}, [leavePage, persistLocalDraft, saveNow]);
const saveLabel = getMobileEditorSaveLabel(saveState);
const statusClassName = getMobileEditorStatusClassName(saveState);
const editorActionDisabled =
!memo || !editor || saveState === "loading" || saveState === "compressing" || saveState === "uploading" || saveState === "leaving";
const currentNotebookLabel =
notebookOptions.find((notebook) => notebook.id === memo?.notebookId)?.name ?? (notebookOptions.length === 0 ? "等待分类" : "笔记本");
const fallbackMarkdown = memo ? docToMarkdown(contentJsonRef.current) : "";
const runEditorCommand = (command: () => boolean) => {
if (editorActionDisabled || !editor) {
return;
}
command();
editor.commands.focus();
};
return (
<div className="mobile-editor-shell">
<MobileEditorHeader saveLabel={saveLabel} statusClassName={statusClassName} saveState={saveState} onLeave={() => void leavePage()} />
<main className="mobile-editor-main">
{error && <div className="mobile-editor-error">{error}</div>}
<input
className="mobile-editor-title"
value={title}
autoComplete="on"
autoCorrect="on"
inputMode="text"
placeholder={DEFAULT_MOBILE_EDITOR_MEMO_TITLE}
onChange={(event) => handleTitleChange(event.target.value)}
/>
<div className="mobile-editor-meta-row">
<MobileEditorNotebookButton
label={currentNotebookLabel}
disabled={!memo || notebookUpdatePending || saveState === "loading" || notebookOptions.length === 0}
onOpen={() => setNotebookSheetOpen(true)}
/>
<input
className="mobile-editor-tags"
value={tagsText}
autoComplete="on"
autoCorrect="on"
inputMode="text"
placeholder="添加标签,用逗号分隔"
onChange={(event) => handleTagsChange(event.target.value)}
/>
</div>
<MobileEditorToolbar
disabled={editorActionDisabled}
boldActive={Boolean(editor?.isActive("bold"))}
bulletListActive={Boolean(editor?.isActive("bulletList"))}
blockquoteActive={Boolean(editor?.isActive("blockquote"))}
onPickImage={() => imageInputRef.current?.click()}
onToggleBold={() => runEditorCommand(() => editor?.chain().focus().toggleBold().run() ?? false)}
onToggleBulletList={() => runEditorCommand(() => editor?.chain().focus().toggleBulletList().run() ?? false)}
onToggleBlockquote={() => runEditorCommand(() => editor?.chain().focus().toggleBlockquote().run() ?? false)}
onSetHorizontalRule={() => runEditorCommand(() => editor?.chain().focus().setHorizontalRule().run() ?? false)}
/>
<input
ref={imageInputRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp,image/avif"
hidden
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
void handleImageUpload(file);
}}
/>
{notebookSheetOpen && (
<MobileEditorNotebookSheet
options={notebookOptions}
selectedNotebookId={memo?.notebookId}
updating={notebookUpdatePending}
onClose={() => setNotebookSheetOpen(false)}
onSelect={(notebookId) => void handleNotebookChange(notebookId)}
/>
)}
<div className="edgeever-mobile-tiptap-editor">
<EditorContent editor={editor} />
</div>
{saveState === "error" && fallbackMarkdown && <MobileEditorFallback markdown={fallbackMarkdown} />}
</main>
</div>
);
};
const root = document.getElementById("mobile-editor-root");
if (!root) {
@@ -641,6 +11,6 @@ if (!root) {
createRoot(root).render(
<React.StrictMode>
<MobileTiptapEditorApp />
<MobileStandaloneTiptapEditor />
</React.StrictMode>
);