Revert "Remove standalone mobile editor entry"

This reverts commit 6c6954ccc9.
This commit is contained in:
yoma committed 2026-07-08 01:50:57 +08:00
1 parent 6c6954ccc9
commit cbc56af7c7
4 files changed
+1255 -1

No files matched your search

+22
View File
@@ -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>
+827
View File
@@ -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;
}
+2 -1
View File
@@ -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: {