diff --git a/README.md b/README.md index dfd8c626..38bc7611 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ The public demo resets every day at 3:00 AM (China Standard Time) and restores s - **Lossless ZIP Backup & Portability**: Export your complete library as a clean archive containing Markdown, Front Matter, nested folders, relative attachment links, and version histories for instant restoration anywhere. - **Native AI Agent Synergy**: Deep integration with Model Context Protocol (MCP) allows AI tools like Claude Code, Codex, and Antigravity to read, organize, and summarize your notes, or sync seamlessly with Notion and Feishu Bitable. - **Bring Your Own AI Models**: Connect OpenAI, Anthropic, or Gemini-compatible services and third-party API relays to empower your editor with smart note summarization, key point extraction, proofreading, translation, and text continuation on full notes or selected text. +- **Plugin Extensibility**: Install client plugins and themes from the Plugin Marketplace to extend note actions, editor commands, custom panels, and more. - **Unlimited Multi-Device Sync**: No commercial device caps or paywalls. Enjoy seamless synchronization across PC, tablet, and mobile via web, PWA, or browser. - **Classic Three-Pane Layout & Focus Mode**: Clean navigation featuring notebook trees, note lists, and an expansive editor, with a desktop focus mode to eliminate distractions. - **Unlimited Nested Notebooks**: Organize your knowledge with arbitrary folder depth. @@ -222,17 +223,11 @@ https://your-domain/api/openapi.json Repository file: [docs/openapi.json](docs/openapi.json). -## Plugin Development Preview - -The standalone Plugin Marketplace supports trusted client plugins and code-free theme packages installed from its verified index, a public GitHub repository, or a manifest URL. Plugins can query and update notes, work with editor selections, register commands and custom panels, use encrypted secret storage, and make allowlisted network requests. A unified top-right desktop entry includes recently used actions. Cron and background jobs are intentionally deferred. See [Plugin Development](docs/plugin-development.md). - ## MCP -Create an API token in **Profile** -> **MCP settings**, then give the token or full MCP configuration to your AI Agent. Once connected, the Agent can securely read, organize, and import notes within your account permissions. MCP also exposes full management of reusable note templates and AI instructions: agents can list, inspect, create, update, and delete both, create a note from a template, and restore missing built-in AI instructions. Template and instruction reads use the `read:memos` scope, while changes use `write:memos`. Repeating the same memo import will not create duplicate notes. +Create an API token in **Profile** -> **MCP settings** and give it to your AI Agent. The Agent can then securely read, organize, and import notes, manage note templates and AI instructions, and connect your notes with tools such as Notion databases and Feishu Bitable—all within your account permissions. -The Remote MCP endpoint supports the stateless `2026-07-28` protocol while retaining the handshake-based 2025 revisions for existing clients. - -With MCP, EdgeEver can also connect to tools such as Notion databases and Feishu Bitable, turning scattered ideas, information, and materials from everyday notes into structured data that is easier to organize, search, and manage. +> Let your ideas run free: ask an AI Agent to organize fleeting thoughts, build a personal knowledge graph, create a profile from your notes, or tag them automatically. ## Image Compression @@ -261,6 +256,7 @@ Docker runs the same frontend, API routes, services, authentication, MCP impleme - The "Minimal Emerald" theme typography layout is inspired by [obsidian-minimal](https://github.com/kepano/obsidian-minimal). - The "Outline Emerald" theme typography layout is inspired by [Outline](https://github.com/outline/outline). +- The "Marxico Classic" theme adapts the default typography of [Marxico](https://maxiang.io/) to EdgeEver's editor. ## Disclaimer diff --git a/README.zh-CN.md b/README.zh-CN.md index beb2844d..1a8e7806 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -40,6 +40,7 @@ EdgeEver 是一款现代化的开源笔记工作区。它为你找回经典印 - **无损 ZIP 打包与无缝迁移**:一键打包导出包含 Markdown、Front Matter、嵌套目录及附件的完整档案,同时保留历史版本与结构化数据,方便在不同实例间完整还原。 - **原生 AI Agent 智脑联动**:内置 MCP(Model Context Protocol)协议,支持 Claude Code、Codex、Antigravity 等 AI 助手直接读取与整理笔记,也可与 Notion Database、飞书多维表格轻松打通。 - **接入自己的 AI 模型**:支持添加多个 OpenAI、Anthropic、Gemini 兼容服务与第三方中转平台,在编辑器中随时对全文或选区进行智能总结、要点提炼、语法校对、翻译与续写润色。 +- **插件扩展能力**:支持从插件市场安装客户端插件与主题,扩展笔记操作、编辑器命令和自定义面板等能力。 - **多端无缝同步,无设备限制**:自托管数据无商业限制,摆脱免费账号仅限 2 台设备的束缚,在 PC、平板与手机上随心同步。 - **经典三栏布局与专注模式**:笔记本树、笔记列表与编辑区一目了然;桌面端一键开启专注模式,让思绪尽情铺满屏幕。 - **无限层级笔记本**:轻松构建清晰的多级目录结构。 @@ -219,19 +220,11 @@ https://你的域名/api/openapi.json 仓库内文件:[docs/openapi.json](docs/openapi.json)。 -## 插件开发预览 - -独立的插件市场页面支持从已验证索引、公开 GitHub 仓库或 Manifest 地址安装受信任的客户端插件和无代码主题包。插件可以查询与修改笔记、操作编辑器选区、注册命令和自定义面板、使用加密 Secret Storage,并向声明过的域名发起网络请求。桌面端右上角提供统一插件入口和最近使用记录;Cron 与后台任务暂不纳入。详见[插件开发文档](docs/plugin-development.zh-CN.md)。 - ## MCP -先在 EdgeEver 左下角 **个人中心** 的 **MCP 设置** 中创建 API Token,再将 Token 或完整 MCP 配置发送给 AI Agent。连接后,Agent 即可在你的授权范围内安全地读取、整理和导入笔记。MCP 也开放了完整的笔记模板与 AI 指令管理能力:Agent 可以列出、查看、新建、更新和删除模板与指令,也可以使用模板创建笔记、恢复缺失的内置指令。读取模板和指令需要 `read:memos` 权限,变更需要 `write:memos` 权限;重复执行同一笔记导入任务不会创建重复笔记。 +在 **个人中心** -> **MCP 设置** 中创建 API Token 并交给 AI Agent,即可让 Agent 在账号授权范围内安全地读取、整理和导入笔记,管理笔记模板与 AI 指令,并与 Notion Database、飞书多维表格等工具联动。 -Remote MCP 端点支持无状态的 `2026-07-28` 协议,同时继续兼容现有客户端使用的 2025 握手式协议版本。 - -> 放飞你的思路,这种情况下是有很多灵活玩法: -比如让AI Agent归纳你随机记录的灵感创意、针对你的笔记做精准的人物画像、构建自己的知识图谱、自动为笔记打标签) -借助 MCP,EdgeEver 还可以与 Notion Database、飞书多维表格等工具联动,把日常笔记中零散的灵感、信息和素材沉淀到结构化数据库中,方便后续整理、检索与管理。 +> 放飞你的想法:让 AI Agent 归纳随手记录的灵感、构建个人知识图谱、根据笔记生成用户画像,或自动为笔记打标签。 ## 图片压缩规则 @@ -259,6 +252,7 @@ Docker 与 Cloudflare 共用同一套前端、API 路由、业务服务、鉴权 - “minimal品牌绿”主题排版架构借鉴于 [obsidian-minimal](https://github.com/kepano/obsidian-minimal)。 - “Outline 品牌绿”主题排版架构借鉴于 [Outline](https://github.com/outline/outline)。 +- “马克飞象经典”主题将[马克飞象](https://maxiang.io/)的默认排版适配到了 EdgeEver 编辑器。 ## 免责声明 diff --git a/apps/mobile/assets/adaptive-icon-transparent.png b/apps/mobile/assets/adaptive-icon-transparent.png index 20f1f7d3..779557fd 100644 Binary files a/apps/mobile/assets/adaptive-icon-transparent.png and b/apps/mobile/assets/adaptive-icon-transparent.png differ diff --git a/apps/mobile/assets/icon.png b/apps/mobile/assets/icon.png index dd371f5c..cb28cb7f 100644 Binary files a/apps/mobile/assets/icon.png and b/apps/mobile/assets/icon.png differ diff --git a/apps/mobile/src/components/LocalTiptapEditor.tsx b/apps/mobile/src/components/LocalTiptapEditor.tsx index 04ee0b1c..aba067e3 100644 --- a/apps/mobile/src/components/LocalTiptapEditor.tsx +++ b/apps/mobile/src/components/LocalTiptapEditor.tsx @@ -1,9 +1,6 @@ 'use dom'; -import "mermaid/dist/mermaid.min.js"; import "katex/dist/katex.min.css"; -import { renderMermaidSVG, THEMES } from "beautiful-mermaid"; -import { toCanvas } from "html-to-image"; import Image from "@tiptap/extension-image"; import CodeBlock from "@tiptap/extension-code-block"; import Placeholder from "@tiptap/extension-placeholder"; @@ -343,12 +340,18 @@ const handleMobileResourceEvent = ( return false; }; -const getMobileMermaidTheme = (theme: "light" | "dark") => THEMES[theme === "dark" ? "zinc-dark" : "zinc-light"]; +let beautifulMermaidRuntime: Promise | null = null; -const renderWithBeautifulMermaid = (source: string, theme: "light" | "dark") => { +const loadBeautifulMermaid = () => { + beautifulMermaidRuntime ??= import("beautiful-mermaid"); + return beautifulMermaidRuntime; +}; + +const renderWithBeautifulMermaid = async (source: string, theme: "light" | "dark") => { try { + const { renderMermaidSVG, THEMES } = await loadBeautifulMermaid(); return renderMermaidSVG(source, { - ...getMobileMermaidTheme(theme), + ...THEMES[theme === "dark" ? "zinc-dark" : "zinc-light"], transparent: true, font: "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif", padding: 24, @@ -392,7 +395,7 @@ const MermaidRenderRuntime = (props: MermaidRendererProps) => { const results: Array<{ source: string; svg: string | null }> = []; for (const source of sources) { try { - const beautifulSvg = renderWithBeautifulMermaid(source, props.theme); + const beautifulSvg = await renderWithBeautifulMermaid(source, props.theme); if (beautifulSvg) { results.push({ source, svg: inlineMermaidSvgStyles(beautifulSvg) }); continue; @@ -671,7 +674,9 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) { const editor = useEditor({ editable: !isViewer, - autofocus: autoFocus ? "end" : false, + // Focus only after the DOM view reports ready. Initial TipTap autofocus plus + // the Android bridge retry raced each other and could leave the WebView stuck. + autofocus: false, extensions: [ StarterKit.configure({ codeBlock: false, link: { openOnClick: false } }), TaskList, @@ -1158,6 +1163,7 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) { const totalHeight = Math.max(1, Math.ceil(documentRoot.getBoundingClientRect().height)); const backgroundColor = NOTE_IMAGE_BACKGROUND_COLORS[resolvedTheme] || themeCfg.canvasBg; + const { toCanvas } = await import("html-to-image"); const canvas = await toCanvas(documentRoot, { backgroundColor, cacheBust: false, @@ -2015,14 +2021,17 @@ const getMobileMermaidThemeVariables = (theme: "light" | "dark") => { }; }; +let mermaidRuntime: Promise | null = null; + const loadMermaid = () => { - const mermaid = (globalThis as typeof globalThis & { - mermaid?: typeof import("mermaid")["default"]; - }).mermaid; - if (!mermaid) { - return Promise.reject(new Error("Mermaid runtime unavailable")); - } - return Promise.resolve(mermaid); + mermaidRuntime ??= import("mermaid/dist/mermaid.min.js").then(() => { + const mermaid = (globalThis as typeof globalThis & { + mermaid?: typeof import("mermaid")["default"]; + }).mermaid; + if (!mermaid) throw new Error("Mermaid runtime unavailable"); + return mermaid; + }); + return mermaidRuntime; }; const createMobileCodeBlockExtension = ( @@ -2133,7 +2142,7 @@ const createMobileCodeBlockExtension = ( preview.replaceChildren(message); void loadMermaid() .then(async (mermaid) => { - const beautifulSvg = renderWithBeautifulMermaid(source, theme); + const beautifulSvg = await renderWithBeautifulMermaid(source, theme); if (beautifulSvg) { return { svg: beautifulSvg }; } diff --git a/apps/mobile/src/lib/mobile-locale.tsx b/apps/mobile/src/lib/mobile-locale.tsx index 69f050c1..6523a639 100644 --- a/apps/mobile/src/lib/mobile-locale.tsx +++ b/apps/mobile/src/lib/mobile-locale.tsx @@ -122,6 +122,10 @@ const mobileOnlyTranslations = new Map([ ["启动至列表数据就绪", "Launch to list data ready"], ["启动至交互空闲", "Launch to interaction idle"], ["最近一次本地编辑器启动", "Latest local editor startup"], + ["正在启动编辑器", "Starting editor"], + ["编辑器启动时间过长", "The editor is taking too long to start"], + ["正在准备本地编辑器,笔记内容是安全的。", "Preparing the local editor. Your note is safe."], + ["本地编辑器未能及时启动,可以重试或返回,当前草稿不会丢失。", "The local editor did not start in time. You can retry or go back; your current draft will not be lost."], ["暂不可用", "Unavailable"], ["尚未记录", "Not recorded"], ["正在搜索", "Searching"], diff --git a/apps/mobile/src/screens/WorkspaceScreen.tsx b/apps/mobile/src/screens/WorkspaceScreen.tsx index 91e8b806..c02a1889 100644 --- a/apps/mobile/src/screens/WorkspaceScreen.tsx +++ b/apps/mobile/src/screens/WorkspaceScreen.tsx @@ -192,6 +192,62 @@ const ANDROID_SYSTEM_NAVIGATION_FALLBACK = 48; const DETAIL_CONTENT_HORIZONTAL_PADDING = 16; const DETAIL_TABLE_FIT_COLUMN_COUNT = 3; const DETAIL_TABLE_MIN_COLUMN_WIDTH = 132; +const MOBILE_EDITOR_STARTUP_TIMEOUT_MS = 10_000; + +const useMobileEditorStartupGuard = ({ active, ready }: { active: boolean; ready: boolean }) => { + const [attempt, setAttempt] = useState(0); + const [timedOut, setTimedOut] = useState(false); + + useEffect(() => { + if (!active || ready) { + setTimedOut(false); + return; + } + const timeout = setTimeout(() => setTimedOut(true), MOBILE_EDITOR_STARTUP_TIMEOUT_MS); + return () => clearTimeout(timeout); + }, [active, attempt, ready]); + + const restart = useCallback(() => { + setTimedOut(false); + setAttempt((current) => current + 1); + }, []); + + return { attempt, restart, timedOut }; +}; + +const MobileEditorStartupOverlay = ({ + onRetry, + timedOut, +}: { + onRetry: () => void; + timedOut: boolean; +}) => { + const { translate } = useMobileLocale(); + return ( + + {!timedOut ? : null} + + {translate(timedOut ? "编辑器启动时间过长" : "正在启动编辑器")} + + + {translate(timedOut + ? "本地编辑器未能及时启动,可以重试或返回,当前草稿不会丢失。" + : "正在准备本地编辑器,笔记内容是安全的。")} + + {timedOut ? ( + + + {translate("重试")} + + ) : null} + + ); +}; const resolveEditableMemoTitle = (title?: string | null) => { const trimmedTitle = title?.trim() ?? ""; @@ -2075,6 +2131,7 @@ const CreateMemoModal = ({ const submitStartedRef = useRef(false); const [submitStarted, setSubmitStarted] = useState(false); const [resourceTarget, setResourceTarget] = useState(null); + const editorStartup = useMobileEditorStartupGuard({ active: draftLoaded && Boolean(baseUrl), ready: editorReady }); const { pickUploadAsset, uploadSourcePicker } = useMobileEditorUploadAsset(); const targetNotebookId = notebookId || fallbackNotebookId; const selectedNotebookName = notebooks.find((notebook) => notebook.id === targetNotebookId)?.name ?? "选择笔记本"; @@ -2114,21 +2171,29 @@ const CreateMemoModal = ({ } }, []); - const scheduleBodyKeyboard = useCallback((delayMs = 160) => { + const scheduleBodyKeyboard = useCallback((delayMs = 160, focusEditor = true) => { clearFocusTimers(); // Full-tree create only mounts the editor DomWebView, so native IME show is safe again. focusTimerRef.current = setTimeout(() => { focusTimerRef.current = null; - safeDomCall(() => editorRef.current?.focusEnd()); + if (focusEditor) { + safeDomCall(() => editorRef.current?.focusEnd()); + } if (Platform.OS === "android") { keyboardTimerRef.current = setTimeout(() => { keyboardTimerRef.current = null; showEdgeEverKeyboard(); - }, 120); + }, focusEditor ? 120 : 0); } }, delayMs); }, [clearFocusTimers]); + const retryEditorStartup = useCallback(() => { + clearFocusTimers(); + setEditorReady(false); + editorStartup.restart(); + }, [clearFocusTimers, editorStartup.restart]); + // Component is only mounted while create is open — init once on mount. useEffect(() => { let active = true; @@ -2514,13 +2579,16 @@ const CreateMemoModal = ({ onReady={async (elapsedMs) => { setEditorReady(true); recordEditorStartup(elapsedMs); - scheduleBodyKeyboard(60); + // LocalTiptapEditor owns caret placement; native only reveals the IME after + // its final focus retry so the two layers cannot race each other. + scheduleBodyKeyboard(180, false); }} + key={editorStartup.attempt} ref={editorRef} locale={resolvedLocale} theme={resolvedTheme} /> - ) : null, [aiPromptsJson, baseUrl, cancelSelectionAi, draftLoaded, loadEditorResource, pushBodyToEditor, requestSelectionAi, resolvedLocale, resolvedTheme, scheduleBodyKeyboard, selectResource]); + ) : null, [aiPromptsJson, baseUrl, cancelSelectionAi, draftLoaded, editorStartup.attempt, loadEditorResource, pushBodyToEditor, requestSelectionAi, resolvedLocale, resolvedTheme, scheduleBodyKeyboard, selectResource]); return ( @@ -2591,6 +2659,7 @@ const CreateMemoModal = ({ {editorElement} + {!editorReady ? : null} {createMutation.error ? ( @@ -2851,6 +2920,7 @@ const RichEditorModal = ({ const [error, setError] = useState(null); const [startupMs, setStartupMs] = useState(null); const [resourceTarget, setResourceTarget] = useState(null); + const editorStartup = useMobileEditorStartupGuard({ active: Boolean(memo && baseUrl), ready }); const { pickUploadAsset, uploadSourcePicker } = useMobileEditorUploadAsset(); const notebookLabel = notebooks.find((notebook) => notebook.id === notebookId)?.name ?? "未分类"; const saveLabel = error ? "保存失败" : saving ? "保存中" : uploading ? "上传中" : dirty ? (draftRestored ? "本地草稿" : "未保存") : ready ? "已保存" : "加载中"; @@ -2868,6 +2938,17 @@ const RichEditorModal = ({ } }, []); + const retryEditorStartup = useCallback(() => { + if (initialFocusTimerRef.current !== null) { + clearTimeout(initialFocusTimerRef.current); + initialFocusTimerRef.current = null; + } + setReady(false); + setStartupMs(null); + setError(null); + editorStartup.restart(); + }, [editorStartup.restart]); + const persistDraft = async (contentJson: TiptapDoc) => { const currentMemo = memoBaseRef.current; if (!currentMemo) { @@ -3062,23 +3143,22 @@ const RichEditorModal = ({ if (initialFocusTimerRef.current !== null) { clearTimeout(initialFocusTimerRef.current); } - // Full-tree edit mounts a single DomWebView — focus then show the soft keyboard. - initialFocusTimerRef.current = setTimeout(() => { - initialFocusTimerRef.current = null; - safeDomCall(() => editorRef.current?.focusEnd()); - if (Platform.OS === "android") { - setTimeout(() => { - showEdgeEverKeyboard(); - }, 120); - } - }, 60); + // The DOM editor performs its own bounded focus retry. Reveal the Android + // keyboard only after that retry instead of issuing another competing focus. + if (Platform.OS === "android") { + initialFocusTimerRef.current = setTimeout(() => { + initialFocusTimerRef.current = null; + showEdgeEverKeyboard(); + }, 180); + } }} + key={editorStartup.attempt} ref={editorRef} locale={resolvedLocale} theme={resolvedTheme} /> ) : null, - [aiPromptsJson, baseUrl, cancelSelectionAi, loadEditorResource, memo?.id, requestSelectionAi, resolvedLocale, resolvedTheme, selectResource] + [aiPromptsJson, baseUrl, cancelSelectionAi, editorStartup.attempt, loadEditorResource, memo?.id, requestSelectionAi, resolvedLocale, resolvedTheme, selectResource] ); useEffect(() => { @@ -3175,6 +3255,7 @@ const RichEditorModal = ({ {draftRestored ? 已恢复上次未完成的本地草稿 : null} {editorElement} + {!ready ? : null} {error ? {error} : null} {startupMs !== null && __DEV__ ? 本地编辑器启动:{startupMs}ms : null} diff --git a/apps/mobile/src/screens/workspace-styles.ts b/apps/mobile/src/screens/workspace-styles.ts index 2b6809f3..845583a7 100644 --- a/apps/mobile/src/screens/workspace-styles.ts +++ b/apps/mobile/src/screens/workspace-styles.ts @@ -1310,6 +1310,12 @@ const baseWorkspaceStyles = StyleSheet.create({ top: 0, zIndex: 2, }, + richEditorLoadingTitle: { + color: "#0f172a", + fontSize: 15, + fontWeight: "800", + textAlign: "center", + }, richEditorWebView: { backgroundColor: "#ffffff", flex: 1, diff --git a/apps/web/mobile-edit.html b/apps/web/mobile-edit.html index 6ca716be..9c286746 100644 --- a/apps/web/mobile-edit.html +++ b/apps/web/mobile-edit.html @@ -14,9 +14,177 @@ 编辑笔记 - EdgeEver + + -
+
+
+
+ +

