Move standalone Tiptap editor into reusable component
This commit is contained in:
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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
Reference in new issue
Block a user