From 377a0daa198d0df51c82559f938d8b0b1bed041d Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Tue, 16 Jun 2026 17:04:36 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(wall=20paper):=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E4=BA=86wallpaper=E7=9A=84=E8=A1=A8=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/src-tauri/capabilities/default.json | 7 +++++++ apps/desktop/src-tauri/capabilities/windows-wallpaper.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index 73aced1..f76d964 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -5,6 +5,13 @@ "windows": ["main", "panel", "wallpaper", "settings"], "permissions": [ "core:default", + "core:window:allow-hide", + "core:window:allow-primary-monitor", + "core:window:allow-set-focus", + "core:window:allow-set-position", + "core:window:allow-set-size", + "core:window:allow-show", + "core:window:allow-start-dragging", "opener:default", "window-state:default" ] diff --git a/apps/desktop/src-tauri/capabilities/windows-wallpaper.json b/apps/desktop/src-tauri/capabilities/windows-wallpaper.json index ee75dad..40b87d1 100644 --- a/apps/desktop/src-tauri/capabilities/windows-wallpaper.json +++ b/apps/desktop/src-tauri/capabilities/windows-wallpaper.json @@ -3,7 +3,7 @@ "identifier": "windows-wallpaper", "description": "Wallpaper plugin — Windows only", "platforms": ["windows"], - "windows": ["wallpaper"], + "windows": ["main", "wallpaper"], "permissions": [ "wallpaper:default" ] From 0126b1e46151a2d9a64aa087f6bdc9d667b4fc7e Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Fri, 19 Jun 2026 17:13:57 +0800 Subject: [PATCH 2/2] =?UTF-8?q?@=20feat(knowledge):=20v3.3=20=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E7=9F=A5=E8=AF=86=E5=B7=A5=E4=BD=9C=E7=A9=BA=E9=97=B4?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=E8=BD=AC=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将产品主轴从"智能网关 + Agent 连接枢纽"转型为"本地知识工作空间": 产品方向: - 重命名能力层: Gateway Layer → Knowledge & AI Layer - 删除 Mode C 分布式远程宿主挂载 - 移除 OpenClaw 自动打桩与 ClawGatewayCard - 里程碑重排: v2=知识工作空间, v3=知识扩展与长期陪伴 架构变更: - 向量方案从 LanceDB/hnswlib-wasm 迁移至 SQLite FTS5 + sqlite-vec - 聊天模型与 Embedding Provider 分开配置 - 明确降级策略: Embedding 不可用时回退全文检索 新增功能: - 知识工作空间窗口 (knowledge label, 1280x820) - 知识组件: ProjectSidebar, NotesPane, BoardPane, TasksPane 等 - Markdown 编辑器: 基于 Tiptap, 支持斜杠命令和标签建议 - KnowledgeEntryCard 面板入口卡片 - KnowledgeSection 设置面板 - Markdown 往返测试 (24 个用例) Closes #4 @ --- README.md | 24 +- apps/desktop/package.json | 13 + apps/desktop/src-tauri/tauri.conf.json | 16 + apps/desktop/src/App.vue | 21 +- .../src/components/knowledge/BoardCard.vue | 24 + .../src/components/knowledge/BoardColumn.vue | 22 + .../src/components/knowledge/BoardPane.vue | 27 + .../components/knowledge/IndexStatusDot.vue | 18 + .../components/knowledge/KnowledgeTopbar.vue | 62 ++ .../src/components/knowledge/NoteList.vue | 32 + .../src/components/knowledge/NotePreview.vue | 52 ++ .../src/components/knowledge/NotesPane.vue | 27 + .../components/knowledge/ProjectSidebar.vue | 51 + .../src/components/knowledge/TasksPane.vue | 37 + .../markdown-editor/EditorContent.vue | 159 ++++ .../markdown-editor/EditorToolbar.vue | 115 +++ .../markdown-editor/MarkdownEditor.vue | 151 +++ .../markdown-editor/editor/PreservedBlock.ts | 217 +++++ .../components/markdown-editor/editor/Tag.ts | 163 ++++ .../markdown-editor/editor/extensions.ts | 53 ++ .../markdown-editor/editor/markdownCodec.ts | 70 ++ .../markdown-editor/editor/markdownStyles.ts | 50 + .../markdown-editor/hooks/useEditorStore.ts | 14 + .../markdown-editor/hooks/useNoteInit.ts | 32 + .../plain-editor/PlainEditor.vue | 278 ++++++ .../state/createEditorStore.ts | 68 ++ .../markdown-editor/state/editorMode.ts | 24 + .../suggestion/SlashCommand.ts | 104 +++ .../suggestion/SuggestionMenu.vue | 80 ++ .../suggestion/TagSuggestion.ts | 64 ++ .../suggestion/createSuggestionRenderer.ts | 79 ++ .../markdown-editor/types/editorController.ts | 24 + .../src/components/panel/ComponentsButton.vue | 2 +- .../src/components/panel/cards/AiChatCard.vue | 104 ++- .../panel/cards/ClawGatewayCard.vue | 42 - .../panel/cards/KnowledgeEntryCard.vue | 49 + .../src/components/pet/PermissionBubble.vue | 1 - .../components/settings/SettingsSidebar.vue | 6 + .../settings/sections/HookSection.vue | 7 +- .../settings/sections/KnowledgeSection.vue | 84 ++ .../src/composables/useKnowledgeMock.ts | 311 +++++++ apps/desktop/src/composables/useSettings.ts | 23 +- apps/desktop/src/main.ts | 2 + apps/desktop/src/styles/knowledge.css | 754 +++++++++++++++ apps/desktop/src/styles/markdown.css | 268 ++++++ apps/desktop/src/styles/panel.css | 230 +++-- apps/desktop/src/styles/permission-bubble.css | 2 - apps/desktop/src/views/KnowledgeView.vue | 73 ++ apps/desktop/src/views/SettingsView.vue | 2 + .../markdown-corpus/preserved/block-html.md | 7 + .../markdown-corpus/preserved/inline-html.md | 1 + .../markdown-corpus/preserved/math-block.md | 7 + .../markdown-corpus/preserved/math-inline.md | 1 + .../markdown-corpus/preserved/table.md | 4 + .../markdown-corpus/supported/basic.md | 7 + .../supported/blockquote-mixed.md | 7 + .../markdown-corpus/supported/cjk-emoji.md | 9 + .../markdown-corpus/supported/code-blocks.md | 16 + .../markdown-corpus/supported/headings.md | 13 + .../markdown-corpus/supported/lists.md | 11 + .../markdown-corpus/supported/task-lists.md | 8 + apps/desktop/tests/markdown-roundtrip.test.ts | 50 + apps/desktop/tests/setup.ts | 22 + apps/desktop/vitest.config.ts | 13 + docs/ARCHITECTURE.md | 484 +++++----- docs/PRD_overview.md | 61 +- ...75\345\212\233\346\236\204\346\200\235.md" | 130 +-- pnpm-lock.yaml | 878 +++++++++++++++++- 68 files changed, 5338 insertions(+), 522 deletions(-) create mode 100644 apps/desktop/src/components/knowledge/BoardCard.vue create mode 100644 apps/desktop/src/components/knowledge/BoardColumn.vue create mode 100644 apps/desktop/src/components/knowledge/BoardPane.vue create mode 100644 apps/desktop/src/components/knowledge/IndexStatusDot.vue create mode 100644 apps/desktop/src/components/knowledge/KnowledgeTopbar.vue create mode 100644 apps/desktop/src/components/knowledge/NoteList.vue create mode 100644 apps/desktop/src/components/knowledge/NotePreview.vue create mode 100644 apps/desktop/src/components/knowledge/NotesPane.vue create mode 100644 apps/desktop/src/components/knowledge/ProjectSidebar.vue create mode 100644 apps/desktop/src/components/knowledge/TasksPane.vue create mode 100644 apps/desktop/src/components/markdown-editor/EditorContent.vue create mode 100644 apps/desktop/src/components/markdown-editor/EditorToolbar.vue create mode 100644 apps/desktop/src/components/markdown-editor/MarkdownEditor.vue create mode 100644 apps/desktop/src/components/markdown-editor/editor/PreservedBlock.ts create mode 100644 apps/desktop/src/components/markdown-editor/editor/Tag.ts create mode 100644 apps/desktop/src/components/markdown-editor/editor/extensions.ts create mode 100644 apps/desktop/src/components/markdown-editor/editor/markdownCodec.ts create mode 100644 apps/desktop/src/components/markdown-editor/editor/markdownStyles.ts create mode 100644 apps/desktop/src/components/markdown-editor/hooks/useEditorStore.ts create mode 100644 apps/desktop/src/components/markdown-editor/hooks/useNoteInit.ts create mode 100644 apps/desktop/src/components/markdown-editor/plain-editor/PlainEditor.vue create mode 100644 apps/desktop/src/components/markdown-editor/state/createEditorStore.ts create mode 100644 apps/desktop/src/components/markdown-editor/state/editorMode.ts create mode 100644 apps/desktop/src/components/markdown-editor/suggestion/SlashCommand.ts create mode 100644 apps/desktop/src/components/markdown-editor/suggestion/SuggestionMenu.vue create mode 100644 apps/desktop/src/components/markdown-editor/suggestion/TagSuggestion.ts create mode 100644 apps/desktop/src/components/markdown-editor/suggestion/createSuggestionRenderer.ts create mode 100644 apps/desktop/src/components/markdown-editor/types/editorController.ts delete mode 100644 apps/desktop/src/components/panel/cards/ClawGatewayCard.vue create mode 100644 apps/desktop/src/components/panel/cards/KnowledgeEntryCard.vue create mode 100644 apps/desktop/src/components/settings/sections/KnowledgeSection.vue create mode 100644 apps/desktop/src/composables/useKnowledgeMock.ts create mode 100644 apps/desktop/src/styles/knowledge.css create mode 100644 apps/desktop/src/styles/markdown.css create mode 100644 apps/desktop/src/views/KnowledgeView.vue create mode 100644 apps/desktop/tests/fixtures/markdown-corpus/preserved/block-html.md create mode 100644 apps/desktop/tests/fixtures/markdown-corpus/preserved/inline-html.md create mode 100644 apps/desktop/tests/fixtures/markdown-corpus/preserved/math-block.md create mode 100644 apps/desktop/tests/fixtures/markdown-corpus/preserved/math-inline.md create mode 100644 apps/desktop/tests/fixtures/markdown-corpus/preserved/table.md create mode 100644 apps/desktop/tests/fixtures/markdown-corpus/supported/basic.md create mode 100644 apps/desktop/tests/fixtures/markdown-corpus/supported/blockquote-mixed.md create mode 100644 apps/desktop/tests/fixtures/markdown-corpus/supported/cjk-emoji.md create mode 100644 apps/desktop/tests/fixtures/markdown-corpus/supported/code-blocks.md create mode 100644 apps/desktop/tests/fixtures/markdown-corpus/supported/headings.md create mode 100644 apps/desktop/tests/fixtures/markdown-corpus/supported/lists.md create mode 100644 apps/desktop/tests/fixtures/markdown-corpus/supported/task-lists.md create mode 100644 apps/desktop/tests/markdown-roundtrip.test.ts create mode 100644 apps/desktop/tests/setup.ts create mode 100644 apps/desktop/vitest.config.ts diff --git a/README.md b/README.md index ff6d2e4..c327b5b 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # NeoCompanion -[![Status](https://img.shields.io/badge/Status-Draft--Approved--v3.2-blue.svg)](#) -[![Version](https://img.shields.io/badge/Version-3.2-green.svg)](#) +[![Status](https://img.shields.io/badge/Status-Draft--Approved--v3.3-blue.svg)](#) +[![Version](https://img.shields.io/badge/Version-3.3-green.svg)](#) [![License](https://img.shields.io/badge/License-MIT-purple.svg)](#) > 一个融入桌面的 AI 助手——状态融入壁纸,轻交互近在手边,编辑留给面板。 @@ -92,7 +92,7 @@ echo '{"state":"success","description":"Build pass"}' > ~/.NeoCompanion/hooks/bu ### 🔒 本地优先,隐私可控 -所有数据存储在本地,不上传云端。窗口检测可开关,应用黑名单可配置,发送给 AI 的内容可审查。不需要注册账户。 +业务数据和索引默认存储在本地,不需要注册账户。窗口检测可开关,应用黑名单可配置;只有用户启用云端 Chat 或 Embedding Provider 后,对应的问答上下文或待向量化文本才会发送给该服务,并在设置中明确展示边界。 --- @@ -106,7 +106,7 @@ NeoCompanion 的能力由浅入深分为四层: |-------------------------------------------------------| | 2. Routine Layer (番茄钟 / 待办清单 / 天气 / 助手日志) | |-------------------------------------------------------| -| 3. Gateway Layer (内置原子能力 / OpenClaw / LLM 对话) | +| 3. Knowledge & AI (项目 / 笔记 / 看板 / 混合检索 / AI) | |-------------------------------------------------------| | 4. Hook & System (安全 Hook API / 本地状态感知与记忆) | +-------------------------------------------------------+ @@ -116,7 +116,7 @@ NeoCompanion 的能力由浅入深分为四层: **日常琐碎陪伴层**——陪伴式番茄钟、助手待办清单、天气碎碎念、助手工作日志。 -**智能网关与对话层**——内置基础 Agent(文件读写、网页搜索、剪贴板获取);复杂跨软件任务委托给 [OpenClaw](https://github.com/openclaw/openclaw);日常提问直连大模型 API。 +**知识与 AI 层**——在单一本地工作空间中组织项目、Markdown 笔记、任务与看板;通过全文检索和向量检索为 AI 对话提供可核验的本地上下文。 **Hook 与系统层**——安全本地多通道 Hook(HTTP / UDS / File Watcher / MQTT);浮动权限审批气泡;本地隐私感知引擎;本地长期记忆。 @@ -128,13 +128,13 @@ NeoCompanion 的能力由浅入深分为四层: 桌面常驻悬浮助手的基本 2D 精灵图形态、语音 TTS 播报。助手番茄钟、待办、天气碎碎念。壁纸层 MVP(天气时间 + 专注主控盘 + 热区轻交互)。Hook 角标通知。 -### 🔌 v2:开放 Hook 极客生态 +### 📚 v2:本地知识工作空间 -安全本地 Hook API(HTTP + File Watcher 多通道推送)。零端口 UDS / Named Pipe 本地挂载。浮动权限审批气泡 + 全局热键(`Ctrl+Shift+Y` / `Ctrl+Shift+N`)。AI 对话面板完善(一键拾取剪贴板报错)。OpenClaw 配置自动打桩。 +项目、Markdown 笔记、统一任务与看板形成可用闭环。SQLite FTS5 提供本地全文搜索,`sqlite-vec` 提供可选向量检索;AI 回答展示可点击来源。Embedding 未配置或失败时自动降级为全文搜索。通用 Hook API 与权限审批能力继续独立演进。 -### 🧠 v3:完整智能网关与分布式远程挂载 +### 🧠 v3:知识增强的长期陪伴 -分布式远程宿主挂载(将 LanceDB、SQLite 托管至 NAS / 云服务器)。深度打通 OpenClaw 跨软件执行网关。助手性格记忆演进,形成有长期偏好的情感交互。 +增加本地文件夹同步、多格式导入和本地 embedding 模型;将用户主动维护的知识与助手长期记忆分开治理,并在明确授权下形成有来源、可追溯的个性化交互。 --- @@ -145,10 +145,10 @@ NeoCompanion 的能力由浅入深分为四层: | 桌面运行时 | **Tauri v2** (Rust) | | 前端 UI | **Vue 3** + Vite + Pinia + TanStack Query | | 本地服务 | **Fastify** (TypeScript Sidecar) | -| 数据库 | **SQLite** (Drizzle ORM) + 向量检索 (hnswlib-wasm / LanceDB) | -| AI | 多模型适配器 (DeepSeek / OpenAI / Claude),用户可自定义 | +| 数据库 | **SQLite** (Drizzle ORM + FTS5) + **sqlite-vec** | +| AI | 聊天模型适配器 + OpenAI-compatible Embedding Adapter | -架构核心:Tauri (Rust) 提供系统级能力 → Fastify (TypeScript) 处理业务逻辑和 AI 调度 → Vue 提供 UI → SQLite + 向量检索本地存储全部数据。 +架构核心:Tauri (Rust) 提供系统级能力 → Fastify (TypeScript) 处理业务逻辑、索引与 AI 调度 → Vue 提供 UI → SQLite 统一存储业务数据、全文索引和向量索引。 详见 [**系统架构设计**](docs/ARCHITECTURE.md)。 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d5b149e..a2158fc 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -13,11 +13,24 @@ "dependencies": { "@neo-companion/shared": "workspace:*", "@tauri-apps/api": "^2.9.0", + "@tiptap/core": "3.26.0", + "@tiptap/extension-heading": "3.26.0", + "@tiptap/extension-list": "3.26.0", + "@tiptap/extensions": "3.26.0", + "@tiptap/markdown": "3.26.0", + "@tiptap/pm": "3.26.0", + "@tiptap/starter-kit": "3.26.0", + "@tiptap/suggestion": "3.26.0", + "@tiptap/vue-3": "3.26.0", + "marked": "^17.0.6", + "textarea-caret": "^3.1.0", "vue": "3.5.34" }, "devDependencies": { "@tauri-apps/cli": "2.11.2", + "@types/textarea-caret": "^3.0.3", "@vitejs/plugin-vue": "^6.0.2", + "jsdom": "^29.1.1", "typescript": "6.0.3", "vite": "8.0.14", "vitest": "4.1.7", diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 8f8f81a..5f8477c 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -71,6 +71,22 @@ "skipTaskbar": false, "visible": false, "center": true + }, + { + "title": "NeoCompanion 知识工作空间", + "label": "knowledge", + "url": "/?view=knowledge", + "width": 1280, + "height": 820, + "minWidth": 1024, + "minHeight": 680, + "transparent": false, + "decorations": true, + "alwaysOnTop": false, + "resizable": true, + "skipTaskbar": false, + "visible": false, + "center": true } ], "security": { diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 307c312..ed65e78 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -21,7 +21,7 @@ import TopNav from "./components/panel/TopNav.vue"; import CreateFocusCard from "./components/panel/cards/CreateFocusCard.vue"; import TodayTasksCard from "./components/panel/cards/TodayTasksCard.vue"; import WeeklyFocusCard from "./components/panel/cards/WeeklyFocusCard.vue"; -import ClawGatewayCard from "./components/panel/cards/ClawGatewayCard.vue"; +import KnowledgeEntryCard from "./components/panel/cards/KnowledgeEntryCard.vue"; import AssistantStatusCard from "./components/panel/cards/AssistantStatusCard.vue"; import HookNotificationsCard from "./components/panel/cards/HookNotificationsCard.vue"; import AiChatCard from "./components/panel/cards/AiChatCard.vue"; @@ -33,20 +33,22 @@ import PageDots from "./components/panel/PageDots.vue"; import ErrorToast from "./components/shared/ErrorToast.vue"; import WallpaperView from "./views/WallpaperView.vue"; import SettingsView from "./views/SettingsView.vue"; +import KnowledgeView from "./views/KnowledgeView.vue"; import { useSettings } from "./composables/useSettings"; const viewMode = new URLSearchParams(window.location.search).get("view"); const isPetView = viewMode === "pet"; const isWallpaperView = viewMode === "wallpaper"; const isSettingsView = viewMode === "settings"; +const isKnowledgeView = viewMode === "knowledge"; const isTauriRuntime = "__TAURI_INTERNALS__" in window; -type PanelPage = "focus" | "tasks" | "weekly" | "claw" | "assistant" | "hooks" | "chat" | "diary"; +type PanelPage = "focus" | "tasks" | "weekly" | "knowledge" | "assistant" | "hooks" | "chat" | "diary"; const panelPages: Array<{ id: PanelPage; label: string }> = [ { id: "focus", label: "专注" }, { id: "tasks", label: "任务" }, { id: "weekly", label: "本周" }, - { id: "claw", label: "OpenClaw" }, + { id: "knowledge", label: "知识库" }, { id: "assistant", label: "助手" }, { id: "hooks", label: "Hook" }, { id: "chat", label: "AI 对话" }, @@ -75,7 +77,7 @@ const contextMenu = ref<{ x: number; y: number } | null>(null); let disconnectWs: (() => void) | null = null; onMounted(async () => { - if (isSettingsView) { + if (isSettingsView || isKnowledgeView) { return; } if (isWallpaperView) { @@ -162,6 +164,13 @@ async function openSettings() { await settings?.setFocus(); } +async function openKnowledge() { + if (!isTauriRuntime) return; + const knowledge = await WebviewWindow.getByLabel("knowledge"); + await knowledge?.show(); + await knowledge?.setFocus(); +} + async function setWallpaperLayerVisible(visible: boolean) { if (!isTauriRuntime) return; @@ -294,6 +303,8 @@ function toggleImmersive() { + +
@@ -331,7 +342,7 @@ function toggleImmersive() { - + diff --git a/apps/desktop/src/components/knowledge/BoardCard.vue b/apps/desktop/src/components/knowledge/BoardCard.vue new file mode 100644 index 0000000..58ec4f2 --- /dev/null +++ b/apps/desktop/src/components/knowledge/BoardCard.vue @@ -0,0 +1,24 @@ + + +
{{ task.title }}
+
+ {{ tag }} + + + + + + + 笔记 + +
+ + diff --git a/apps/desktop/src/components/knowledge/BoardColumn.vue b/apps/desktop/src/components/knowledge/BoardColumn.vue new file mode 100644 index 0000000..225a0a1 --- /dev/null +++ b/apps/desktop/src/components/knowledge/BoardColumn.vue @@ -0,0 +1,22 @@ + + + diff --git a/apps/desktop/src/components/knowledge/BoardPane.vue b/apps/desktop/src/components/knowledge/BoardPane.vue new file mode 100644 index 0000000..d381c35 --- /dev/null +++ b/apps/desktop/src/components/knowledge/BoardPane.vue @@ -0,0 +1,27 @@ + + + diff --git a/apps/desktop/src/components/knowledge/IndexStatusDot.vue b/apps/desktop/src/components/knowledge/IndexStatusDot.vue new file mode 100644 index 0000000..771edd8 --- /dev/null +++ b/apps/desktop/src/components/knowledge/IndexStatusDot.vue @@ -0,0 +1,18 @@ + + + diff --git a/apps/desktop/src/components/knowledge/KnowledgeTopbar.vue b/apps/desktop/src/components/knowledge/KnowledgeTopbar.vue new file mode 100644 index 0000000..b7f0c98 --- /dev/null +++ b/apps/desktop/src/components/knowledge/KnowledgeTopbar.vue @@ -0,0 +1,62 @@ + + + diff --git a/apps/desktop/src/components/knowledge/NoteList.vue b/apps/desktop/src/components/knowledge/NoteList.vue new file mode 100644 index 0000000..5db9125 --- /dev/null +++ b/apps/desktop/src/components/knowledge/NoteList.vue @@ -0,0 +1,32 @@ + + + diff --git a/apps/desktop/src/components/knowledge/NotePreview.vue b/apps/desktop/src/components/knowledge/NotePreview.vue new file mode 100644 index 0000000..ee3018d --- /dev/null +++ b/apps/desktop/src/components/knowledge/NotePreview.vue @@ -0,0 +1,52 @@ + + + diff --git a/apps/desktop/src/components/knowledge/NotesPane.vue b/apps/desktop/src/components/knowledge/NotesPane.vue new file mode 100644 index 0000000..aac5790 --- /dev/null +++ b/apps/desktop/src/components/knowledge/NotesPane.vue @@ -0,0 +1,27 @@ + + + diff --git a/apps/desktop/src/components/knowledge/ProjectSidebar.vue b/apps/desktop/src/components/knowledge/ProjectSidebar.vue new file mode 100644 index 0000000..e421c87 --- /dev/null +++ b/apps/desktop/src/components/knowledge/ProjectSidebar.vue @@ -0,0 +1,51 @@ + + + diff --git a/apps/desktop/src/components/knowledge/TasksPane.vue b/apps/desktop/src/components/knowledge/TasksPane.vue new file mode 100644 index 0000000..3e5a3d9 --- /dev/null +++ b/apps/desktop/src/components/knowledge/TasksPane.vue @@ -0,0 +1,37 @@ + + + diff --git a/apps/desktop/src/components/markdown-editor/EditorContent.vue b/apps/desktop/src/components/markdown-editor/EditorContent.vue new file mode 100644 index 0000000..c02d35b --- /dev/null +++ b/apps/desktop/src/components/markdown-editor/EditorContent.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/apps/desktop/src/components/markdown-editor/EditorToolbar.vue b/apps/desktop/src/components/markdown-editor/EditorToolbar.vue new file mode 100644 index 0000000..006d58b --- /dev/null +++ b/apps/desktop/src/components/markdown-editor/EditorToolbar.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/apps/desktop/src/components/markdown-editor/MarkdownEditor.vue b/apps/desktop/src/components/markdown-editor/MarkdownEditor.vue new file mode 100644 index 0000000..d9ddbf5 --- /dev/null +++ b/apps/desktop/src/components/markdown-editor/MarkdownEditor.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/apps/desktop/src/components/markdown-editor/editor/PreservedBlock.ts b/apps/desktop/src/components/markdown-editor/editor/PreservedBlock.ts new file mode 100644 index 0000000..c177b9d --- /dev/null +++ b/apps/desktop/src/components/markdown-editor/editor/PreservedBlock.ts @@ -0,0 +1,217 @@ +import type { MarkdownParseHelpers, MarkdownToken } from "@tiptap/core"; +import { Extension, Mark, mergeAttributes, Node } from "@tiptap/core"; +import type { Tokens } from "marked"; +import { marked } from "marked"; + +/** + * Fidelity workhorse for syntax the WYSIWYG editor does not model (tables, + * math, raw HTML). Constructs are captured at parse time with their raw + * markdown source, shown as editable literal mono text, and re-emitted + * byte-for-byte on serialize. `code: true` keeps the serializer from + * escaping/entity-encoding the inner text. + */ +export const PreservedBlock = Node.create({ + name: "preservedBlock", + group: "block", + content: "text*", + marks: "", + code: true, + defining: true, + + parseHTML() { + return [{ tag: "pre[data-preserved-block]", preserveWhitespace: "full" }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "pre", + mergeAttributes(HTMLAttributes, { + "data-preserved-block": "", + class: "font-mono text-sm opacity-80 whitespace-pre-wrap my-0", + }), + 0, + ]; + }, + + // The literal source is the node's text; Document joins blocks with \n\n. + renderMarkdown: (node, helpers) => (node.content ? helpers.renderChildren(node.content) : ""), +}); + +/** Inline counterpart: a `code: true` mark that emits its text verbatim. */ +export const PreservedInline = Mark.create({ + name: "preservedInline", + code: true, + + parseHTML() { + return [{ tag: "span[data-preserved-inline]" }]; + }, + + renderHTML({ HTMLAttributes }) { + return ["span", mergeAttributes(HTMLAttributes, { "data-preserved-inline": "", class: "font-mono opacity-80" }), 0]; + }, + + // No delimiters: the preserved text already contains its own syntax. + renderMarkdown: (node, helpers) => (node.content ? helpers.renderChildren(node.content) : ""), +}); + +// Note: `MarkdownParseResult` in @tiptap/core 3.26.0 does not admit `null`; +// the manager treats an empty array as "handler declined" (it requires +// `normalized.length > 0` before accepting a result), so `[]` is the +// type-safe way to fall through to the next handler / fallback. +function preservedBlockFromToken(token: MarkdownToken, helpers: MarkdownParseHelpers) { + const source = (token.raw ?? "").replace(/\n+$/, ""); + if (!source) { + return []; + } + return helpers.createNode("preservedBlock", {}, [helpers.createTextNode(source)]); +} + +/** Inline counterpart: the token's raw source as preservedInline-marked text. */ +function preservedInlineFromToken(token: MarkdownToken, helpers: MarkdownParseHelpers) { + return helpers.createTextNode(token.raw ?? "", [{ type: "preservedInline" }]); +} + +/** Routes marked's `table` tokens into preservedBlock instead of dropping them. */ +export const PreservedTableBridge = Extension.create({ + name: "preservedTableBridge", + markdownTokenName: "table", + parseMarkdown: preservedBlockFromToken, +}); + +/** + * Routes block-level raw HTML into preservedBlock. Inline HTML never reaches + * the handler registry (the markdown manager intercepts it and would convert + * recognized tags like into marks), so inline tags are captured earlier + * by the custom tokenizer in PreservedHtmlInlineBridge below. + */ +export const PreservedHtmlBlockBridge = Extension.create({ + name: "preservedHtmlBlockBridge", + markdownTokenName: "html", + parseMarkdown: (token, helpers) => { + if (!token.block) { + return []; + } + return preservedBlockFromToken(token, helpers); + }, +}); + +function tokenizePreservedHtmlInline(src: string): Tokens.Generic | undefined { + // One HTML comment, or one opening/closing/self-closing HTML tag. For + // tags, the first character after `<` (or `` — this never + // matches autolinks such as or + // (`:`/`@` break the match). Attribute values are matched quote-aware so + // `>` inside a quoted value (e.g. data-x="1 > 0") does not end the tag. + // The alternatives are disjoint: the catch-all class [^<>"'] excludes both + // quote characters so it cannot overlap with the quoted branches, which + // eliminates catastrophic backtracking on unterminated tags with many quotes. + const match = /^(?:|<\/?[a-zA-Z][a-zA-Z0-9-]*(?:\s(?:"[^"]*"|'[^']*'|[^<>"'])*)?\/?>)/.exec(src); + if (!match) { + return undefined; + } + return { type: "preservedHtmlInline", raw: match[0], text: match[0] }; +} + +function tokenizePreservedMathBlock(src: string): Tokens.Generic | undefined { + // Anchored to line geometry: opens at line start (guaranteed by marked + // handing us `src` from the current block position) and closes with `$$` + // at end-of-line. Trailing newlines are NOT consumed into `raw` (the + // `(?=\n|$)` lookahead): marked tolerates this for block tokens — its + // "space" tokenizer absorbs the leftover `\n` and the following block + // tokenizes normally (verified by the round-trip tests). Either way, + // "$$\nx\n$$\nnext" serializes as "$$\nx\n$$\n\nnext": the \n\n comes + // from the Document serializer joining sibling blocks, and + // preservedBlockFromToken strips trailing \n from raw regardless — so + // we keep raw minimal rather than swallow separators we don't re-emit. + const match = /^\$\$[\s\S]+?\$\$(?=\n|$)/.exec(src); + if (!match) { + return undefined; + } + return { type: "preservedMathBlock", raw: match[0], text: match[0] }; +} + +function tokenizePreservedMathInline(src: string): Tokens.Generic | undefined { + const match = /^\$[^$\n]+\$/.exec(src); + if (!match) { + return undefined; + } + return { type: "preservedMathInline", raw: match[0], text: match[0] }; +} + +/** + * The three preserved-syntax tokenizers are registered DIRECTLY on the global + * marked singleton, module-scope and idempotent — the Tag.ts pattern (see the + * rationale there). Routing them through each Extension's `markdownTokenizer` + * would make every `new Editor()` re-register them on the shared `marked` + * instance forever (the accumulation hazard documented in markdownCodec.ts). + * The Extensions below keep `markdownTokenName` + `parseMarkdown`, which is + * all the manager needs to route the tokens. + */ +let preservedTokenizersRegistered = false; +function registerPreservedTokenizers() { + if (preservedTokenizersRegistered) { + return; + } + preservedTokenizersRegistered = true; + marked.use({ + extensions: [ + { + name: "preservedHtmlInline", + level: "inline", + start: (src: string) => src.indexOf("<"), + tokenizer: tokenizePreservedHtmlInline, + }, + { + name: "preservedMathBlock", + level: "block", + // Marked truncates the preceding paragraph at whatever index `start` + // returns, even if `tokenizer` then declines — so only report `$$` that + // sits at the start of a line, never a mid-line `$$` (e.g. "costs $$"). + // Marked invokes block `start` callbacks exclusively from its paragraph + // interrupt check, on `src.slice(1)` — index 0 of the string we receive + // is always mid-line, so a `^`-anchored branch would false-positive + // (e.g. "$$$$" → "$\n$\n$$"). Only a `$$` right after `\n` is line-start. + start: (src: string) => { + const match = /\n\$\$/.exec(src); + return match ? match.index + 1 : -1; + }, + tokenizer: tokenizePreservedMathBlock, + }, + { + name: "preservedMathInline", + level: "inline", + start: (src: string) => src.indexOf("$"), + tokenizer: tokenizePreservedMathInline, + }, + ], + }); +} +registerPreservedTokenizers(); + +export const PreservedHtmlInlineBridge = Extension.create({ + name: "preservedHtmlInlineBridge", + markdownTokenName: "preservedHtmlInline", + parseMarkdown: preservedInlineFromToken, +}); + +export const PreservedMathBlockBridge = Extension.create({ + name: "preservedMathBlockBridge", + markdownTokenName: "preservedMathBlock", + parseMarkdown: preservedBlockFromToken, +}); + +export const PreservedMathInlineBridge = Extension.create({ + name: "preservedMathInlineBridge", + markdownTokenName: "preservedMathInline", + parseMarkdown: preservedInlineFromToken, +}); + +export const preservedExtensions = [ + PreservedBlock, + PreservedInline, + PreservedTableBridge, + PreservedHtmlBlockBridge, + PreservedHtmlInlineBridge, + PreservedMathBlockBridge, + PreservedMathInlineBridge, +]; diff --git a/apps/desktop/src/components/markdown-editor/editor/Tag.ts b/apps/desktop/src/components/markdown-editor/editor/Tag.ts new file mode 100644 index 0000000..515e6cc --- /dev/null +++ b/apps/desktop/src/components/markdown-editor/editor/Tag.ts @@ -0,0 +1,163 @@ +import type { MarkdownToken } from "@tiptap/core"; +import { InputRule, Mark, mergeAttributes } from "@tiptap/core"; +import type { TokenizerThis, Tokens } from "marked"; +import { marked } from "marked"; +import { tagStyles } from "./markdownStyles"; + +// Default tag pill, shared with the read-only view. +// Computed once — renderHTML runs on every view update. +const TAG_CLASS = `${tagStyles.base} ${tagStyles.defaultColor}`.trim(); + +// Mirrors the renderer's tag lexer (web/src/utils/remark-plugins/remark-tag.ts): +// letters, numbers, symbols, plus _ - / &, max 100 chars. Keep the two in sync. +const TAG_CHAR = "[\\p{L}\\p{N}\\p{S}_\\-/&]"; +const TAG_INPUT_RULE = new RegExp(`(?:^|\\s)#(${TAG_CHAR}{1,100})\\s$`, "u"); +const TAG_TOKEN_RULE = new RegExp(`^#(${TAG_CHAR}{1,100})`, "u"); +// Tests the REMAINDER of the source (not a single code unit) so astral-plane +// tag characters (emoji et al.) are seen whole, not as lone surrogates. +const TAG_CHAR_AHEAD = new RegExp(`^${TAG_CHAR}`, "u"); + +/** + * Tag tokenizer, registered DIRECTLY on the global marked singleton instead of + * through `markdownTokenizer`. Two reasons: + * + * 1. `@tiptap/markdown`'s MarkdownManager wraps `markdownTokenizer.tokenize` + * in `tokenizer(src, tokens) { ... tokenize(src, tokens, helper) }` — the + * wrapper receives marked's TokenizerThis (with `lexer.state.inLink`) but + * does NOT forward it, so a manager-registered tokenizer can never know it + * is inside a link label. Registered natively, marked invokes us with + * `this.lexer` bound and we can decline inside `[label](url)` the same way + * remark-tag skips link nodes. + * 2. The manager defaults to the global `marked` export (`markedInstance = + * options?.marked ?? marked`) and `web` resolves the exact same marked + * module instance as `@tiptap/markdown` does, so this registration is + * visible to every lexer the manager creates. Module scope + idempotent: + * registered exactly once per page load (the manager's own per-Editor + * `marked.use` calls are the accumulation hazard documented in + * markdownCodec.ts; this adds a single registration, ever). + * + * The Tag mark below still declares `markdownTokenName: "memoTag"` + + * `parseMarkdown`, which is all the manager needs to route the token. + */ +function tokenizeTag(this: TokenizerThis, src: string): Tokens.Generic | undefined { + // remark-tag skips link nodes entirely; marked sets `state.inLink` while + // tokenizing link/reflink labels, so declining here keeps `[see #x](url)` + // a plain link label instead of tearing the tag out of it. + if (this.lexer?.state?.inLink) { + return undefined; + } + const match = TAG_TOKEN_RULE.exec(src); + if (!match) { + return undefined; + } + const rest = src.slice(match[0].length); + // `#a#b`: decline when the run is directly followed by another `#`, so the + // FIRST run stays plain text. NOT full remark parity — remark-tag treats + // `#a#b` as two tags and `##x` as all-text, while here `#a#b` becomes text + // "#a" + tag "b" and `##x` becomes text "#" + tag "x". The divergence is + // visual-only in the editor: both shapes serialize back byte-identically + // (the surrounding text nodes re-emit their literal characters). + if (rest.startsWith("#")) { + return undefined; + } + // Runs longer than 100 tag characters are not tags at all in remark-tag + // (the whole run stays plain text) — decline instead of splitting the run + // into a 100-char tag plus leftover text. + if (TAG_CHAR_AHEAD.test(rest)) { + return undefined; + } + return { type: "memoTag", raw: match[0], text: match[0], tag: match[1] }; +} + +let tagTokenizerRegistered = false; +function registerTagTokenizer() { + if (tagTokenizerRegistered) { + return; + } + tagTokenizerRegistered = true; + marked.use({ + extensions: [ + { + name: "memoTag", + level: "inline", + start: (src: string) => src.indexOf("#"), + tokenizer: tokenizeTag, + }, + ], + }); +} +registerTagTokenizer(); + +/** + * Mark for memos `#tags`: styled in the editor, serialized back to `#tag` + * verbatim. Modeled as a `code: true` text mark (the PreservedInline pattern, + * see PreservedBlock.ts) rather than an inline atom node: text marks compose + * through the serializer's mark open/close machinery, so a tag inside + * `**bold**` or a heading round-trips byte-identically, and `code: true` + * keeps the literal text (`#a_b`) unescaped. Inline atoms cannot do either — + * `applyMarkToContent` only attaches enclosing marks to text nodes, so an + * atom inside bold silently drops the bold delimiters. + * + * Parsed live while typing (input rule) and from markdown (the native marked + * tokenizer above). + */ +export const Tag = Mark.create({ + name: "tag", + // Typing immediately after a tag must not extend it. + inclusive: false, + // Serializer emits the inner text verbatim, without escaping. + code: true, + + addAttributes() { + return { + // The tag name without `#`, for downstream consumers (e.g. suggestion + // insertion). Stored as `tag` but rendered/parsed as `data-tag`. + tag: { + default: "", + parseHTML: (element) => element.getAttribute("data-tag") ?? "", + renderHTML: (attributes) => ({ "data-tag": attributes.tag }), + }, + }; + }, + + parseHTML() { + return [{ tag: "span[data-tag]" }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "span", + mergeAttributes(HTMLAttributes, { + class: TAG_CLASS, + }), + 0, + ]; + }, + + addInputRules() { + return [ + new InputRule({ + find: TAG_INPUT_RULE, + handler: ({ state, range, match }) => { + // The match may include a leading separator — only mark from the + // `#`. The trailing space stays outside the mark; because the + // handler produces steps the typed space is suppressed, so it is + // re-inserted explicitly after the marked range. + const start = range.from + match[0].indexOf("#"); + state.tr + .addMark(start, range.to, this.type.create({ tag: match[1] })) + .insertText(" ", range.to) + .removeStoredMark(this.type); + }, + }), + ]; + }, + + markdownTokenName: "memoTag", + parseMarkdown: (token, helpers) => { + const t = token as MarkdownToken & { tag?: string }; + return helpers.createTextNode(t.raw ?? "", [{ type: "tag", attrs: { tag: t.tag ?? "" } }]); + }, + // No delimiters: the literal `#tag` text carries the syntax. + renderMarkdown: (node, helpers) => (node.content ? helpers.renderChildren(node.content) : ""), +}); diff --git a/apps/desktop/src/components/markdown-editor/editor/extensions.ts b/apps/desktop/src/components/markdown-editor/editor/extensions.ts new file mode 100644 index 0000000..ac759bd --- /dev/null +++ b/apps/desktop/src/components/markdown-editor/editor/extensions.ts @@ -0,0 +1,53 @@ +import { type AnyExtension, mergeAttributes } from "@tiptap/core"; +import { Heading } from "@tiptap/extension-heading"; +import { TaskItem, TaskList } from "@tiptap/extension-list"; +import { Markdown } from "@tiptap/markdown"; +import StarterKit from "@tiptap/starter-kit"; +import { type HeadingLevel, headingClass, markdownStyles } from "./markdownStyles"; +import { preservedExtensions } from "./PreservedBlock"; +import { Tag } from "./Tag"; + +/** + * StarterKit's Heading is bundled and cannot vary classes by level via static + * HTMLAttributes, so we disable it (heading: false) and render headings here. + * renderHTML only affects the editable DOM — heading markdown serialization is + * unchanged — so the round-trip codec is unaffected. + */ +const StyledHeading = Heading.extend({ + renderHTML({ node, HTMLAttributes }) { + const { levels } = this.options; + const level = (levels.includes(node.attrs.level) ? node.attrs.level : levels[0]) as HeadingLevel; + return [`h${level}`, mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { class: headingClass(level) }), 0]; + }, +}); + +/** + * The canonical schema-relevant extension set, shared by the live editor and + * the headless markdown codec so that parse/serialize behavior is identical + * in both. UI-only extensions (Placeholder, suggestion popups) are added by + * the editor component on top of this list and must never affect the schema. + */ +export function buildExtensions(): AnyExtension[] { + return [ + StarterKit.configure({ + heading: false, + link: { openOnClick: false, HTMLAttributes: { class: markdownStyles.link } }, + // Markdown has no underline syntax; keeping the extension would let + // Ctrl+U create marks that cannot serialize. Out of the schema entirely. + underline: false, + paragraph: { HTMLAttributes: { class: markdownStyles.paragraph } }, + blockquote: { HTMLAttributes: { class: markdownStyles.blockquote } }, + bulletList: { HTMLAttributes: { class: markdownStyles.bulletList } }, + orderedList: { HTMLAttributes: { class: markdownStyles.orderedList } }, + listItem: { HTMLAttributes: { class: markdownStyles.listItem } }, + code: { HTMLAttributes: { class: markdownStyles.inlineCode } }, + horizontalRule: { HTMLAttributes: { class: markdownStyles.horizontalRule } }, + }), + StyledHeading.configure({ levels: [1, 2, 3, 4, 5, 6] }), + TaskList, + TaskItem.configure({ nested: true }), + Markdown, + ...preservedExtensions, + Tag, + ]; +} diff --git a/apps/desktop/src/components/markdown-editor/editor/markdownCodec.ts b/apps/desktop/src/components/markdown-editor/editor/markdownCodec.ts new file mode 100644 index 0000000..27086f6 --- /dev/null +++ b/apps/desktop/src/components/markdown-editor/editor/markdownCodec.ts @@ -0,0 +1,70 @@ +import { Editor, type JSONContent } from "@tiptap/core"; +import { buildExtensions } from "./extensions"; + +/** + * Headless markdown ⇄ ProseMirror document helpers built on the exact same + * extension list as the live editor. Used by the round-trip corpus tests and + * the memo-open load guard. + * + * WHY A SINGLETON: + * `@tiptap/markdown`'s MarkdownManager registers each extension's + * `markdownTokenizer` onto the global `marked` singleton via `marked.use(...)` + * inside every `new Editor(...)` construction, and `editor.destroy()` never + * unregisters those tokenizers. `@tiptap/extension-list` ships 2 block + * tokenizers, so every per-call editor construction leaks 2 registrations onto + * the global marked instance. The resulting accumulation causes parse time to + * degrade measurably (observed: ~0.7 s → 14.5 s over ~120 calls). The + * preserved-syntax bridges (PreservedBlock.ts) add more custom tokenizers per + * Editor construction — each live editor mount registers them globally too — + * so the codec singleton keeps the tests/load-guard pinned at one registration. + * + * WHY NOT `Markdown.configure({ marked: new Marked() })`: + * Upstream `MarkdownManager.createLexer()` calls + * `new this.markedInstance.Lexer()` without passing the options object that + * carries the instance-registered tokenizer extensions, so any tokenizers + * registered on the private Marked instance are silently ignored. Task-list + * syntax breaks: `- [ ] open` round-trips to `- open`. + * + * The singleton is created lazily on first use and reused forever. + */ +let _singletonEditor: Editor | null = null; + +function getSingletonEditor(): Editor { + if (!_singletonEditor) { + _singletonEditor = new Editor({ + extensions: buildExtensions(), + content: "", + contentType: "markdown", + }); + } + return _singletonEditor; +} + +function getMarkdownManager() { + const editor = getSingletonEditor(); + if (!editor.markdown) { + throw new Error("markdownCodec: editor.markdown is not available — ensure @tiptap/markdown is in the extension list"); + } + return editor.markdown; +} + +export function parseMarkdown(markdown: string): JSONContent { + return getMarkdownManager().parse(markdown); +} + +export function roundTripMarkdown(markdown: string): string { + const manager = getMarkdownManager(); + return manager.serialize(manager.parse(markdown)); +} + +/** True when a serialize cycle would not change the document's meaning. */ +export function isLosslessRoundTrip(markdown: string): boolean { + try { + const manager = getMarkdownManager(); + const once = manager.parse(markdown); + const twice = manager.parse(manager.serialize(once)); + return JSON.stringify(once) === JSON.stringify(twice); + } catch { + return false; + } +} diff --git a/apps/desktop/src/components/markdown-editor/editor/markdownStyles.ts b/apps/desktop/src/components/markdown-editor/editor/markdownStyles.ts new file mode 100644 index 0000000..ebe563b --- /dev/null +++ b/apps/desktop/src/components/markdown-editor/editor/markdownStyles.ts @@ -0,0 +1,50 @@ +/** + * 共享样式 token,被编辑器(extensions.ts 通过 HTMLAttributes.class)与渲染(同一份 + * extensions.ts 复用)双方使用,保证编辑/浏览视觉一致。 + * + * 对应 wynxing/memos 的 web/src/lib/markdownStyles.ts。memos 用 Tailwind className + * 字符串,NeoCompanion 不使用 Tailwind,所以这里全部映射到普通 CSS 类(前缀 + * `nc-md-*`),具体规则定义在 apps/desktop/src/styles/markdown.css。 + */ + +export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6; + +const headingClasses: Record = { + 1: "nc-md-h1", + 2: "nc-md-h2", + 3: "nc-md-h3", + 4: "nc-md-h4", + 5: "nc-md-h5", + 6: "nc-md-h6", +}; + +/** + * 编辑器与渲染共享的 markdown 元素样式 class。每个值都是完整可独立使用的 class, + * 既可以通过 Tiptap 的 HTMLAttributes 注入,也可以渲染 DOM 时直接挂上。 + */ +export const markdownStyles = { + paragraph: "nc-md-paragraph", + blockquote: "nc-md-blockquote", + bulletList: "nc-md-bullet-list", + orderedList: "nc-md-ordered-list", + listItem: "nc-md-list-item", + taskList: "nc-md-task-list", + taskItem: "nc-md-task-item", + inlineCode: "nc-md-inline-code", + codeBlock: "nc-md-code-block", + link: "nc-md-link", + horizontalRule: "nc-md-hr", + strong: "nc-md-strong", + emphasis: "nc-md-emphasis", +} as const; + +/** 标签 pill 共享样式(编辑模式 + 渲染模式都用同一组 class)。 */ +export const tagStyles = { + base: "nc-md-tag-pill", + defaultColor: "", +} as const; + +/** 给定 heading level 返回完整 class。 */ +export function headingClass(level: HeadingLevel): string { + return headingClasses[level]; +} diff --git a/apps/desktop/src/components/markdown-editor/hooks/useEditorStore.ts b/apps/desktop/src/components/markdown-editor/hooks/useEditorStore.ts new file mode 100644 index 0000000..d1fabb6 --- /dev/null +++ b/apps/desktop/src/components/markdown-editor/hooks/useEditorStore.ts @@ -0,0 +1,14 @@ +import { inject } from "vue"; +import { EDITOR_STORE_KEY, type EditorStore } from "../state/createEditorStore"; + +/** + * 在 MarkdownEditor.vue 内部 provide('editorStore', store) 之后,子组件 + * (EditorContent / PlainEditor / EditorToolbar)通过这个 hook 取出 store。 + */ +export function useEditorStore(): EditorStore { + const store = inject(EDITOR_STORE_KEY); + if (!store) { + throw new Error("useEditorStore() called outside an EditorStore provider"); + } + return store; +} diff --git a/apps/desktop/src/components/markdown-editor/hooks/useNoteInit.ts b/apps/desktop/src/components/markdown-editor/hooks/useNoteInit.ts new file mode 100644 index 0000000..ed12d23 --- /dev/null +++ b/apps/desktop/src/components/markdown-editor/hooks/useNoteInit.ts @@ -0,0 +1,32 @@ +import { watch, type Ref } from "vue"; +import { isLosslessRoundTrip } from "../editor/markdownCodec"; +import type { EditorStore } from "../state/createEditorStore"; + +/** + * Load-guard:当 body 包含会被 markdown round-trip 改变的语法时,本会话强制降级到 + * plain 模式(不写 storage),并把 `loadGuardDegraded` 置 true 触发 banner。 + * + * 对应 wynxing/memos `hooks/useMemoInit.ts`;本期没有 toast 系统,banner 由 + * MarkdownEditor.vue 顶部条幅承载。 + * + * 当 body 变化(切换到下一篇 note)时重新校验。 + */ +export function useNoteInit(opts: { body: Ref; store: EditorStore }): void { + const { body, store } = opts; + + function check(): void { + const text = body.value ?? ""; + if (!text.trim()) { + store.loadGuardDegraded.value = false; + return; + } + if (store.editorMode.value === "wysiwyg" && !isLosslessRoundTrip(text)) { + console.warn("[useNoteInit] note content failed wysiwyg round-trip; falling back to plain editor for this session"); + store.forceDegradeToPlain(); + } else { + store.loadGuardDegraded.value = false; + } + } + + watch(body, check, { immediate: true }); +} diff --git a/apps/desktop/src/components/markdown-editor/plain-editor/PlainEditor.vue b/apps/desktop/src/components/markdown-editor/plain-editor/PlainEditor.vue new file mode 100644 index 0000000..95a2e29 --- /dev/null +++ b/apps/desktop/src/components/markdown-editor/plain-editor/PlainEditor.vue @@ -0,0 +1,278 @@ + + +