Revert "Remove standalone mobile editor entry"
This reverts commit 6c6954ccc9.
This commit is contained in:
1 parent
6c6954ccc9
commit
cbc56af7c7
4 files changed
+1255
-1
No files matched your search
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content"
|
||||
/>
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<title>编辑笔记 - EdgeEver</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="mobile-editor-root"></div>
|
||||
<script type="module" src="/src/mobile-edit.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,827 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } 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 { Bold, Check, ChevronDown, ImagePlus, List, Minus, Quote } from "lucide-react";
|
||||
import { docToMarkdown, emptyDoc, markdownToDoc, type MemoDetail, type Notebook, type Resource, type TiptapDoc } from "@edgeever/shared";
|
||||
import { getNotebookMoveOptions } from "@/lib/app-helpers";
|
||||
import { compressImageForUpload } from "@/lib/image-compression";
|
||||
import "./styles/mobile-markdown-editor.css";
|
||||
|
||||
const AUTO_SAVE_DELAY_MS = 1200;
|
||||
const LEAVE_SAVE_TIMEOUT_MS = 1600;
|
||||
const INITIAL_EDITOR_FOCUS_DELAY_MS = 160;
|
||||
const DRAFT_STORAGE_PREFIX = "edgeever-mobile-tiptap-draft:";
|
||||
const DEFAULT_MEMO_TITLE = "无标题笔记";
|
||||
|
||||
type MemoResponse = {
|
||||
memo: MemoDetail;
|
||||
};
|
||||
|
||||
type ListNotebooksResponse = {
|
||||
notebooks: Notebook[];
|
||||
};
|
||||
|
||||
type ResourceResponse = {
|
||||
resource: Resource;
|
||||
};
|
||||
|
||||
type MobileDraft = {
|
||||
title: string;
|
||||
tagsText: string;
|
||||
contentJson: TiptapDoc;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type SaveState = "loading" | "idle" | "dirty" | "saving" | "saved" | "compressing" | "uploading" | "error" | "local-draft" | "leaving";
|
||||
|
||||
const getParams = () => new URLSearchParams(window.location.hash ? window.location.hash.slice(1) : window.location.search);
|
||||
|
||||
const parseTags = (value: string) =>
|
||||
value
|
||||
.split(/[,,]/)
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const safeReturnPath = (value: string | null) => (value?.startsWith("/") ? value : "/");
|
||||
|
||||
const requestJson = async <T,>(path: string, init?: RequestInit): Promise<T> => {
|
||||
const headers = new Headers(init?.headers);
|
||||
const isFormData = init?.body instanceof FormData;
|
||||
|
||||
if (!isFormData && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
const response = await fetch(path, {
|
||||
credentials: "include",
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => null);
|
||||
const message =
|
||||
body && typeof body === "object" && "error" in body
|
||||
? (body as { error?: { message?: string } }).error?.message
|
||||
: response.statusText;
|
||||
throw new Error(message || "Request failed");
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
};
|
||||
|
||||
const uploadResource = async (memoId: string, file: File) => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
|
||||
return requestJson<ResourceResponse>(`/api/v1/memos/${encodeURIComponent(memoId)}/resources`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
};
|
||||
|
||||
const normalizeDoc = (memo: MemoDetail): TiptapDoc => {
|
||||
if (memo.contentJson && typeof memo.contentJson === "object") {
|
||||
return memo.contentJson as TiptapDoc;
|
||||
}
|
||||
|
||||
if (memo.contentMarkdown) {
|
||||
return markdownToDoc(memo.contentMarkdown);
|
||||
}
|
||||
|
||||
return emptyDoc();
|
||||
};
|
||||
|
||||
const MobileTiptapEditorApp = () => {
|
||||
const params = useMemo(() => getParams(), []);
|
||||
const memoId = params.get("memoId");
|
||||
const returnTo = safeReturnPath(params.get("returnTo"));
|
||||
const draftKey = memoId ? `${DRAFT_STORAGE_PREFIX}${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<SaveState>("loading");
|
||||
const saveStateRef = useRef<SaveState>("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: SaveState) => {
|
||||
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: MobileDraft = {
|
||||
title: titleRef.current,
|
||||
tagsText: tagsTextRef.current,
|
||||
contentJson: contentJsonRef.current,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
localStorage.setItem(draftKey, JSON.stringify(draft));
|
||||
}, [draftKey]);
|
||||
|
||||
const readLocalDraft = useCallback((): MobileDraft | null => {
|
||||
if (!draftKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(draftKey);
|
||||
return raw ? (JSON.parse(raw) as MobileDraft) : 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();
|
||||
}, 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 requestJson<MemoResponse>(`/api/v1/memos/${encodeURIComponent(currentMemo.id)}`, {
|
||||
method: "PATCH",
|
||||
keepalive,
|
||||
body: JSON.stringify({
|
||||
expectedRevision: currentMemo.revision,
|
||||
title: titleRef.current,
|
||||
contentJson: nextContentJson,
|
||||
tags: parseTags(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();
|
||||
}, 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, 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 requestJson<MemoResponse>(`/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 uploadResource(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");
|
||||
}, INITIAL_EDITOR_FOCUS_DELAY_MS);
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void requestJson<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 requestJson<MemoResponse>(`/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 = normalizeDoc(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 =
|
||||
saveState === "loading"
|
||||
? "加载中"
|
||||
: saveState === "saving"
|
||||
? "保存中"
|
||||
: saveState === "compressing"
|
||||
? "压缩中"
|
||||
: saveState === "uploading"
|
||||
? "上传中"
|
||||
: saveState === "dirty"
|
||||
? "未保存"
|
||||
: saveState === "saved"
|
||||
? "已保存"
|
||||
: saveState === "local-draft"
|
||||
? "本地草稿"
|
||||
: saveState === "leaving"
|
||||
? "返回中"
|
||||
: saveState === "error"
|
||||
? "保存失败"
|
||||
: "已保存";
|
||||
|
||||
const statusClassName =
|
||||
saveState === "error"
|
||||
? "error"
|
||||
: saveState === "dirty" || saveState === "saving" || saveState === "compressing" || saveState === "uploading" || saveState === "leaving"
|
||||
? "active"
|
||||
: "";
|
||||
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">
|
||||
<header className="mobile-editor-header">
|
||||
<button className="mobile-editor-back" type="button" aria-label="返回" onClick={() => void leavePage()}>
|
||||
‹
|
||||
</button>
|
||||
<div className="mobile-editor-actions">
|
||||
<span className={`mobile-editor-status ${statusClassName}`}>{saveLabel}</span>
|
||||
<button className="mobile-editor-done" type="button" disabled={saveState === "loading"} onClick={() => void leavePage()}>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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_MEMO_TITLE}
|
||||
onChange={(event) => handleTitleChange(event.target.value)}
|
||||
/>
|
||||
<div className="mobile-editor-meta-row">
|
||||
<button
|
||||
className="mobile-editor-notebook-button"
|
||||
type="button"
|
||||
aria-label="所在笔记本"
|
||||
disabled={!memo || notebookUpdatePending || saveState === "loading" || notebookOptions.length === 0}
|
||||
onClick={() => setNotebookSheetOpen(true)}
|
||||
>
|
||||
<span>{currentNotebookLabel}</span>
|
||||
<ChevronDown aria-hidden="true" size={14} strokeWidth={2.2} />
|
||||
</button>
|
||||
<input
|
||||
className="mobile-editor-tags"
|
||||
value={tagsText}
|
||||
autoComplete="on"
|
||||
autoCorrect="on"
|
||||
inputMode="text"
|
||||
placeholder="添加标签,用逗号分隔"
|
||||
onChange={(event) => handleTagsChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mobile-editor-tool-row">
|
||||
<button
|
||||
className="mobile-editor-tool-button"
|
||||
type="button"
|
||||
aria-label="上传图片"
|
||||
title="上传图片"
|
||||
disabled={editorActionDisabled}
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
>
|
||||
<ImagePlus aria-hidden="true" size={18} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
className="mobile-editor-tool-button"
|
||||
type="button"
|
||||
aria-label="加粗"
|
||||
title="加粗"
|
||||
aria-pressed={Boolean(editor?.isActive("bold"))}
|
||||
disabled={editorActionDisabled}
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
onClick={() => runEditorCommand(() => editor?.chain().focus().toggleBold().run() ?? false)}
|
||||
>
|
||||
<Bold aria-hidden="true" size={17} strokeWidth={2.4} />
|
||||
</button>
|
||||
<button
|
||||
className="mobile-editor-tool-button"
|
||||
type="button"
|
||||
aria-label="无序列表"
|
||||
title="无序列表"
|
||||
aria-pressed={Boolean(editor?.isActive("bulletList"))}
|
||||
disabled={editorActionDisabled}
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
onClick={() => runEditorCommand(() => editor?.chain().focus().toggleBulletList().run() ?? false)}
|
||||
>
|
||||
<List aria-hidden="true" size={18} strokeWidth={2.2} />
|
||||
</button>
|
||||
<button
|
||||
className="mobile-editor-tool-button"
|
||||
type="button"
|
||||
aria-label="引用"
|
||||
title="引用"
|
||||
aria-pressed={Boolean(editor?.isActive("blockquote"))}
|
||||
disabled={editorActionDisabled}
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
onClick={() => runEditorCommand(() => editor?.chain().focus().toggleBlockquote().run() ?? false)}
|
||||
>
|
||||
<Quote aria-hidden="true" size={17} strokeWidth={2.2} />
|
||||
</button>
|
||||
<button
|
||||
className="mobile-editor-tool-button"
|
||||
type="button"
|
||||
aria-label="分割线"
|
||||
title="分割线"
|
||||
disabled={editorActionDisabled}
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
onClick={() => runEditorCommand(() => editor?.chain().focus().setHorizontalRule().run() ?? false)}
|
||||
>
|
||||
<Minus aria-hidden="true" size={18} strokeWidth={2.4} />
|
||||
</button>
|
||||
</div>
|
||||
<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 && (
|
||||
<div className="mobile-editor-sheet-backdrop" role="presentation" onClick={() => setNotebookSheetOpen(false)}>
|
||||
<section
|
||||
className="mobile-editor-notebook-sheet"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="选择笔记本"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="mobile-editor-notebook-sheet-handle" aria-hidden="true" />
|
||||
<div className="mobile-editor-notebook-sheet-header">
|
||||
<h2>所在笔记本</h2>
|
||||
<button type="button" onClick={() => setNotebookSheetOpen(false)}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<div className="mobile-editor-notebook-list">
|
||||
{notebookOptions.map((notebook) => {
|
||||
const selected = notebook.id === memo?.notebookId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={notebook.id}
|
||||
className="mobile-editor-notebook-option"
|
||||
type="button"
|
||||
aria-current={selected ? "page" : undefined}
|
||||
disabled={notebookUpdatePending}
|
||||
style={{ paddingLeft: `${16 + notebook.depth * 18}px` }}
|
||||
onClick={() => void handleNotebookChange(notebook.id)}
|
||||
>
|
||||
<span>{notebook.name}</span>
|
||||
{selected && <Check aria-hidden="true" size={16} strokeWidth={2.4} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="edgeever-mobile-tiptap-editor">
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
|
||||
{saveState === "error" && fallbackMarkdown && (
|
||||
<details className="mobile-editor-fallback">
|
||||
<summary>查看当前正文 Markdown 备份</summary>
|
||||
<pre>{fallbackMarkdown}</pre>
|
||||
</details>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const root = document.getElementById("mobile-editor-root");
|
||||
|
||||
if (!root) {
|
||||
throw new Error("Mobile editor root not found");
|
||||
}
|
||||
|
||||
createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<MobileTiptapEditorApp />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,404 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#mobile-editor-root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.mobile-editor-shell {
|
||||
min-height: 100dvh;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.mobile-editor-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
min-height: 52px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
background: #fff;
|
||||
padding: max(8px, env(safe-area-inset-top)) 12px 8px;
|
||||
}
|
||||
|
||||
.mobile-editor-back,
|
||||
.mobile-editor-done,
|
||||
.mobile-editor-tool-button {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
min-height: 36px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
|
||||
.mobile-editor-back {
|
||||
min-width: 38px;
|
||||
padding: 4px 8px;
|
||||
font-size: 30px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mobile-editor-actions {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mobile-editor-done {
|
||||
background: #020617;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.mobile-editor-done:disabled,
|
||||
.mobile-editor-tool-button:disabled {
|
||||
background: #e2e8f0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.mobile-editor-status {
|
||||
align-self: center;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 6px 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobile-editor-status.active {
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.mobile-editor-status.error {
|
||||
background: #fff1f2;
|
||||
color: #be123c;
|
||||
}
|
||||
|
||||
.mobile-editor-main {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 14px 12px max(16px, env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.mobile-editor-error {
|
||||
border: 1px solid #fecdd3;
|
||||
border-radius: 8px;
|
||||
background: #fff1f2;
|
||||
color: #9f1239;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.mobile-editor-title,
|
||||
.mobile-editor-tags {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.mobile-editor-title {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.mobile-editor-tags {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
color: #64748b;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.mobile-editor-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-editor-notebook-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
flex: 0 1 auto;
|
||||
max-width: 46%;
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
padding: 0 5px 0 0;
|
||||
}
|
||||
|
||||
.mobile-editor-notebook-button span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobile-editor-notebook-button:active {
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.mobile-editor-notebook-button:disabled {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.mobile-editor-title::placeholder,
|
||||
.mobile-editor-tags::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.mobile-editor-tool-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 38px;
|
||||
overflow-x: auto;
|
||||
border-block: 1px solid #f1f5f9;
|
||||
margin-inline: -12px;
|
||||
padding: 6px 12px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.mobile-editor-tool-row::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-editor-tool-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
min-height: 32px;
|
||||
width: 36px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mobile-editor-tool-button:active,
|
||||
.mobile-editor-tool-button[aria-pressed="true"] {
|
||||
background: #ecfdf5;
|
||||
border-color: #bbf7d0;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.mobile-editor-tool-button:disabled {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.mobile-editor-tool-button:disabled:active {
|
||||
background: #e2e8f0;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.mobile-editor-sheet-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
background: rgb(15 23 42 / 0.22);
|
||||
}
|
||||
|
||||
.mobile-editor-notebook-sheet {
|
||||
width: 100%;
|
||||
max-height: 68dvh;
|
||||
overflow: hidden;
|
||||
border-radius: 18px 18px 0 0;
|
||||
background: #fff;
|
||||
box-shadow: 0 -18px 45px rgb(15 23 42 / 0.16);
|
||||
padding: 8px 0 max(10px, env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.mobile-editor-notebook-sheet-handle {
|
||||
width: 38px;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: #cbd5e1;
|
||||
margin: 2px auto 8px;
|
||||
}
|
||||
|
||||
.mobile-editor-notebook-sheet-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
padding: 0 16px 10px;
|
||||
}
|
||||
|
||||
.mobile-editor-notebook-sheet-header h2 {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 16px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.mobile-editor-notebook-sheet-header button {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.mobile-editor-notebook-list {
|
||||
max-height: calc(68dvh - 4.5rem - env(safe-area-inset-bottom));
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.mobile-editor-notebook-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: #334155;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mobile-editor-notebook-option span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobile-editor-notebook-option[aria-current="page"] {
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.mobile-editor-notebook-option:active {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.edgeever-mobile-tiptap-editor {
|
||||
min-height: calc(100dvh - 14rem);
|
||||
margin: 0 -12px;
|
||||
}
|
||||
|
||||
.edgeever-mobile-tiptap-content {
|
||||
min-height: calc(100dvh - 14rem);
|
||||
outline: none;
|
||||
padding: 18px 12px 32px;
|
||||
color: #0f172a;
|
||||
font-size: 17px;
|
||||
line-height: 1.7;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
caret-color: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.edgeever-mobile-tiptap-content p {
|
||||
margin: 0 0 0.85em;
|
||||
}
|
||||
|
||||
.edgeever-mobile-tiptap-content h1,
|
||||
.edgeever-mobile-tiptap-content h2,
|
||||
.edgeever-mobile-tiptap-content h3 {
|
||||
line-height: 1.25;
|
||||
margin: 0.9em 0 0.45em;
|
||||
}
|
||||
|
||||
.edgeever-mobile-tiptap-content ul,
|
||||
.edgeever-mobile-tiptap-content ol {
|
||||
margin: 0 0 0.85em;
|
||||
padding-left: 1.4em;
|
||||
}
|
||||
|
||||
.edgeever-mobile-tiptap-content blockquote {
|
||||
border-left: 3px solid #cbd5e1;
|
||||
color: #475569;
|
||||
margin: 0 0 0.85em;
|
||||
padding-left: 0.9em;
|
||||
}
|
||||
|
||||
.edgeever-mobile-tiptap-content hr {
|
||||
border: 0;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
margin: 1.2rem 0;
|
||||
}
|
||||
|
||||
.edgeever-mobile-tiptap-content img {
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.edgeever-mobile-tiptap-content p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
color: #94a3b8;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mobile-editor-fallback {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.mobile-editor-fallback pre {
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -73,7 +73,7 @@ export default defineConfig({
|
||||
workbox: {
|
||||
globPatterns: ["**/*.{js,css,html,ico,png,svg,webp,woff2}"],
|
||||
navigateFallback: "/index.html",
|
||||
navigateFallbackDenylist: [/^\/api\//, /^\/mcp\//, /^\/tiptap-ime-test\.html$/],
|
||||
navigateFallbackDenylist: [/^\/api\//, /^\/mcp\//, /^\/mobile-edit\.html$/, /^\/tiptap-ime-test\.html$/],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: ({ url }) => url.pathname.startsWith("/api/") || url.pathname.startsWith("/mcp/"),
|
||||
@@ -106,6 +106,7 @@ export default defineConfig({
|
||||
rolldownOptions: {
|
||||
input: {
|
||||
app: fileURLToPath(new URL("./index.html", import.meta.url)),
|
||||
"mobile-edit": fileURLToPath(new URL("./mobile-edit.html", import.meta.url)),
|
||||
"tiptap-ime-test": fileURLToPath(new URL("./tiptap-ime-test.html", import.meta.url)),
|
||||
},
|
||||
output: {
|
||||
|
||||
Reference in new issue
Block a user