From 2b059aa39fb2cad864b2a2e9fe7068381f3f0e6d Mon Sep 17 00:00:00 2001 From: tianma-if Date: Wed, 22 Jul 2026 10:31:59 +0800 Subject: [PATCH] feat: add Mermaid diagrams across web and mobile --- README.md | 1 + README.zh-CN.md | 1 + apps/api/src/index.ts | 36 ++ apps/mobile/app.json | 4 +- apps/mobile/package.json | 2 + .../src/components/LocalTiptapEditor.tsx | 311 +++++++++++++++++- apps/mobile/src/components/MobileMermaid.tsx | 161 +++++++++ apps/mobile/src/lib/mobile-mermaid.test.ts | 23 ++ apps/mobile/src/lib/mobile-mermaid.ts | 12 + apps/mobile/src/mermaid-global.d.ts | 1 + apps/mobile/src/screens/WorkspaceScreen.tsx | 25 +- apps/web/package.json | 3 +- apps/web/src/components/EditorPane.tsx | 5 +- apps/web/src/components/EditorToolbar.tsx | 33 ++ apps/web/src/components/MermaidCodeBlock.tsx | 111 +++++++ .../MobileStandaloneEditorParts.tsx | 9 +- .../MobileStandaloneTiptapEditor.tsx | 18 +- apps/web/src/lib/code-block.ts | 10 + apps/web/src/styles/globals.css | 82 +++++ apps/web/vite.config.ts | 19 +- bun.lock | 220 ++++++++++++- package.json | 3 + packages/shared/src/content.test.mjs | 14 + packages/shared/src/i18n/en-US.ts | 6 + packages/shared/src/i18n/zh-CN.ts | 6 + packages/shared/src/mobile-editor.test.mjs | 2 + packages/shared/src/mobile-editor.ts | 5 + patches/mermaid@11.16.0.patch | 10 + 28 files changed, 1116 insertions(+), 17 deletions(-) create mode 100644 apps/mobile/src/components/MobileMermaid.tsx create mode 100644 apps/mobile/src/lib/mobile-mermaid.test.ts create mode 100644 apps/mobile/src/lib/mobile-mermaid.ts create mode 100644 apps/mobile/src/mermaid-global.d.ts create mode 100644 apps/web/src/components/MermaidCodeBlock.tsx create mode 100644 patches/mermaid@11.16.0.patch diff --git a/README.md b/README.md index 950bc147..1370c013 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ The public demo resets every Monday at 1:00 AM (China Standard Time) and restore - Unlimited nested notebooks. - Rich text editing. - Switch between Markdown source and rich text views on desktop. +- Mermaid diagrams rendered from standard fenced code blocks on the Web and native app, with editable source preserved across rich text and Markdown views. - Note version history for reviewing previous content changes. - Local browser-side image compression before upload, often reducing screenshots and large photos by about 50%-90%. - Batch note merging. diff --git a/README.zh-CN.md b/README.zh-CN.md index ca026722..5fb56ad0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -40,6 +40,7 @@ EdgeEver 是一个开源、自托管、Cloudflare-native 的现代笔记工作 - 无限级嵌套笔记本。 - 支持富文本编辑。 - 桌面端支持 Markdown 源码与富文本视图切换。 +- Web 与原生 App 均支持渲染标准 Mermaid 围栏代码块,并在富文本与 Markdown 视图之间保留可编辑源码。 - 支持查看笔记历史版本,便于回溯内容变化。 - 笔记图片上传前支持 Web 端本地压缩,常见截图和大尺寸照片通常可减少约 50%-90% 体积,减少资源占用且不消耗 Cloudflare Images 额度。 - 多选合并笔记。 diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 9942ec8b..961c718c 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -345,6 +345,42 @@ const DEMO_SEED_MEMOS = [ markdown: "## Agent-ready:REST API 与 MCP\n\nEdgeEver 提供 REST API、OpenAPI schema 和 MCP endpoint。AI Agent 可以读取笔记本、创建笔记、整理标签,并把导入资料迁移到你的自托管实例。\n\n### 从这里开始\n\n- OpenAPI schema:`/api/openapi.json`\n- MCP endpoint:`/mcp`\n\n一个很小的 Agent 工作流是:先读取「功能演示」笔记本,再把这两条合并素材整理成一条带结论的长期笔记。", }, + { + id: "memo_demo_mermaid_flowchart", + notebookId: "nb_demo_features", + title: "Mermaid 示例:笔记整理流程", + tags: ["mermaid", "flowchart", "demo"], + isPinned: true, + markdown: + "## 笔记整理流程\n\n流程图适合表达判断、分支和处理步骤。进入编辑模式后,可以直接修改下方 Mermaid 源码并查看预览。\n\n```mermaid\nflowchart TD\n A[\"记录灵感\"] --> B{\"需要立即处理?\"}\n B -- \"是\" --> C[\"加入今日行动\"]\n B -- \"否\" --> D[\"放入等待分类\"]\n D --> E{\"是否属于长期主题?\"}\n E -- \"是\" --> F[\"移动到主题笔记本\"]\n E -- \"否\" --> G[\"添加标签并归档\"]\n C --> H[\"完成后沉淀结论\"]\n F --> H\n```\n\n修改节点文字或连线后,图表会自动重新渲染;Markdown 中仍保存标准的 `mermaid` 围栏代码块。", + }, + { + id: "memo_demo_mermaid_sequence", + notebookId: "nb_demo_features", + title: "Mermaid 示例:离线同步时序", + tags: ["mermaid", "sequence", "sync"], + isPinned: false, + markdown: + "## 离线同步时序\n\n时序图适合说明多个角色或系统之间按时间发生的交互。\n\n```mermaid\nsequenceDiagram\n actor U as 用户\n participant A as EdgeEver App\n participant Q as 本地同步队列\n participant API as EdgeEver API\n participant DB as D1\n\n U->>A: 编辑并保存笔记\n A->>Q: 写入本地草稿与待同步记录\n A-->>U: 立即显示“已保存到本地”\n Q->>API: 网络恢复后提交更新\n API->>DB: 校验版本并持久化\n DB-->>API: 返回新版本\n API-->>Q: 同步成功\n Q-->>A: 清除待同步记录\n```\n\n这种图很适合记录 API 调用、登录流程、消息队列和跨服务协作。", + }, + { + id: "memo_demo_mermaid_state", + notebookId: "nb_demo_features", + title: "Mermaid 示例:笔记生命周期", + tags: ["mermaid", "state-diagram", "workflow"], + isPinned: false, + markdown: + "## 笔记生命周期\n\n状态图强调一个对象会处于哪些状态,以及什么事件会触发状态变化。\n\n```mermaid\nstateDiagram-v2\n [*] --> 草稿\n 草稿 --> 待同步: 离线保存\n 草稿 --> 已保存: 在线保存\n 待同步 --> 已保存: 同步成功\n 待同步 --> 冲突: 服务端版本已变化\n 冲突 --> 草稿: 选择本地版本继续编辑\n 冲突 --> 已保存: 接受服务端版本\n 已保存 --> 已归档: 归档\n 已归档 --> 已保存: 恢复\n 已保存 --> 回收站: 删除\n 回收站 --> 已保存: 恢复\n 回收站 --> [*]: 永久删除\n```\n\n它也适合描述订单、审批单、任务或发布流程。", + }, + { + id: "memo_demo_mermaid_gantt", + notebookId: "nb_demo_features", + title: "Mermaid 示例:一周发布计划", + tags: ["mermaid", "gantt", "planning"], + isPinned: false, + markdown: + "## 一周发布计划\n\n甘特图可以把任务、依赖关系和时间安排放在一张图里。\n\n```mermaid\ngantt\n title EdgeEver 功能发布计划\n dateFormat YYYY-MM-DD\n axisFormat %m-%d\n\n section 开发\n 完成 Mermaid 渲染 :done, dev1, 2026-07-20, 2d\n 补齐 App 编辑能力 :done, dev2, after dev1, 1d\n\n section 验证\n 自动化测试 :active, qa1, after dev2, 1d\n Android 与 Web 验收 :qa2, after qa1, 1d\n\n section 发布\n 整理说明与发布 :milestone, release, after qa2, 0d\n```\n\n日期和持续时间都是普通文本,因此可以直接在笔记里维护轻量项目计划。", + }, { id: "memo_demo_mobile", notebookId: "nb_personal", diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 3371351a..89b03939 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -3,7 +3,7 @@ "name": "EdgeEver", "slug": "edgeever", "scheme": "edgeever", - "version": "1.4.0", + "version": "1.5.0", "runtimeVersion": { "policy": "appVersion" }, @@ -28,7 +28,7 @@ }, "android": { "package": "org.edgeever.mobile", - "versionCode": 32, + "versionCode": 33, "adaptiveIcon": { "backgroundColor": "#d1fae5", "backgroundImage": "./assets/icon.png", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index b3123909..2988dd5b 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -20,6 +20,7 @@ "@tanstack/query-async-storage-persister": "^5.101.2", "@tanstack/react-query": "latest", "@tanstack/react-query-persist-client": "^5.101.2", + "@tiptap/extension-code-block": "3.27.1", "@tiptap/extension-image": "3.27.1", "@tiptap/extension-placeholder": "3.27.1", "@tiptap/extension-table": "3.27.1", @@ -38,6 +39,7 @@ "expo-sqlite": "~57.0.1", "expo-status-bar": "latest", "expo-updates": "~57.0.8", + "mermaid": "^11.16.0", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "latest", diff --git a/apps/mobile/src/components/LocalTiptapEditor.tsx b/apps/mobile/src/components/LocalTiptapEditor.tsx index 44b61af5..f072d552 100644 --- a/apps/mobile/src/components/LocalTiptapEditor.tsx +++ b/apps/mobile/src/components/LocalTiptapEditor.tsx @@ -1,6 +1,8 @@ 'use dom'; +import "mermaid/dist/mermaid.min.js"; import Image from "@tiptap/extension-image"; +import CodeBlock from "@tiptap/extension-code-block"; import Placeholder from "@tiptap/extension-placeholder"; import { TableKit } from "@tiptap/extension-table"; import { EditorContent, useEditor, useEditorState } from "@tiptap/react"; @@ -49,6 +51,7 @@ export interface LocalTiptapEditorRef extends DOMImperativeFactory { } type LocalTiptapEditorProps = { + mode?: "editor"; autoFocus?: boolean; baseUrl: string; content: EditorDoc; @@ -63,11 +66,148 @@ type LocalTiptapEditorProps = { theme: "light" | "dark"; }; +type MermaidRendererProps = { + diagramsJson: string; + dom?: DOMProps; + mode: "mermaid-renderer"; + onRendered: (resultsJson: string) => Promise; + theme: "light" | "dark"; +}; + const CHANGE_IDLE_MS = 500; const TRANSIENT_IMAGE_UPLOAD_META = "edgeeverImageUploadPlaceholder"; const ignoreSearchResult = async () => undefined; -export default function LocalTiptapEditor(props: LocalTiptapEditorProps) { +export default function LocalTiptapEditor(props: LocalTiptapEditorProps | MermaidRendererProps) { + return props.mode === "mermaid-renderer" + ? + : ; +} + +const MermaidRenderRuntime = (props: MermaidRendererProps) => { + useEffect(() => { + let cancelled = false; + + const renderDiagrams = async () => { + let sources: string[] = []; + try { + const parsed = JSON.parse(props.diagramsJson) as unknown; + sources = Array.isArray(parsed) + ? parsed.filter((source): source is string => typeof source === "string" && source.trim().length > 0) + : []; + } catch { + sources = []; + } + + const mermaid = await loadMermaid(); + mermaid.initialize({ + startOnLoad: false, + securityLevel: "strict", + suppressErrorRendering: true, + theme: props.theme === "dark" ? "dark" : "neutral", + flowchart: { htmlLabels: false }, + }); + + const results: Array<{ source: string; svg: string | null }> = []; + for (const source of sources) { + try { + const valid = await mermaid.parse(source, { suppressErrors: true }); + if (!valid) { + throw new Error("Invalid Mermaid diagram"); + } + mermaidRenderSequence += 1; + const { svg } = await mermaid.render(`edgeever-mobile-mermaid-${mermaidRenderSequence}`, source); + results.push({ source, svg: inlineMermaidSvgStyles(svg) }); + } catch { + results.push({ source, svg: null }); + } + } + + if (!cancelled) { + await props.onRendered(JSON.stringify(results)); + } + }; + + void renderDiagrams().catch(() => { + if (!cancelled) { + void props.onRendered("[]"); + } + }); + return () => { + cancelled = true; + }; + }, [props.diagramsJson, props.onRendered, props.theme]); + + return null; +}; + +const inlineMermaidSvgStyles = (svg: string) => { + const container = document.createElement("div"); + container.style.cssText = "position:fixed;left:-10000px;top:-10000px;visibility:hidden;"; + container.innerHTML = svg; + document.body.append(container); + + const root = container.querySelector("svg"); + if (!root) { + container.remove(); + return svg; + } + + for (const foreignObject of root.querySelectorAll("foreignObject")) { + const label = foreignObject.textContent?.replace(/\s+/g, " ").trim(); + if (!label) { + foreignObject.remove(); + continue; + } + const x = Number.parseFloat(foreignObject.getAttribute("x") ?? "0"); + const y = Number.parseFloat(foreignObject.getAttribute("y") ?? "0"); + const width = Number.parseFloat(foreignObject.getAttribute("width") ?? "0"); + const height = Number.parseFloat(foreignObject.getAttribute("height") ?? "0"); + const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); + text.setAttribute("x", String(x + width / 2)); + text.setAttribute("y", String(y + height / 2)); + text.setAttribute("text-anchor", "middle"); + text.setAttribute("dominant-baseline", "central"); + text.setAttribute("font-size", "16px"); + text.textContent = label; + foreignObject.replaceWith(text); + } + + const properties = [ + "color", + "fill", + "fill-opacity", + "font-family", + "font-size", + "font-style", + "font-weight", + "opacity", + "stroke", + "stroke-dasharray", + "stroke-dashoffset", + "stroke-linecap", + "stroke-linejoin", + "stroke-opacity", + "stroke-width", + "text-anchor", + ] as const; + + for (const element of root.querySelectorAll("*")) { + const computed = getComputedStyle(element); + for (const property of properties) { + const value = computed.getPropertyValue(property); + if (value) { + element.setAttribute(property, value); + } + } + } + + const serialized = new XMLSerializer().serializeToString(root); + container.remove(); + return serialized; +}; + +function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) { const [tableMenuOpen, setTableMenuOpen] = useState(false); const startedAtRef = useRef(performance.now()); const changeTimerRef = useRef(null); @@ -88,11 +228,16 @@ export default function LocalTiptapEditor(props: LocalTiptapEditorProps) { () => createProtectedImageExtension(props.baseUrl, props.locale, (source) => onLoadResourceRef.current(source)), [props.baseUrl, props.locale] ); + const mermaidCodeBlockExtension = useMemo( + () => createMobileCodeBlockExtension(props.locale, props.theme), + [props.locale, props.theme] + ); const editor = useEditor({ autofocus: props.autoFocus ? "end" : false, extensions: [ - StarterKit, + StarterKit.configure({ codeBlock: false }), + mermaidCodeBlockExtension, protectedImageExtension, TableKit.configure({ table: { renderWrapper: true }, @@ -257,6 +402,7 @@ export default function LocalTiptapEditor(props: LocalTiptapEditorProps) { editor, selector: ({ editor: activeEditor }) => (activeEditor?.isActive("bold") ? MOBILE_EDITOR_ACTIVE_FLAGS.bold : 0) | + (activeEditor?.isActive("codeBlock", { language: "mermaid" }) ? MOBILE_EDITOR_ACTIVE_FLAGS.mermaid : 0) | (activeEditor?.isActive("bulletList") ? MOBILE_EDITOR_ACTIVE_FLAGS.bulletList : 0) | (activeEditor?.isActive("blockquote") ? MOBILE_EDITOR_ACTIVE_FLAGS.blockquote : 0) | (activeEditor?.isActive("table") ? MOBILE_EDITOR_ACTIVE_FLAGS.table : 0) | @@ -323,6 +469,7 @@ export default function LocalTiptapEditor(props: LocalTiptapEditorProps) { const toolbarIcons: Record = { image: , + mermaid: , bold: , bulletList: , blockquote: , @@ -337,6 +484,20 @@ export default function LocalTiptapEditor(props: LocalTiptapEditorProps) { }; const toolbarHandlers: Record void> = { image: () => void insertImage(), + mermaid: () => { + if (!editor) { + return; + } + if (editor.isActive("codeBlock")) { + editor.chain().focus().updateAttributes("codeBlock", { language: "mermaid" }).run(); + return; + } + editor.chain().focus().insertContent({ + type: "codeBlock", + attrs: { language: "mermaid" }, + content: [{ type: "text", text: "flowchart LR\n A[Start] --> B[End]" }], + }).run(); + }, bold: () => editor?.chain().focus().toggleBold().run(), bulletList: () => editor?.chain().focus().toggleBulletList().run(), blockquote: () => editor?.chain().focus().toggleBlockquote().run(), @@ -495,6 +656,15 @@ const BoldIcon = () => ( ); +const DiagramIcon = () => ( + + + + + + +); + const ListIcon = () => ( @@ -576,6 +746,134 @@ const applyImageWidth = ( return width; }; +let mermaidRenderSequence = 0; + +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); +}; + +const createMobileCodeBlockExtension = ( + locale: "zh-CN" | "en-US", + theme: "light" | "dark" +) => CodeBlock.extend({ + addNodeView() { + return ({ node }) => { + const wrapper = document.createElement("div"); + const preview = document.createElement("div"); + const message = document.createElement("p"); + const svgContainer = document.createElement("div"); + const pre = document.createElement("pre"); + const code = document.createElement("code"); + preview.contentEditable = "false"; + preview.className = "edgeever-mermaid-preview"; + message.className = "edgeever-mermaid-message"; + svgContainer.className = "edgeever-mermaid-svg"; + svgContainer.setAttribute("role", "img"); + svgContainer.setAttribute("aria-label", locale === "en-US" ? "Mermaid diagram preview" : "Mermaid 图表预览"); + pre.append(code); + wrapper.append(preview, pre); + + let currentNode = node; + let renderTimer: number | null = null; + let renderRequest = 0; + + const clearRender = () => { + renderRequest += 1; + if (renderTimer !== null) { + window.clearTimeout(renderTimer); + renderTimer = null; + } + }; + + const renderNode = () => { + clearRender(); + const language = typeof currentNode.attrs.language === "string" + ? currentNode.attrs.language.toLowerCase() + : "plaintext"; + const isMermaid = language === "mermaid"; + wrapper.className = isMermaid ? "edgeever-mermaid-code-block" : "edgeever-code-block"; + wrapper.dataset.language = language; + preview.hidden = !isMermaid; + code.setAttribute("aria-label", isMermaid + ? (locale === "en-US" ? "Mermaid source" : "Mermaid 源码") + : (locale === "en-US" ? "Code source" : "代码源码")); + if (!isMermaid) { + preview.replaceChildren(); + return; + } + + const source = currentNode.textContent.trim(); + if (!source) { + message.className = "edgeever-mermaid-message"; + message.textContent = locale === "en-US" ? "Enter Mermaid source below." : "请在下方输入 Mermaid 源码。"; + preview.replaceChildren(message); + return; + } + + const activeRequest = renderRequest; + renderTimer = window.setTimeout(() => { + message.className = "edgeever-mermaid-message"; + message.textContent = locale === "en-US" ? "Rendering diagram…" : "正在渲染图表…"; + preview.replaceChildren(message); + void loadMermaid() + .then(async (mermaid) => { + mermaid.initialize({ + startOnLoad: false, + securityLevel: "strict", + suppressErrorRendering: true, + theme: theme === "dark" ? "dark" : "neutral", + }); + const valid = await mermaid.parse(source, { suppressErrors: true }); + if (!valid) { + throw new Error("Invalid Mermaid diagram"); + } + mermaidRenderSequence += 1; + return mermaid.render(`edgeever-mobile-editor-mermaid-${mermaidRenderSequence}`, source); + }) + .then(({ svg }) => { + if (activeRequest !== renderRequest) { + return; + } + svgContainer.innerHTML = svg; + preview.replaceChildren(svgContainer); + }) + .catch(() => { + if (activeRequest !== renderRequest) { + return; + } + message.className = "edgeever-mermaid-error"; + message.textContent = locale === "en-US" + ? "Unable to render this diagram. Check its syntax." + : "无法渲染此图表,请检查语法。"; + preview.replaceChildren(message); + }); + }, 300); + }; + + renderNode(); + return { + dom: wrapper, + contentDOM: code, + update: (updatedNode) => { + if (updatedNode.type !== currentNode.type) { + return false; + } + currentNode = updatedNode; + renderNode(); + return true; + }, + destroy: clearRender, + }; + }; + }, +}); + const createMobileImageSizeControls = ( locale: "zh-CN" | "en-US", updateWidth: (width: number) => void @@ -977,6 +1275,15 @@ const getEditorStyles = (theme: "light" | "dark") => ` .edgeever-editor-content pre { overflow-x: auto; border-radius: 10px; padding: 14px; background: #0f172a; color: #e2e8f0; } .edgeever-editor-content code { border-radius: 4px; padding: 2px 4px; background: ${theme === "dark" ? "#1e293b" : "#f1f5f9"}; } .edgeever-editor-content pre code { padding: 0; background: transparent; } + .edgeever-mermaid-code-block { margin: 18px 0; overflow: hidden; border: 1px solid ${theme === "dark" ? "#334155" : "#e2e8f0"}; border-radius: 12px; background: ${theme === "dark" ? "#111827" : "#fff"}; } + .edgeever-mermaid-code-block > pre { margin: 0; border-radius: 0; border-top: 1px solid ${theme === "dark" ? "#334155" : "#e2e8f0"}; } + .edgeever-mermaid-preview { display: flex; min-height: 116px; align-items: center; justify-content: center; overflow-x: auto; padding: 14px; background: ${theme === "dark" ? "#0f172a" : "#fff"}; } + .edgeever-mermaid-preview[hidden] { display: none; } + .edgeever-mermaid-svg { width: 100%; text-align: center; } + .edgeever-mermaid-svg svg { display: block; width: 100%; max-width: 100%; height: auto; max-height: 420px; margin: auto; } + .edgeever-mermaid-message, .edgeever-mermaid-error { margin: 0; font-size: 14px; line-height: 1.5; text-align: center; } + .edgeever-mermaid-message { color: ${theme === "dark" ? "#94a3b8" : "#64748b"}; } + .edgeever-mermaid-error { color: ${theme === "dark" ? "#fda4af" : "#be123c"}; } .edgeever-editor-content img { display: block; max-width: 100%; height: auto; margin: 14px auto; border-radius: 10px; } .edgeever-editor-content .tableWrapper { --mobile-table-column-width: clamp(5.5rem, calc((100vw - 3rem) / 3), 14rem); width: 100%; max-width: 100%; overflow-x: auto; margin-top: 20px; margin-right: auto; margin-bottom: 20px; margin-left: 0; border: 1px solid ${theme === "dark" ? "#334155" : "#d8d8d8"}; border-radius: 2px; background: ${theme === "dark" ? "#0f172a" : "#fff"}; overscroll-behavior-inline: contain; scrollbar-color: rgba(100, 116, 139, 0.28) transparent; } .edgeever-editor-content table { width: max-content; min-width: 100% !important; border-collapse: separate; border-spacing: 0; table-layout: fixed; } diff --git a/apps/mobile/src/components/MobileMermaid.tsx b/apps/mobile/src/components/MobileMermaid.tsx new file mode 100644 index 00000000..69787bb6 --- /dev/null +++ b/apps/mobile/src/components/MobileMermaid.tsx @@ -0,0 +1,161 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { StyleSheet, Text, useWindowDimensions, View } from "react-native"; +import { SvgXml } from "react-native-svg"; +import LocalTiptapEditor from "./LocalTiptapEditor"; +import { getMermaidSvgAspectRatio } from "../lib/mobile-mermaid"; + +type MermaidResult = { source: string; svg: string | null }; +type MermaidContextValue = { + register: (source: string) => () => void; + results: ReadonlyMap; +}; + +const MermaidContext = createContext(null); + +export const MobileMermaidProvider = ({ + children, + theme, +}: { + children: ReactNode; + theme: "light" | "dark"; +}) => { + const [sources, setSources] = useState([]); + const [results, setResults] = useState>(new Map()); + const sourceCountsRef = useRef(new Map()); + const diagramsJson = useMemo(() => JSON.stringify(sources), [sources]); + const register = useCallback((source: string) => { + const count = sourceCountsRef.current.get(source) ?? 0; + sourceCountsRef.current.set(source, count + 1); + if (count === 0) { + setSources((current) => [...current, source]); + } + return () => { + const nextCount = (sourceCountsRef.current.get(source) ?? 1) - 1; + if (nextCount > 0) { + sourceCountsRef.current.set(source, nextCount); + return; + } + sourceCountsRef.current.delete(source); + setSources((current) => current.filter((item) => item !== source)); + }; + }, []); + const handleRendered = useCallback(async (resultsJson: string) => { + try { + const parsed = JSON.parse(resultsJson) as MermaidResult[]; + setResults(new Map(parsed.map((result) => [result.source, result.svg]))); + } catch { + setResults(new Map()); + } + }, []); + const contextValue = useMemo(() => ({ + register, + results, + }), [register, results]); + + useEffect(() => { + setResults(new Map()); + }, [theme]); + + return ( + + {children} + {sources.length > 0 ? ( + + ) : null} + + ); +}; + +export const MobileMermaidDiagram = ({ + locale, + source, + theme, +}: { + locale: "zh-CN" | "en-US"; + source: string; + theme: "light" | "dark"; +}) => { + const context = useContext(MermaidContext); + const register = context?.register; + const { width } = useWindowDimensions(); + const normalizedSource = source.trim(); + + useEffect(() => normalizedSource ? register?.(normalizedSource) : undefined, [normalizedSource, register]); + + if (!normalizedSource) { + return {locale === "en-US" ? "This Mermaid diagram is empty." : "此 Mermaid 图表暂无内容。"}; + } + + const hasResult = context?.results.has(normalizedSource) ?? false; + const svg = context?.results.get(normalizedSource) ?? null; + if (!hasResult) { + return {locale === "en-US" ? "Rendering diagram…" : "正在渲染图表…"}; + } + if (!svg) { + return {locale === "en-US" ? "Unable to render this Mermaid diagram. Check its syntax." : "无法渲染此 Mermaid 图表,请检查语法。"}; + } + + const aspectRatio = getMermaidSvgAspectRatio(svg); + const availableWidth = Math.max(1, width - 48); + const height = Math.min(420, Math.max(120, availableWidth / aspectRatio)); + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + diagram: { + width: "100%", + marginVertical: 12, + borderWidth: 1, + borderColor: "#e2e8f0", + borderRadius: 12, + padding: 10, + backgroundColor: "#ffffff", + }, + diagramDark: { + borderColor: "#334155", + backgroundColor: "#0f172a", + }, + error: { + marginVertical: 10, + borderRadius: 10, + padding: 12, + color: "#be123c", + backgroundColor: "#fff1f2", + fontSize: 14, + lineHeight: 20, + }, + message: { + marginVertical: 10, + borderRadius: 10, + padding: 12, + color: "#64748b", + backgroundColor: "#f8fafc", + fontSize: 14, + lineHeight: 20, + }, + messageDark: { + color: "#cbd5e1", + backgroundColor: "#1e293b", + }, + renderer: { + position: "absolute", + width: 1, + height: 1, + opacity: 0, + }, +}); diff --git a/apps/mobile/src/lib/mobile-mermaid.test.ts b/apps/mobile/src/lib/mobile-mermaid.test.ts new file mode 100644 index 00000000..d419588b --- /dev/null +++ b/apps/mobile/src/lib/mobile-mermaid.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test"; +import { + getMermaidSvgAspectRatio, + getMobileMarkdownFenceLanguage, + trimMobileMarkdownFenceContent, +} from "./mobile-mermaid"; + +describe("mobile Mermaid rendering", () => { + test("recognizes Mermaid fence info case-insensitively", () => { + expect(getMobileMarkdownFenceLanguage(" Mermaid ")).toBe("mermaid"); + expect(getMobileMarkdownFenceLanguage("typescript title=example")).toBe("typescript"); + }); + + test("removes only the parser-added trailing newline", () => { + expect(trimMobileMarkdownFenceContent("flowchart LR\n A --> B\n")).toBe("flowchart LR\n A --> B"); + expect(trimMobileMarkdownFenceContent("flowchart LR")).toBe("flowchart LR"); + }); + + test("derives a stable native layout ratio from Mermaid SVG output", () => { + expect(getMermaidSvgAspectRatio('')).toBe(2); + expect(getMermaidSvgAspectRatio("")).toBe(1.6); + }); +}); diff --git a/apps/mobile/src/lib/mobile-mermaid.ts b/apps/mobile/src/lib/mobile-mermaid.ts new file mode 100644 index 00000000..4f1beb60 --- /dev/null +++ b/apps/mobile/src/lib/mobile-mermaid.ts @@ -0,0 +1,12 @@ +export const getMobileMarkdownFenceLanguage = (sourceInfo: unknown) => + typeof sourceInfo === "string" ? sourceInfo.trim().split(/\s+/)[0]?.toLowerCase() ?? "" : ""; + +export const trimMobileMarkdownFenceContent = (content: string) => + content.endsWith("\n") ? content.slice(0, -1) : content; + +export const getMermaidSvgAspectRatio = (svg: string) => { + const viewBox = /viewBox=["']\s*[-\d.]+\s+[-\d.]+\s+([\d.]+)\s+([\d.]+)\s*["']/i.exec(svg); + const width = Number(viewBox?.[1]); + const height = Number(viewBox?.[2]); + return width > 0 && height > 0 ? width / height : 1.6; +}; diff --git a/apps/mobile/src/mermaid-global.d.ts b/apps/mobile/src/mermaid-global.d.ts new file mode 100644 index 00000000..93737c6c --- /dev/null +++ b/apps/mobile/src/mermaid-global.d.ts @@ -0,0 +1 @@ +declare module "mermaid/dist/mermaid.min.js"; diff --git a/apps/mobile/src/screens/WorkspaceScreen.tsx b/apps/mobile/src/screens/WorkspaceScreen.tsx index 55224dc0..60752e65 100644 --- a/apps/mobile/src/screens/WorkspaceScreen.tsx +++ b/apps/mobile/src/screens/WorkspaceScreen.tsx @@ -68,6 +68,7 @@ import { RefreshControl, ScrollView, StyleSheet, + Text as RNText, type StyleProp, Switch, useWindowDimensions, @@ -125,6 +126,8 @@ import { showEdgeEverKeyboard } from "../../modules/edgeever-keyboard"; import LocalTiptapEditor, { type LocalTiptapEditorRef } from "../components/LocalTiptapEditor"; import { resolveMobileThemeStyles, useMobileTheme, type MobileResolvedTheme } from "../lib/mobile-theme"; import { MobileUpdateCard } from "../components/MobileUpdateCard"; +import { MobileMermaidDiagram, MobileMermaidProvider } from "../components/MobileMermaid"; +import { getMobileMarkdownFenceLanguage, trimMobileMarkdownFenceContent } from "../lib/mobile-mermaid"; const ALL_NOTES_ID = "all"; const DEFAULT_MEMO_TITLE = "无标题笔记"; @@ -4025,6 +4028,7 @@ const MemoDetailModal = ({ }) => { const { session } = useSession(); const { resolvedTheme } = useMobileTheme(); + const { resolvedLocale } = useMobileLocale(); const { width: viewportWidth } = useWindowDimensions(); const safeAreaInsets = useSafeAreaInsets(); const [actionsOpen, setActionsOpen] = useState(false); @@ -4069,6 +4073,21 @@ const MemoDetailModal = ({ }; return { + fence: (node, _children, _parents, markdownStyles, inheritedStyles = {}) => { + const language = getMobileMarkdownFenceLanguage((node as ASTNode & { sourceInfo?: string }).sourceInfo); + const content = trimMobileMarkdownFenceContent(node.content); + if (language === "mermaid") { + return ( + + ); + } + return {content}; + }, image: (node, _children, _parents, markdownStyles) => ( renderTableCell(node, children, parents, markdownStyles, false), th: (node, children, parents, markdownStyles) => renderTableCell(node, children, parents, markdownStyles, true), }; - }, [session, viewportWidth]); + }, [resolvedLocale, resolvedTheme, session, viewportWidth]); const detailText = memo?.contentMarkdown || memo?.contentText || "没有正文内容"; const searchMatches = useMemo(() => getTextSearchMatches(detailText, searchQuery), [detailText, searchQuery]); const searchMatchLabel = searchQuery.trim() ? `${searchMatches.length > 0 ? activeMatchIndex + 1 : 0}/${searchMatches.length}` : "0/0"; @@ -4221,7 +4240,9 @@ const MemoDetailModal = ({ {searchOpen && searchQuery.trim() ? ( ) : ( - {detailText} + + {detailText} + )} ) : ( diff --git a/apps/web/package.json b/apps/web/package.json index c0b45fcc..f625c385 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,6 +5,7 @@ "type": "module", "dependencies": { "@tiptap/extension-code-block-lowlight": "3.27.1", - "lowlight": "^3.3.0" + "lowlight": "^3.3.0", + "mermaid": "^11.16.0" } } diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index 54dce634..6e69ba3b 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -2,7 +2,6 @@ import { useRef, useState, useEffect, useCallback, useMemo, type PointerEvent as import { useQueryClient, useMutation } from "@tanstack/react-query"; import { NodeViewWrapper, ReactNodeViewRenderer, useEditor, EditorContent, type Editor, type NodeViewProps } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; -import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; import Image from "@tiptap/extension-image"; import Placeholder from "@tiptap/extension-placeholder"; import { TableKit } from "@tiptap/extension-table"; @@ -75,7 +74,7 @@ import { clampImageWidth, parseImageWidth, } from "@edgeever/shared/image-display"; -import { codeBlockLowlight } from "@/lib/code-block"; +import { codeBlockLowlight, EdgeEverCodeBlock } from "@/lib/code-block"; import { compressImageForUpload } from "@/lib/image-compression"; import { localDb, type MemoUpdateSyncPayload } from "@/lib/local-db"; import { getMemoUpdateQueueId, isMemoUpdateAlreadyApplied, queueMemoUpdate, shouldQueueMemoSaveError } from "@/lib/sync-queue"; @@ -1329,7 +1328,7 @@ const RichEditorPane = ({ const editor = useEditor({ extensions: [ StarterKit.configure({ codeBlock: false }), - CodeBlockLowlight.configure({ lowlight: codeBlockLowlight, defaultLanguage: "plaintext" }), + EdgeEverCodeBlock.configure({ lowlight: codeBlockLowlight, defaultLanguage: "plaintext" }), ResizableImage.configure({ allowBase64: false, inline: false, diff --git a/apps/web/src/components/EditorToolbar.tsx b/apps/web/src/components/EditorToolbar.tsx index 62e0f5b8..72163361 100644 --- a/apps/web/src/components/EditorToolbar.tsx +++ b/apps/web/src/components/EditorToolbar.tsx @@ -12,6 +12,7 @@ import { ListOrdered, Quote, SquareCode, + Workflow, Minus, } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -99,6 +100,30 @@ const toggleCodeBlock = (editor: Editor) => { .run(); }; +const insertMermaidDiagram = (editor: Editor) => { + if (editor.isActive("codeBlock")) { + editor.chain().focus().updateAttributes("codeBlock", { language: "mermaid" }).run(); + return; + } + + const { from, to } = editor.state.selection; + const selectedText = editor.state.doc.textBetween(from, to, "\n", "\n").trim(); + const source = selectedText || "flowchart LR\n A[Start] --> B[End]"; + + editor + .chain() + .focus() + .insertContentAt( + { from, to }, + { + type: "codeBlock", + attrs: { language: "mermaid" }, + content: [{ type: "text", text: source }], + } + ) + .run(); +}; + export const EditorToolbar = ({ editor, readOnly, @@ -315,6 +340,14 @@ export const EditorToolbar = ({ > + run(insertMermaidDiagram)} + > + + {showCodeLanguageSelector && (