diff --git a/apps/web/src/components/MobileStandaloneTiptapEditor.tsx b/apps/web/src/components/MobileStandaloneTiptapEditor.tsx new file mode 100644 index 00000000..83eb0c4a --- /dev/null +++ b/apps/web/src/components/MobileStandaloneTiptapEditor.tsx @@ -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(null); + const memoRef = useRef(null); + const [notebooks, setNotebooks] = useState([]); + 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(emptyDoc()); + const [saveState, setSaveState] = useState("loading"); + const saveStateRef = useRef("loading"); + const [error, setError] = useState(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(null); + const initialFocusTimerRef = useRef(null); + const currentSavePromiseRef = useRef | null>(null); + const lastSavedSnapshotRef = useRef(""); + const imageInputRef = useRef(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>(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(`/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(`/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("/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(`/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 ( +
+ void leavePage()} /> + +
+ {error &&
{error}
} + handleTitleChange(event.target.value)} + /> +
+ setNotebookSheetOpen(true)} + /> + handleTagsChange(event.target.value)} + /> +
+ + 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)} + /> + { + const file = event.target.files?.[0]; + event.target.value = ""; + void handleImageUpload(file); + }} + /> + + {notebookSheetOpen && ( + setNotebookSheetOpen(false)} + onSelect={(notebookId) => void handleNotebookChange(notebookId)} + /> + )} + +
+ +
+ + {saveState === "error" && fallbackMarkdown && } +
+
+ ); +}; diff --git a/apps/web/src/mobile-edit.tsx b/apps/web/src/mobile-edit.tsx index 23fda3d5..a37b0696 100644 --- a/apps/web/src/mobile-edit.tsx +++ b/apps/web/src/mobile-edit.tsx @@ -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(null); - const memoRef = useRef(null); - const [notebooks, setNotebooks] = useState([]); - 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(emptyDoc()); - const [saveState, setSaveState] = useState("loading"); - const saveStateRef = useRef("loading"); - const [error, setError] = useState(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(null); - const initialFocusTimerRef = useRef(null); - const currentSavePromiseRef = useRef | null>(null); - const lastSavedSnapshotRef = useRef(""); - const imageInputRef = useRef(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>(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(`/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(`/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("/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(`/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 ( -
- void leavePage()} /> - -
- {error &&
{error}
} - handleTitleChange(event.target.value)} - /> -
- setNotebookSheetOpen(true)} - /> - handleTagsChange(event.target.value)} - /> -
- - 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)} - /> - { - const file = event.target.files?.[0]; - event.target.value = ""; - void handleImageUpload(file); - }} - /> - - {notebookSheetOpen && ( - setNotebookSheetOpen(false)} - onSelect={(notebookId) => void handleNotebookChange(notebookId)} - /> - )} - -
- -
- - {saveState === "error" && fallbackMarkdown && } -
-
- ); -}; - const root = document.getElementById("mobile-editor-root"); if (!root) { @@ -641,6 +11,6 @@ if (!root) { createRoot(root).render( - + );