diff --git a/apps/web/mobile-edit.html b/apps/web/mobile-edit.html
new file mode 100644
index 00000000..6ca716be
--- /dev/null
+++ b/apps/web/mobile-edit.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ 编辑笔记 - EdgeEver
+
+
+
+
+
+
diff --git a/apps/web/src/mobile-edit.tsx b/apps/web/src/mobile-edit.tsx
new file mode 100644
index 00000000..f945f9a1
--- /dev/null
+++ b/apps/web/src/mobile-edit.tsx
@@ -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 (path: string, init?: RequestInit): Promise => {
+ 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;
+};
+
+const uploadResource = async (memoId: string, file: File) => {
+ const form = new FormData();
+ form.append("file", file);
+
+ return requestJson(`/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(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: 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>(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(`/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(`/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("/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(`/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 (
+
+
+
+
+ {error && {error}
}
+ handleTitleChange(event.target.value)}
+ />
+
+
+ handleTagsChange(event.target.value)}
+ />
+
+
+
+
+
+
+
+
+
+ {
+ const file = event.target.files?.[0];
+ event.target.value = "";
+ void handleImageUpload(file);
+ }}
+ />
+
+ {notebookSheetOpen && (
+ setNotebookSheetOpen(false)}>
+
event.stopPropagation()}
+ >
+
+
+
所在笔记本
+
+
+
+ {notebookOptions.map((notebook) => {
+ const selected = notebook.id === memo?.notebookId;
+
+ return (
+
+ );
+ })}
+
+
+
+ )}
+
+
+
+
+
+ {saveState === "error" && fallbackMarkdown && (
+
+ 查看当前正文 Markdown 备份
+ {fallbackMarkdown}
+
+ )}
+
+
+ );
+};
+
+const root = document.getElementById("mobile-editor-root");
+
+if (!root) {
+ throw new Error("Mobile editor root not found");
+}
+
+createRoot(root).render(
+
+
+
+);
diff --git a/apps/web/src/styles/mobile-markdown-editor.css b/apps/web/src/styles/mobile-markdown-editor.css
new file mode 100644
index 00000000..6a81bc19
--- /dev/null
+++ b/apps/web/src/styles/mobile-markdown-editor.css
@@ -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;
+}
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index cdef1045..193a4c26 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -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: {