正在打开编辑器…

+

笔记内容是安全的,网络较慢时可能需要稍等片刻。

+
+ + +
+
+
+
diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index a21c4db7..1e58849b 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -1295,7 +1295,7 @@ const RichEditorPane = ({ editable: Boolean(memo && !effectiveReadOnly && hydratedEditorMemoId === memo.id), editorProps: { attributes: { - class: "edgeever-note-rich-editor prose prose-slate max-w-none focus:outline-none min-h-[300px] px-4 py-3 sm:px-7", + class: "edgeever-note-rich-editor prose prose-slate max-w-none focus:outline-none min-h-[240px] px-4 py-3 sm:px-7 lg:min-h-[180px]", }, handleKeyDown: (view, event) => { const { selection } = view.state; @@ -4615,7 +4615,8 @@ const RichEditorPane = ({ editorTheme === "minimal-emerald" || editorTheme === "outline-emerald" || editorTheme === "wechat-green" || - editorTheme === "modern-mint" + editorTheme === "modern-mint" || + editorTheme === "marxico" ? editorTheme : "custom" } @@ -4628,7 +4629,8 @@ const RichEditorPane = ({ editorTheme !== "minimal-emerald" && editorTheme !== "outline-emerald" && editorTheme !== "wechat-green" && - editorTheme !== "modern-mint" + editorTheme !== "modern-mint" && + editorTheme !== "marxico" ? { "--editor-theme-light-bg": customEditorTheme.light.background, "--editor-theme-light-text": customEditorTheme.light.text, @@ -4662,6 +4664,7 @@ const RichEditorPane = ({ editorTheme !== "outline-emerald" && editorTheme !== "wechat-green" && editorTheme !== "modern-mint" && + editorTheme !== "marxico" && customEditorTheme.customCss && (