From cbc56af7c7aa2126992f8afec0c188ba8f2613d4 Mon Sep 17 00:00:00 2001 From: yoma Date: Wed, 8 Jul 2026 01:50:57 +0800 Subject: [PATCH] Revert "Remove standalone mobile editor entry" This reverts commit 6c6954ccc9e957df9a02b2410dcc3645bd64ec5e. --- apps/web/mobile-edit.html | 22 + apps/web/src/mobile-edit.tsx | 827 ++++++++++++++++++ .../web/src/styles/mobile-markdown-editor.css | 404 +++++++++ apps/web/vite.config.ts | 3 +- 4 files changed, 1255 insertions(+), 1 deletion(-) create mode 100644 apps/web/mobile-edit.html create mode 100644 apps/web/src/mobile-edit.tsx create mode 100644 apps/web/src/styles/mobile-markdown-editor.css 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 ( +
+
+ +
+ {saveLabel} + +
+
+ +
+ {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()} + > +
+
+ )} + +
+ +
+ + {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: {