diff --git a/.agent/skills/README.md b/.agent/skills/README.md new file mode 100644 index 0000000..d710ec5 --- /dev/null +++ b/.agent/skills/README.md @@ -0,0 +1,123 @@ +# milkup 项目专属 Skills + +这个目录包含了为 milkup 项目定制的 Claude Code skills,用于简化常见的开发任务。 + +## 可用的 Skills + +### 1. milkup-build +**用途**:构建和打包应用 + +用于构建 milkup 应用的不同平台版本(Windows、macOS、Linux),支持多种架构(x64、arm64)。 + +**使用方式**: +```bash +/milkup-build +``` + +**主要功能**: +- 开发构建 +- 生产构建 +- 平台特定构建(Windows/macOS/Linux) +- 多架构支持(x64/arm64) + +--- + +### 2. milkup-dev +**用途**:开发环境管理 + +用于启动开发服务器、运行 Electron 应用、进行热重载调试等开发任务。 + +**使用方式**: +```bash +/milkup-dev +``` + +**主要功能**: +- 启动 Vite 开发服务器 +- 启动 Electron 应用 +- 热重载支持 +- 开发调试工具 + +--- + +### 3. milkup-lint +**用途**:代码质量检查 + +使用 oxlint 和 oxfmt 进行代码检查和格式化,确保代码符合项目规范。 + +**使用方式**: +```bash +/milkup-lint +``` + +**主要功能**: +- 代码语法检查 +- 代码格式化 +- 自动修复问题 +- Git 钩子集成 + +--- + +### 4. milkup-release +**用途**:版本发布管理 + +管理版本号、生成更新日志、创建发布标签等版本发布相关任务。 + +**使用方式**: +```bash +/milkup-release +``` + +**主要功能**: +- 版本号管理(遵循 Semver) +- 自动生成更新日志 +- 创建 Git 标签 +- 发布流程指导 + +--- + +## 如何使用 + +在 Claude Code 中,你可以通过以下方式使用这些 skills: + +1. **直接调用**:在对话中输入 `/skill-name`,例如 `/milkup-build` +2. **自然语言**:描述你想做的事情,Claude 会自动选择合适的 skill + - "帮我构建 Windows 版本" → 自动使用 milkup-build + - "启动开发环境" → 自动使用 milkup-dev + - "检查代码质量" → 自动使用 milkup-lint + - "发布新版本" → 自动使用 milkup-release + +## Skills 之间的关系 + +``` +milkup-dev (开发) + ↓ +milkup-lint (检查) + ↓ +milkup-build (构建) + ↓ +milkup-release (发布) +``` + +典型的工作流程: +1. 使用 `milkup-dev` 启动开发环境进行开发 +2. 使用 `milkup-lint` 检查和格式化代码 +3. 使用 `milkup-build` 构建应用 +4. 使用 `milkup-release` 发布新版本 + +## 自定义和扩展 + +这些 skills 都是开源的,你可以根据项目需要进行修改和扩展: + +1. 编辑 `.agent/skills//SKILL.md` 文件 +2. 添加新的命令或工作流程 +3. 调整配置以适应项目变化 + +## 贡献 + +如果你创建了新的有用的 skill 或改进了现有的 skill,欢迎提交 PR 分享给社区! + +--- + +**注意**:这些 skills 是专门为 milkup 项目设计的,使用了项目特定的配置和工具。 +如果你想在其他项目中使用类似的 skills,需要根据具体项目进行调整。 diff --git a/.agent/skills/milkup-dev/SKILL.md b/.agent/skills/milkup-dev/SKILL.md new file mode 100644 index 0000000..cae59b4 --- /dev/null +++ b/.agent/skills/milkup-dev/SKILL.md @@ -0,0 +1,600 @@ +--- +name: milkup-dev +description: > + milkup 项目开发环境管理专家,深入了解项目的技术栈(Electron + Vue 3 + Milkdown + TypeScript)、 + 架构设计和开发工作流。负责启动开发服务器、运行 Electron 应用、热重载调试等开发任务。 +license: MIT +compatibility: Designed for Claude Code +allowed-tools: Bash Read Grep Glob +metadata: + version: "2.0.0" + category: "development" + status: "active" + updated: "2026-01-30" + user-invocable: "true" + tags: "milkup, electron, vue3, milkdown, typescript, vite, development" + related-skills: "milkup-build, milkup-lint, moai-framework-electron" +--- + +# milkup 开发环境工具 + +## 快速参考 + +milkup-dev 是专门为 milkup 项目设计的开发环境管理工具,深入了解项目的技术栈和架构。 + +milkup 是一个现代化的桌面端 Markdown 编辑器,基于 Electron 37+ 构建,使用 Vue 3 作为前端框架, +Milkdown 作为编辑器核心,支持所见即所得(WYSIWYG)和源码编辑双模式。 + +自动触发:当用户请求启动开发环境、运行应用、进行开发调试或询问技术栈相关问题时。 + +### 技术栈概览 + +核心框架: + +- Electron 37+ - 桌面应用框架(Chromium 130 + Node.js 20.18) +- Vue 3.5+ - 渐进式前端框架,使用 Composition API +- TypeScript 5.9+ - 类型安全的 JavaScript 超集,启用 strict 模式 +- Vite 7+ - 下一代前端构建工具,提供极速的 HMR + +编辑器核心: + +- Milkdown 7.17+ - 基于 ProseMirror 的插件化 Markdown 编辑器 +- @milkdown/crepe - Milkdown 的所见即所得(WYSIWYG)模式 +- @milkdown/kit - Milkdown 完整插件套件 +- CodeMirror 6 - 源码编辑模式的代码编辑器 +- Vditor 3.11+ - 额外的 Markdown 编辑功能支持 + +构建工具链: + +- Vite - 渲染进程构建和开发服务器 +- esbuild - 主进程和预加载脚本的快速构建 +- vite-plugin-electron - Electron 与 Vite 的集成插件 +- electron-builder - 多平台应用打包工具 + +国际化与工具: + +- vite-auto-i18n-plugin - 自动国际化翻译插件 +- oxlint - 快速的 JavaScript/TypeScript 代码检查工具 +- oxfmt - 快速的代码格式化工具 +- simple-git-hooks - 轻量级 Git 钩子管理 + +UI 与增强: + +- @vueuse/core - Vue 组合式 API 工具集 +- vue-draggable-plus - 拖拽功能支持 +- autodialog.js - 对话框管理 +- autotoast.js - 通知提示管理 +- mermaid - 图表渲染支持 + +### 项目架构 + +进程架构: + +milkup 遵循 Electron 的多进程架构。主进程(Main Process)运行在 Node.js 环境中, +负责窗口管理、文件系统访问、IPC 通信处理和系统集成。渲染进程(Renderer Process) +运行在 Chromium 中,负责 UI 渲染和用户交互,通过预加载脚本(Preload Script) +与主进程进行安全的 IPC 通信。 + +目录结构: + +``` +src/ +├── main/ # 主进程代码 +│ ├── index.ts # 主进程入口,窗口创建和生命周期管理 +│ ├── ipcBridge/ # IPC 通信处理器 +│ ├── menu/ # 应用菜单定义 +│ └── update/ # 自动更新逻辑 +├── renderer/ # 渲染进程代码(Vue 应用) +│ ├── App.vue # 根组件 +│ ├── main.ts # 渲染进程入口 +│ ├── components/ # Vue 组件 +│ │ ├── editor/ # 编辑器组件(Milkdown、CodeMirror) +│ │ ├── workspace/ # 工作区和标签页管理 +│ │ ├── outline/ # 文档大纲导航 +│ │ ├── menu/ # 菜单栏和状态栏 +│ │ ├── settings/ # 设置页面 +│ │ ├── dialogs/ # 对话框组件 +│ │ └── ui/ # 通用 UI 组件 +│ ├── hooks/ # Vue 组合式函数 +│ ├── services/ # 业务逻辑服务 +│ ├── plugins/ # Milkdown 插件 +│ ├── styles/ # 样式文件 +│ └── utils/ # 工具函数 +├── preload.ts # 预加载脚本,暴露安全的 API 给渲染进程 +├── shared/ # 主进程和渲染进程共享的类型和常量 +├── types/ # TypeScript 类型定义 +├── themes/ # 主题系统 +├── plugins/ # 应用级插件 +└── config/ # 配置文件 +``` + +双窗口系统: + +milkup 实现了双窗口架构。主窗口(Main Window)是主要的编辑器界面, +包含编辑器、工作区、大纲和菜单栏。主题编辑器窗口(Theme Editor Window) +是一个独立的模态窗口,用于自定义和编辑主题样式。两个窗口通过 IPC 通信 +共享状态和数据。 + +路径别名配置: + +项目配置了三个路径别名以简化导入: +- `@/` - 指向 `src/` 目录,用于访问所有源代码 +- `@renderer/` - 指向 `src/renderer/` 目录,用于渲染进程代码 +- `@ui/` - 指向 `src/renderer/components/ui/` 目录,用于 UI 组件 + +### 核心功能特性 + +编辑器模式: + +milkup 支持两种编辑模式的无缝切换。所见即所得模式(WYSIWYG)使用 Milkdown +的 Crepe 主题,提供类似 Typora 的即时渲染体验。源码模式使用 CodeMirror 6, +提供语法高亮、代码折叠和 Markdown 语法支持。两种模式共享同一份文档数据, +切换时保持光标位置和滚动状态。 + +文件管理: + +应用支持多文件标签页管理,可以同时打开多个 Markdown 文件。工作区面板 +显示文件树,支持文件夹展开/折叠和文件拖拽。文件关联功能允许双击 .md 文件 +直接在 milkup 中打开。支持通过命令行参数启动时打开指定文件。 + +自定义协议: + +实现了 `milkup://` 自定义协议用于加载本地图片。协议格式为 +`milkup:////`, +解决了 Electron 中加载相对路径图片的安全限制问题。主进程注册协议处理器, +将相对路径解析为绝对路径并返回文件内容。 + +主题系统: + +支持多主题切换和自定义主题编辑。主题文件使用 Less 编写,支持变量和嵌套。 +主题编辑器提供可视化界面,可以实时预览主题效果。支持主题导入导出, +方便分享和备份。 + +国际化支持: + +使用 vite-auto-i18n-plugin 实现自动国际化。支持中文、英文、日文、韩文、 +俄文和法文。翻译文件自动生成,开发时只需编写中文文本,构建时自动翻译 +为其他语言。 + +### 常用命令 + +启动开发服务器: +```bash +pnpm run dev +``` +此命令会依次执行: +1. 构建预加载脚本(esbuild) +2. 构建主进程代码(esbuild) +3. 启动 Vite 开发服务器 +4. 自动启动 Electron 应用(通过 vite-plugin-electron) + +启动 Electron 应用: +```bash +pnpm run start:electron +``` +此命令用于单独启动 Electron 应用,不启动 Vite 开发服务器。 + +构建主进程: +```bash +pnpm run build:main +``` + +构建预加载脚本: +```bash +pnpm run build:preload +``` + +--- + +## 实现指南 + +### 开发环境设置 + +环境要求: + +开发 milkup 需要以下环境: +- Node.js >= 20.17.0(项目使用 Node.js 20.18 的特性) +- pnpm >= 10.0.0(项目使用 pnpm 10.12.1) +- Git(用于版本控制) +- 支持的操作系统:Windows、macOS 或 Linux + +依赖安装: + +首次克隆项目后,执行 `pnpm install` 安装所有依赖。项目配置了 preinstall +钩子,会检查是否使用 pnpm,如果使用其他包管理器会报错。安装过程中会自动 +执行 simple-git-hooks 的 prepare 脚本,安装 Git 钩子。 + +开发服务器启动流程: + +执行 `pnpm run dev` 后的完整流程: + +1. esbuild 构建预加载脚本(src/preload.ts → dist-electron/preload.js) +2. esbuild 构建主进程代码(src/main/index.ts → dist-electron/main/index.js) +3. Vite 启动开发服务器,监听 src/renderer 目录 +4. vite-plugin-electron 自动启动 Electron 主进程 +5. 主进程创建 BrowserWindow,加载 Vite 开发服务器的 URL +6. 渲染进程加载 Vue 应用,初始化 Milkdown 编辑器 +7. 开发模式下自动打开 DevTools + +环境变量: + +开发模式下,vite-plugin-electron 会设置 `VITE_DEV_SERVER_URL` 环境变量, +主进程通过此变量判断是否为开发模式。开发模式下加载 Vite 开发服务器的 URL, +生产模式下加载本地 HTML 文件。 + +### 编辑器集成详解 + +Milkdown 集成: + +Milkdown 是基于 ProseMirror 的插件化 Markdown 编辑器。项目使用 @milkdown/vue +提供的 Vue 组件集成。编辑器配置包括: +- @milkdown/preset-commonmark - CommonMark 规范支持 +- @milkdown/preset-gfm - GitHub Flavored Markdown 支持 +- @milkdown/plugin-prism - 代码块语法高亮 +- @milkdown/plugin-diagram - Mermaid 图表支持 +- @milkdown/plugin-listener - 编辑器事件监听 +- @milkdown/plugin-automd - 自动 Markdown 格式化 + +Milkdown 编辑器组件位于 src/renderer/components/editor/MilkdownEditor.vue。 +编辑器实例通过 Vue 的 provide/inject 机制在组件树中共享,允许其他组件 +访问编辑器 API 进行内容操作。 + +CodeMirror 集成: + +CodeMirror 6 用于源码编辑模式。配置包括: +- @codemirror/lang-markdown - Markdown 语法支持 +- @codemirror/autocomplete - 自动补全 +- @codemirror/commands - 编辑器命令 +- codemirror-lang-mermaid - Mermaid 语法支持 + +CodeMirror 编辑器组件位于 src/renderer/components/editor/MarkdownSourceEditor.vue。 +编辑器状态通过 @codemirror/state 管理,视图通过 @codemirror/view 渲染。 + +编辑器模式切换: + +两种编辑器模式共享同一份 Markdown 文本数据。切换时,当前编辑器的内容 +会保存到共享状态中,然后新编辑器从共享状态加载内容。使用防抖机制避免 +频繁切换导致的性能问题。 + +### IPC 通信模式 + +安全通信架构: + +milkup 遵循 Electron 安全最佳实践。渲染进程启用 contextIsolation, +禁用 nodeIntegration。预加载脚本使用 contextBridge.exposeInMainWorld +暴露安全的 API 给渲染进程。渲染进程通过 window.electronAPI 访问主进程功能。 + +IPC 通信类型: + +项目使用三种 IPC 通信模式: + +1. invoke/handle 模式 - 用于请求-响应通信。渲染进程调用 ipcRenderer.invoke, + 主进程通过 ipcMain.handle 注册处理器并返回结果。适用于文件读写、 + 对话框显示等需要返回值的操作。 + +2. send/on 模式 - 用于单向通信。渲染进程调用 ipcRenderer.send 发送消息, + 主进程通过 ipcMain.on 监听。适用于通知类操作,不需要返回值。 + +3. webContents.send 模式 - 主进程主动向渲染进程发送消息。用于文件打开、 + 窗口关闭确认等主进程发起的操作。 + +IPC 处理器组织: + +主进程的 IPC 处理器分为三类,位于 src/main/ipcBridge/ 目录: +- 全局处理器 - 不依赖窗口实例的处理器 +- Handle 处理器 - 需要返回值的请求-响应处理器 +- On 处理器 - 单向消息处理器 + +### 热重载机制 + +渲染进程热重载: + +Vite 提供的 HMR(Hot Module Replacement)支持渲染进程的热重载。 +修改 Vue 组件、样式文件或 TypeScript 代码时,Vite 会通过 WebSocket +推送更新,浏览器端的 HMR 客户端接收更新并替换模块,无需刷新整个页面。 + +Vue 组件的热重载由 @vitejs/plugin-vue 处理,支持保持组件状态。 +修改组件模板或脚本时,只重新渲染受影响的组件,保持应用状态不变。 + +主进程热重载: + +vite-plugin-electron 监听主进程和预加载脚本的文件变化。检测到变化时, +自动重新构建并重启 Electron 进程。这个过程会关闭当前窗口并创建新窗口, +因此会丢失应用状态。 + +为了减少主进程重启的影响,建议将业务逻辑尽量放在渲染进程,主进程只 +处理必要的系统集成和 IPC 通信。 + +### 调试技巧 + +渲染进程调试: + +开发模式下,应用启动时会自动打开 Chrome DevTools。也可以通过快捷键 +Ctrl+Shift+I(macOS: Cmd+Option+I)或全局快捷键 Ctrl+Shift+I 打开。 + +DevTools 提供完整的调试功能: +- Console 面板 - 查看日志输出和执行 JavaScript 代码 +- Sources 面板 - 设置断点、单步调试、查看调用栈 +- Elements 面板 - 检查 DOM 结构和样式 +- Network 面板 - 监控网络请求(虽然桌面应用较少使用) +- Performance 面板 - 性能分析和火焰图 +- Memory 面板 - 内存分析和泄漏检测 + +Vue DevTools 集成: + +可以安装 Vue DevTools 浏览器扩展的独立版本用于调试 Vue 应用。 +在 DevTools 中可以查看组件树、组件状态、Vuex/Pinia 状态、事件追踪等。 + +主进程调试: + +主进程运行在 Node.js 环境中,可以使用 Node.js 调试器。有两种方式: + +1. VS Code 调试配置 - 在 .vscode/launch.json 中配置调试配置, + 设置 Electron 可执行文件路径和启动参数,可以在 VS Code 中 + 直接设置断点调试主进程代码。 + +2. 远程调试 - 使用 --inspect 或 --inspect-brk 标志启动 Electron, + 然后在 Chrome 中访问 chrome://inspect 连接调试器。 + +日志输出策略: + +主进程的 console.log 输出到终端,渲染进程的 console.log 输出到 DevTools。 +建议使用不同的日志前缀区分不同模块,例如 `[main]`、`[renderer]`、`[ipc]` 等。 + +对于复杂的调试场景,可以使用 electron-log 库统一管理日志,支持日志级别、 +文件输出和格式化。 + +Vue 组件调试: + +在 Vue 组件中可以使用以下技巧: +- 使用 `console.log` 在生命周期钩子中输出状态 +- 使用 Vue DevTools 查看组件的 props、data 和 computed +- 使用 `debugger` 语句设置断点 +- 使用 `watchEffect` 追踪响应式数据变化 + +Milkdown 调试: + +Milkdown 编辑器的调试较为复杂,因为涉及 ProseMirror 的内部状态。 +可以通过以下方式调试: +- 使用 @milkdown/plugin-listener 监听编辑器事件 +- 访问编辑器实例的 `ctx` 对象查看插件状态 +- 使用 ProseMirror DevTools 查看文档结构和事务 + +### 故障排查 + +常见开发问题: + +**端口占用**: +Vite 默认使用 5173 端口。如果端口被占用,Vite 会自动尝试下一个端口。 +检查终端输出确认实际使用的端口号。如果需要固定端口,在 vite.config.mts +中配置 `server.port`。 + +**白屏问题**: +如果应用启动后显示白屏,按以下步骤排查: +1. 打开 DevTools 查看 Console 是否有错误信息 +2. 检查 Network 面板确认资源是否正确加载 +3. 确认预加载脚本路径正确(dist-electron/preload.js) +4. 检查主进程终端输出是否有错误 +5. 确认 Vite 开发服务器正常运行 + +**热重载失效**: +如果修改代码后页面没有更新: +1. 确认 Vite 开发服务器正常运行,检查终端是否有错误 +2. 检查修改的文件是否在 Vite 监听的目录范围内(src/renderer) +3. 尝试手动刷新页面(Ctrl+R 或 Cmd+R) +4. 检查是否有语法错误导致 HMR 失败 +5. 重启开发服务器 + +**IPC 通信失败**: +如果渲染进程无法调用主进程功能: +1. 确认预加载脚本正确暴露了 API(使用 contextBridge) +2. 检查 IPC 通道名称是否匹配 +3. 确认主进程已注册对应的处理器 +4. 检查 contextIsolation 是否正确启用 +5. 在 DevTools Console 中检查 window.electronAPI 是否存在 + +**编辑器加载失败**: +如果 Milkdown 或 CodeMirror 编辑器无法正常显示: +1. 检查编辑器组件是否正确挂载 +2. 确认编辑器样式文件已正确导入 +3. 检查 Console 是否有插件加载错误 +4. 确认编辑器容器元素存在且有正确的尺寸 +5. 检查是否有 CSS 冲突影响编辑器样式 + +**构建失败**: +如果 esbuild 或 Vite 构建失败: +1. 检查 TypeScript 类型错误 +2. 确认所有导入路径正确 +3. 检查是否有循环依赖 +4. 确认 node_modules 完整安装 +5. 尝试删除 node_modules 和 pnpm-lock.yaml 重新安装 + +**主题不生效**: +如果自定义主题没有应用: +1. 检查主题文件路径是否正确 +2. 确认 Less 文件语法正确 +3. 检查主题变量是否正确覆盖 +4. 确认主题文件已在入口文件中导入 +5. 清除缓存并重新构建 + +### 开发工作流 + +典型开发流程: + +1. **启动开发环境** + - 执行 `pnpm run dev` 启动开发服务器 + - 等待 Electron 应用自动启动 + - 确认 DevTools 已打开 + +2. **功能开发** + - 在 src/renderer 中修改 Vue 组件或添加新组件 + - 保存文件后查看 HMR 自动更新效果 + - 使用 DevTools 调试和验证功能 + +3. **主进程开发** + - 修改 src/main 中的主进程代码 + - 保存后等待 Electron 自动重启 + - 在终端查看主进程日志输出 + +4. **IPC 通信开发** + - 在 src/main/ipcBridge 中添加 IPC 处理器 + - 在 src/preload.ts 中暴露 API + - 在渲染进程中通过 window.electronAPI 调用 + - 使用 TypeScript 类型确保类型安全 + +5. **样式调整** + - 修改 Less 文件或组件样式 + - 使用 DevTools Elements 面板实时调试样式 + - 保存后查看 HMR 更新效果 + +6. **代码检查** + - 定期执行 `pnpm run lint` 检查代码质量 + - 执行 `pnpm run format` 自动格式化代码 + - Git 提交前会自动运行 lint-staged + +7. **测试验证** + - 在开发环境中测试功能 + - 执行 `pnpm run build` 构建生产版本 + - 执行 `pnpm run start:electron` 测试构建产物 + +最佳实践: + +- 使用 TypeScript 类型系统避免类型错误 +- 遵循 Vue 3 Composition API 最佳实践 +- 保持组件单一职责,避免过大的组件 +- 使用 Vue 组合式函数(hooks)复用逻辑 +- IPC 通信使用类型安全的接口定义 +- 主进程代码保持简洁,业务逻辑放在渲染进程 +- 使用路径别名简化导入语句 +- 定期运行代码检查和格式化 +- 提交前确保代码通过 lint 检查 + +--- + +## 使用示例 + +### 启动开发环境 + +基本启动: +```bash +# 安装依赖(首次或依赖更新后) +pnpm install + +# 启动开发服务器和 Electron 应用 +pnpm run dev +``` + +应用会自动启动,DevTools 会自动打开。终端会显示 Vite 开发服务器的地址 +和构建信息。 + +单独构建主进程: +```bash +# 只构建主进程代码 +pnpm run build:main + +# 只构建预加载脚本 +pnpm run build:preload +``` + +### 开发新功能 + +添加新的 Vue 组件: +```bash +# 在 src/renderer/components 中创建新组件 +# 使用路径别名导入 +import MyComponent from '@renderer/components/MyComponent.vue' +``` + +添加新的 IPC 通信: +```typescript +// 1. 在 src/main/ipcBridge 中添加处理器 +ipcMain.handle('my-channel', async (event, arg) => { + // 处理逻辑 + return result +}) + +// 2. 在 src/preload.ts 中暴露 API +contextBridge.exposeInMainWorld('electronAPI', { + myFunction: (arg) => ipcRenderer.invoke('my-channel', arg) +}) + +// 3. 在渲染进程中调用 +const result = await window.electronAPI.myFunction(arg) +``` + +### 调试场景 + +调试渲染进程: +```bash +# 启动开发环境 +pnpm run dev + +# DevTools 会自动打开 +# 在 Sources 面板设置断点 +# 在 Console 面板执行代码 +``` + +调试主进程: +```bash +# 方式 1: 使用 VS Code 调试配置 +# 在 .vscode/launch.json 中配置后,按 F5 启动调试 + +# 方式 2: 使用 --inspect 标志 +# 修改 package.json 的 dev 脚本添加 --inspect +# 然后在 Chrome 中访问 chrome://inspect +``` + +### 测试构建 + +构建并测试: +```bash +# 完整构建 +pnpm run build + +# 启动构建后的应用 +pnpm run start:electron +``` + +这会加载 dist 目录中的构建产物,而不是 Vite 开发服务器。 +用于验证生产构建是否正常工作。 + +--- + +## 配合使用 + +- **milkup-build** - 构建生产版本和打包应用 +- **milkup-lint** - 代码质量检查和格式化 +- **milkup-release** - 版本发布和更新日志生成 +- **moai-framework-electron** - Electron 开发最佳实践和高级模式 + +--- + +## 技术资源 + +配置文件: +- vite.config.mts - Vite 和 Electron 插件配置 +- tsconfig.json - TypeScript 编译配置 +- package.json - 依赖和脚本配置 + +关键文件: +- src/main/index.ts - 主进程入口,窗口管理和生命周期 +- src/preload.ts - 预加载脚本,IPC API 暴露 +- src/renderer/main.ts - 渲染进程入口,Vue 应用初始化 +- src/renderer/App.vue - 根组件,应用布局 + +编辑器组件: +- src/renderer/components/editor/MilkdownEditor.vue - Milkdown 编辑器 +- src/renderer/components/editor/MarkdownSourceEditor.vue - CodeMirror 编辑器 + +官方文档: +- Electron 文档: https://www.electronjs.org/docs +- Vue 3 文档: https://vuejs.org/ +- Vite 文档: https://vitejs.dev/ +- Milkdown 文档: https://milkdown.dev/ +- CodeMirror 文档: https://codemirror.net/ + +--- + +Version: 2.0.0 +Last Updated: 2026-01-30 +Changes: 添加完整的技术栈说明、架构详解、开发工作流和调试指南 diff --git a/.agent/skills/moai-framework-electron/SKILL.md b/.agent/skills/moai-framework-electron/SKILL.md new file mode 100644 index 0000000..d3fbd45 --- /dev/null +++ b/.agent/skills/moai-framework-electron/SKILL.md @@ -0,0 +1,283 @@ +--- +name: moai-framework-electron +description: > + Electron 33+ desktop app development specialist covering Main/Renderer + process architecture, IPC communication, auto-update, packaging with + Electron Forge and electron-builder, and security best practices. Use when + building cross-platform desktop applications, implementing native OS + integrations, or packaging Electron apps for distribution. [KO: Electron + 데스크톱 앱, 크로스플랫폼 개발, IPC 통신] [JA: Electronデスクトップアプリ、 + クロスプラットフォーム開発] [ZH: Electron桌面应用、跨平台开发] +license: Apache-2.0 +compatibility: Designed for Claude Code +allowed-tools: Read Grep Glob mcp__context7__resolve-library-id mcp__context7__get-library-docs +metadata: + version: "2.0.0" + category: "framework" + status: "active" + updated: "2026-01-10" + modularized: "false" + user-invocable: "false" + tags: "electron, desktop, cross-platform, nodejs, chromium, ipc, auto-update, electron-builder, electron-forge" + context7-libraries: "/electron/electron, /electron/forge, /electron-userland/electron-builder" + related-skills: "moai-lang-typescript, moai-domain-frontend, moai-lang-javascript" +--- + +# Electron 33+ Desktop Development + +## Quick Reference + +Electron 33+ Desktop App Development Specialist enables building cross-platform desktop applications with web technologies. + +Auto-Triggers: Electron projects detected via electron.vite.config.ts or electron-builder.yml files, desktop app development requests, IPC communication pattern implementation + +### Core Capabilities + +Electron 33 Platform: + +- Chromium 130 rendering engine for modern web features +- Node.js 20.18 runtime for native system access +- Native ESM support in main process +- WebGPU API support for GPU-accelerated graphics + +Process Architecture: + +- Main process runs as single instance per application with full Node.js access +- Renderer processes display web content in sandboxed environments +- Preload scripts bridge main and renderer with controlled API exposure +- Utility processes handle background tasks without blocking UI + +IPC Communication: + +- Type-safe invoke/handle patterns for request-response communication +- contextBridge API for secure renderer access to main process functionality +- Event-based messaging for push notifications from main to renderer + +Auto-Update Support: + +- electron-updater integration with GitHub and S3 publishing +- Differential updates for smaller download sizes +- Update notification and installation management + +Packaging Options: + +- Electron Forge for integrated build tooling and plugin ecosystem +- electron-builder for flexible multi-platform distribution + +Security Features: + +- contextIsolation for preventing prototype pollution +- Sandbox enforcement for renderer process isolation +- Content Security Policy configuration +- Input validation patterns for IPC handlers + +### Project Initialization + +Creating a new Electron application requires running the create-electron-app command with the vite-typescript template. Install electron-builder as a development dependency for packaging. Add electron-updater as a runtime dependency for auto-update functionality. + +For detailed commands and configuration, see reference.md Quick Commands section. + +--- + +## Implementation Guide + +### Project Structure + +Recommended Directory Layout: + +The source directory should contain four main subdirectories: + +Main Directory: Contains the main process entry point, IPC handler definitions organized by domain, business logic services, and window management modules + +Preload Directory: Contains the preload script entry point and exposed API definitions that bridge main and renderer + +Renderer Directory: Contains the web application built with React, Vue, or Svelte, including the HTML entry point and Vite configuration + +Shared Directory: Contains TypeScript types and constants shared between main and renderer processes + +The project root should include the electron.vite.config.ts for build configuration, electron-builder.yml for packaging options, and resources directory for app icons and assets. + +### Main Process Setup + +Application Lifecycle Management: + +The main process initialization follows a specific sequence. First, enable sandbox globally using app.enableSandbox() to ensure all renderer processes run in isolated environments. Then request single instance lock to prevent multiple app instances from running simultaneously. + +Window creation should occur after the app ready event fires. Configure BrowserWindow with security-focused webPreferences including contextIsolation enabled, nodeIntegration disabled, sandbox enabled, and webSecurity enabled. Set the preload script path to expose safe APIs to the renderer. + +Handle platform-specific behaviors: on macOS, re-create windows when the dock icon is clicked if no windows exist. On other platforms, quit the application when all windows close. + +For implementation examples, see examples.md Main Process Entry Point section. + +### Type-Safe IPC Communication + +IPC Type Definition Pattern: + +Define an interface that maps channel names to their payload types. Group channels by domain such as file operations, window operations, and storage operations. This enables type checking for both main process handlers and renderer invocations. + +Main Process Handler Registration: + +Register IPC handlers in a dedicated module that imports from the shared types. Each handler should validate input using a schema validation library such as Zod before processing. Use ipcMain.handle for request-response patterns and return structured results. + +Preload Script Implementation: + +Create an API object that wraps ipcRenderer.invoke calls for each channel. Use contextBridge.exposeInMainWorld to make this API available in the renderer as window.electronAPI. Include cleanup functions for event listeners to prevent memory leaks. + +For complete IPC implementation patterns, see examples.md Type-Safe IPC Implementation section. + +### Security Best Practices + +Mandatory Security Settings: + +Every BrowserWindow must have webPreferences configured with four critical settings. contextIsolation must always be enabled to prevent renderer code from accessing Electron internals. nodeIntegration must always be disabled in renderer processes. sandbox must always be enabled for process-level isolation. webSecurity must never be disabled to maintain same-origin policy enforcement. + +Content Security Policy: + +Configure session-level CSP headers using webRequest.onHeadersReceived. Restrict default-src to self, script-src to self without unsafe-inline, and connect-src to allowed API domains. This prevents XSS attacks and unauthorized resource loading. + +Input Validation: + +Every IPC handler must validate inputs before processing. Prevent path traversal attacks by rejecting paths containing parent directory references. Validate file names against reserved characters. Use allowlists for permitted directories when implementing file access. + +For security implementation details, see reference.md Security Best Practices section. + +### Auto-Update Implementation + +Update Service Architecture: + +Create an UpdateService class that manages the electron-updater lifecycle. Initialize with the main window reference to enable UI notifications. Configure autoDownload as false to give users control over bandwidth usage. + +Event Handling: + +Handle update-available events by notifying the renderer and prompting the user for download confirmation. Track download-progress events to display progress indicators. Handle update-downloaded events by prompting for restart. + +User Notification Pattern: + +Use system dialogs to prompt users when updates are available and when downloads complete. Send events to the renderer for in-app notification display. Support both immediate and deferred installation. + +For complete update service implementation, see examples.md Auto-Update Integration section. + +### App Packaging + +Electron Builder Configuration: + +Configure the appId with reverse-domain notation for platform registration. Specify productName for display in system UI. Set up platform-specific targets for macOS, Windows, and Linux. + +macOS Configuration: + +Set category for App Store classification. Enable hardenedRuntime and configure entitlements for notarization. Configure universal builds targeting both x64 and arm64 architectures. + +Windows Configuration: + +Specify icon path for executable and installer. Configure NSIS installer options including installation directory selection. Set up code signing with appropriate hash algorithms. + +Linux Configuration: + +Configure category for desktop environment integration. Set up multiple targets including AppImage for universal distribution and deb/rpm for package manager installation. + +For complete configuration examples, see reference.md Configuration section. + +--- + +## Advanced Patterns + +For comprehensive documentation on advanced topics, see reference.md and examples.md: + +Window State Persistence: + +- Saving and restoring window position and size across sessions +- Handling multiple displays and display changes +- Managing maximized and fullscreen states + +Multi-Window Management: + +- Creating secondary windows with proper parent-child relationships +- Sharing state between multiple windows +- Coordinating window lifecycle events + +System Tray and Native Menus: + +- Creating and updating system tray icons with context menus +- Building application menus with keyboard shortcuts +- Platform-specific menu patterns for macOS and Windows + +Utility Processes: + +- Spawning utility processes for CPU-intensive background tasks +- Communicating with utility processes via MessageChannel +- Managing utility process lifecycle and error handling + +Native Module Integration: + +- Rebuilding native modules for Electron Node.js version +- Using better-sqlite3 for local database storage +- Integrating keytar for secure credential storage + +Protocol Handlers and Deep Linking: + +- Registering custom URL protocols for app launching +- Handling deep links on different platforms +- OAuth callback handling via custom protocols + +Performance Optimization: + +- Lazy loading windows and heavy modules +- Optimizing startup time with deferred initialization +- Memory management for long-running applications + +--- + +## Works Well With + +- moai-lang-typescript - TypeScript patterns for type-safe Electron development +- moai-domain-frontend - React, Vue, or Svelte renderer development +- moai-lang-javascript - Node.js patterns for main process +- moai-domain-backend - Backend API integration +- moai-workflow-testing - Testing strategies for desktop apps + +--- + +## Troubleshooting + +Common Issues and Solutions: + +White Screen on Launch: + +Verify the preload script path is correctly configured relative to the built output directory. Check that loadFile or loadURL paths point to existing files. Enable devTools to inspect console errors. Review CSP settings that may block script execution. + +IPC Not Working: + +Confirm channel names match exactly between main handlers and renderer invocations. Ensure handlers are registered before windows load content. Verify contextBridge usage follows the correct pattern with exposeInMainWorld. + +Native Modules Fail: + +Run electron-rebuild after npm install to recompile native modules. Match the Node.js version embedded in Electron. Add a postinstall script to automate rebuilding. + +Auto-Update Not Working: + +Verify the application is code-signed as updates require signing. Check publish configuration in electron-builder.yml. Enable electron-updater logging to diagnose connection issues. Review firewall settings that may block update checks. + +Debug Commands: + +Rebuild native modules with npx electron-rebuild. Check Electron version with npx electron --version. Enable verbose update logging with DEBUG=electron-updater environment variable. + +--- + +## Resources + +For complete code examples and configuration templates, see: + +- reference.md - Detailed API documentation, version matrix, Context7 library mappings +- examples.md - Production-ready code examples for all patterns + +For latest documentation, use Context7 to query: + +- /electron/electron for core Electron APIs +- /electron/forge for Electron Forge tooling +- /electron-userland/electron-builder for packaging configuration + +--- + +Version: 2.0.0 +Last Updated: 2026-01-10 +Changes: Restructured to comply with CLAUDE.md Documentation Standards - removed all code examples, converted to narrative text format diff --git a/.agent/skills/moai-framework-electron/examples.md b/.agent/skills/moai-framework-electron/examples.md new file mode 100644 index 0000000..7fc5328 --- /dev/null +++ b/.agent/skills/moai-framework-electron/examples.md @@ -0,0 +1,2082 @@ +# Electron Framework Examples + +Production-ready code examples for Electron 33+ desktop application development. + +--- + +## Complete Electron App Setup with Vite + +### Package Configuration + +```json +// package.json +{ + "name": "electron-app", + "version": "1.0.0", + "main": "dist/main/index.js", + "scripts": { + "dev": "electron-vite dev", + "build": "electron-vite build", + "preview": "electron-vite preview", + "package": "electron-builder", + "package:mac": "electron-builder --mac", + "package:win": "electron-builder --win", + "package:linux": "electron-builder --linux", + "postinstall": "electron-builder install-app-deps" + }, + "dependencies": { + "electron-store": "^8.1.0", + "electron-updater": "^6.1.7" + }, + "devDependencies": { + "@electron-toolkit/preload": "^3.0.0", + "@electron-toolkit/utils": "^3.0.0", + "@types/node": "^20.10.0", + "@vitejs/plugin-react": "^4.2.0", + "electron": "^33.0.0", + "electron-builder": "^24.9.1", + "electron-vite": "^2.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "typescript": "^5.3.0", + "vite": "^5.0.0" + } +} +``` + +### Electron Vite Configuration + +```typescript +// electron.vite.config.ts +import { defineConfig, externalizeDepsPlugin } from "electron-vite"; +import react from "@vitejs/plugin-react"; +import { resolve } from "path"; + +export default defineConfig({ + main: { + plugins: [externalizeDepsPlugin()], + build: { + rollupOptions: { + input: { + index: resolve(__dirname, "src/main/index.ts"), + }, + }, + }, + }, + preload: { + plugins: [externalizeDepsPlugin()], + build: { + rollupOptions: { + input: { + index: resolve(__dirname, "src/preload/index.ts"), + }, + }, + }, + }, + renderer: { + root: resolve(__dirname, "src/renderer"), + plugins: [react()], + build: { + rollupOptions: { + input: { + index: resolve(__dirname, "src/renderer/index.html"), + }, + }, + }, + }, +}); +``` + +### Main Process Entry Point + +```typescript +// src/main/index.ts +import { app, BrowserWindow, ipcMain, session, shell } from "electron"; +import { join } from "path"; +import { electronApp, optimizer, is } from "@electron-toolkit/utils"; +import { registerIpcHandlers } from "./ipc"; +import { UpdateService } from "./services/updater"; +import { WindowManager } from "./windows/window-manager"; + +const windowManager = new WindowManager(); +const updateService = new UpdateService(); + +async function createMainWindow(): Promise { + const mainWindow = windowManager.createWindow("main", { + width: 1200, + height: 800, + minWidth: 800, + minHeight: 600, + titleBarStyle: "hiddenInset", + trafficLightPosition: { x: 15, y: 15 }, + webPreferences: { + preload: join(__dirname, "../preload/index.js"), + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + }, + }); + + // Open external links in default browser + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + shell.openExternal(url); + return { action: "deny" }; + }); + + // Load app + if (is.dev && process.env["ELECTRON_RENDERER_URL"]) { + mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]); + mainWindow.webContents.openDevTools({ mode: "detach" }); + } else { + mainWindow.loadFile(join(__dirname, "../renderer/index.html")); + } + + return mainWindow; +} + +app.whenReady().then(async () => { + // Set app user model ID for Windows + electronApp.setAppUserModelId("com.example.myapp"); + + // Watch for shortcuts to optimize new windows + app.on("browser-window-created", (_, window) => { + optimizer.watchWindowShortcuts(window); + }); + + // Configure session security + configureSession(); + + // Register IPC handlers + registerIpcHandlers(); + + // Create main window + const mainWindow = await createMainWindow(); + + // Initialize auto-updater + if (!is.dev) { + updateService.initialize(mainWindow); + updateService.checkForUpdates(); + } + + app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) { + createMainWindow(); + } + }); +}); + +app.on("window-all-closed", () => { + if (process.platform !== "darwin") { + app.quit(); + } +}); + +function configureSession(): void { + // Content Security Policy + session.defaultSession.webRequest.onHeadersReceived((details, callback) => { + callback({ + responseHeaders: { + ...details.responseHeaders, + "Content-Security-Policy": [ + "default-src 'self'; " + + "script-src 'self'; " + + "style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data: https:; " + + "font-src 'self' data:; " + + "connect-src 'self' https://api.github.com", + ], + }, + }); + }); + + // Permission handler + session.defaultSession.setPermissionRequestHandler( + (webContents, permission, callback) => { + const allowedPermissions = ["notifications", "clipboard-read"]; + callback(allowedPermissions.includes(permission)); + }, + ); + + // Block navigation to external URLs + session.defaultSession.setPermissionCheckHandler(() => false); +} + +// Single instance lock +const gotSingleLock = app.requestSingleInstanceLock(); +if (!gotSingleLock) { + app.quit(); +} else { + app.on("second-instance", (_event, _commandLine, _workingDirectory) => { + const mainWindow = windowManager.getWindow("main"); + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); + } + }); +} +``` + +--- + +## Type-Safe IPC Implementation + +### Shared Type Definitions + +```typescript +// src/shared/types/ipc.ts +export interface FileInfo { + path: string; + content: string; + encoding?: BufferEncoding; +} + +export interface SaveResult { + success: boolean; + path: string; + error?: string; +} + +export interface DialogOptions { + title?: string; + filters?: { name: string; extensions: string[] }[]; + defaultPath?: string; +} + +export interface StorageItem { + key: string; + value: T; + timestamp?: number; +} + +// IPC Channel Definitions +export interface IpcMainToRenderer { + "app:update-available": { version: string; releaseNotes: string }; + "app:update-progress": { + percent: number; + transferred: number; + total: number; + }; + "app:update-downloaded": { version: string }; + "window:maximize-change": boolean; + "file:external-open": { path: string }; +} + +export interface IpcRendererToMain { + // File operations + "file:open-dialog": DialogOptions; + "file:save-dialog": DialogOptions; + "file:read": string; + "file:write": { path: string; content: string }; + "file:exists": string; + + // Window operations + "window:minimize": void; + "window:maximize": void; + "window:close": void; + "window:is-maximized": void; + + // Storage operations + "storage:get": string; + "storage:set": StorageItem; + "storage:delete": string; + "storage:clear": void; + + // App operations + "app:get-version": void; + "app:get-path": "home" | "appData" | "userData" | "temp" | "downloads"; + "app:open-external": string; + + // Update operations + "update:check": void; + "update:download": void; + "update:install": void; +} + +// Return types for IPC handlers +export interface IpcReturnTypes { + "file:open-dialog": FileInfo | null; + "file:save-dialog": string | null; + "file:read": string; + "file:write": SaveResult; + "file:exists": boolean; + "window:minimize": void; + "window:maximize": void; + "window:close": void; + "window:is-maximized": boolean; + "storage:get": unknown; + "storage:set": void; + "storage:delete": void; + "storage:clear": void; + "app:get-version": string; + "app:get-path": string; + "app:open-external": void; + "update:check": void; + "update:download": void; + "update:install": void; +} +``` + +### Main Process IPC Handlers + +```typescript +// src/main/ipc/index.ts +import { ipcMain, dialog, app, shell, BrowserWindow } from "electron"; +import { readFile, writeFile, access } from "fs/promises"; +import { constants } from "fs"; +import Store from "electron-store"; +import { z } from "zod"; +import type { + DialogOptions, + FileInfo, + SaveResult, + StorageItem, +} from "../../shared/types/ipc"; + +const store = new Store({ + encryptionKey: process.env.STORE_ENCRYPTION_KEY, +}); + +// Validation schemas +const FilePathSchema = z + .string() + .min(1) + .refine( + (path) => { + const normalized = path.replace(/\\/g, "/"); + return !normalized.includes("..") && !normalized.includes("\0"); + }, + { message: "Invalid file path" }, + ); + +const StorageKeySchema = z + .string() + .min(1) + .max(256) + .regex(/^[a-zA-Z0-9_.-]+$/); + +export function registerIpcHandlers(): void { + // File operations + ipcMain.handle( + "file:open-dialog", + async (_event, options: DialogOptions): Promise => { + const result = await dialog.showOpenDialog({ + title: options.title ?? "Open File", + properties: ["openFile"], + filters: options.filters ?? [{ name: "All Files", extensions: ["*"] }], + defaultPath: options.defaultPath, + }); + + if (result.canceled || result.filePaths.length === 0) { + return null; + } + + const path = result.filePaths[0]; + const content = await readFile(path, "utf-8"); + return { path, content }; + }, + ); + + ipcMain.handle( + "file:save-dialog", + async (_event, options: DialogOptions): Promise => { + const result = await dialog.showSaveDialog({ + title: options.title ?? "Save File", + filters: options.filters ?? [{ name: "All Files", extensions: ["*"] }], + defaultPath: options.defaultPath, + }); + + return result.canceled ? null : (result.filePath ?? null); + }, + ); + + ipcMain.handle("file:read", async (_event, path: string): Promise => { + const validPath = FilePathSchema.parse(path); + return readFile(validPath, "utf-8"); + }); + + ipcMain.handle( + "file:write", + async ( + _event, + { path, content }: { path: string; content: string }, + ): Promise => { + try { + const validPath = FilePathSchema.parse(path); + await writeFile(validPath, content, "utf-8"); + return { success: true, path: validPath }; + } catch (error) { + return { + success: false, + path, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + }, + ); + + ipcMain.handle( + "file:exists", + async (_event, path: string): Promise => { + try { + const validPath = FilePathSchema.parse(path); + await access(validPath, constants.F_OK); + return true; + } catch { + return false; + } + }, + ); + + // Window operations + ipcMain.handle("window:minimize", (event): void => { + BrowserWindow.fromWebContents(event.sender)?.minimize(); + }); + + ipcMain.handle("window:maximize", (event): void => { + const window = BrowserWindow.fromWebContents(event.sender); + if (window?.isMaximized()) { + window.unmaximize(); + } else { + window?.maximize(); + } + }); + + ipcMain.handle("window:close", (event): void => { + BrowserWindow.fromWebContents(event.sender)?.close(); + }); + + ipcMain.handle("window:is-maximized", (event): boolean => { + return BrowserWindow.fromWebContents(event.sender)?.isMaximized() ?? false; + }); + + // Storage operations + ipcMain.handle("storage:get", (_event, key: string): unknown => { + const validKey = StorageKeySchema.parse(key); + return store.get(validKey); + }); + + ipcMain.handle("storage:set", (_event, { key, value }: StorageItem): void => { + const validKey = StorageKeySchema.parse(key); + store.set(validKey, value); + }); + + ipcMain.handle("storage:delete", (_event, key: string): void => { + const validKey = StorageKeySchema.parse(key); + store.delete(validKey); + }); + + ipcMain.handle("storage:clear", (): void => { + store.clear(); + }); + + // App operations + ipcMain.handle("app:get-version", (): string => { + return app.getVersion(); + }); + + ipcMain.handle( + "app:get-path", + ( + _event, + name: "home" | "appData" | "userData" | "temp" | "downloads", + ): string => { + return app.getPath(name); + }, + ); + + ipcMain.handle( + "app:open-external", + async (_event, url: string): Promise => { + // Validate URL before opening + const parsedUrl = new URL(url); + if (parsedUrl.protocol === "https:" || parsedUrl.protocol === "http:") { + await shell.openExternal(url); + } + }, + ); +} +``` + +### Preload Script with Full API + +```typescript +// src/preload/index.ts +import { contextBridge, ipcRenderer, IpcRendererEvent } from "electron"; +import type { + DialogOptions, + FileInfo, + SaveResult, + StorageItem, + IpcMainToRenderer, +} from "../shared/types/ipc"; + +// Type-safe event listener helper +type EventCallback = (data: T) => void; +type Unsubscribe = () => void; + +function createEventListener( + channel: K, + callback: EventCallback, +): Unsubscribe { + const handler = (_event: IpcRendererEvent, data: IpcMainToRenderer[K]) => { + callback(data); + }; + ipcRenderer.on(channel, handler); + return () => ipcRenderer.removeListener(channel, handler); +} + +const electronAPI = { + // File operations + file: { + openDialog: (options: DialogOptions = {}): Promise => + ipcRenderer.invoke("file:open-dialog", options), + saveDialog: (options: DialogOptions = {}): Promise => + ipcRenderer.invoke("file:save-dialog", options), + read: (path: string): Promise => + ipcRenderer.invoke("file:read", path), + write: (path: string, content: string): Promise => + ipcRenderer.invoke("file:write", { path, content }), + exists: (path: string): Promise => + ipcRenderer.invoke("file:exists", path), + }, + + // Window operations + window: { + minimize: (): Promise => ipcRenderer.invoke("window:minimize"), + maximize: (): Promise => ipcRenderer.invoke("window:maximize"), + close: (): Promise => ipcRenderer.invoke("window:close"), + isMaximized: (): Promise => + ipcRenderer.invoke("window:is-maximized"), + onMaximizeChange: (callback: (isMaximized: boolean) => void): Unsubscribe => + createEventListener("window:maximize-change", callback), + }, + + // Storage operations + storage: { + get: (key: string): Promise => + ipcRenderer.invoke("storage:get", key) as Promise, + set: (key: string, value: T): Promise => + ipcRenderer.invoke("storage:set", { key, value } as StorageItem), + delete: (key: string): Promise => + ipcRenderer.invoke("storage:delete", key), + clear: (): Promise => ipcRenderer.invoke("storage:clear"), + }, + + // App operations + app: { + getVersion: (): Promise => ipcRenderer.invoke("app:get-version"), + getPath: ( + name: "home" | "appData" | "userData" | "temp" | "downloads", + ): Promise => ipcRenderer.invoke("app:get-path", name), + openExternal: (url: string): Promise => + ipcRenderer.invoke("app:open-external", url), + }, + + // Update operations + update: { + check: (): Promise => ipcRenderer.invoke("update:check"), + download: (): Promise => ipcRenderer.invoke("update:download"), + install: (): Promise => ipcRenderer.invoke("update:install"), + onAvailable: ( + callback: (info: { version: string; releaseNotes: string }) => void, + ): Unsubscribe => createEventListener("app:update-available", callback), + onProgress: ( + callback: (progress: { + percent: number; + transferred: number; + total: number; + }) => void, + ): Unsubscribe => createEventListener("app:update-progress", callback), + onDownloaded: ( + callback: (info: { version: string }) => void, + ): Unsubscribe => createEventListener("app:update-downloaded", callback), + }, + + // Platform info + platform: { + isMac: process.platform === "darwin", + isWindows: process.platform === "win32", + isLinux: process.platform === "linux", + }, +}; + +contextBridge.exposeInMainWorld("electronAPI", electronAPI); + +// Type declaration for renderer +export type ElectronAPI = typeof electronAPI; + +declare global { + interface Window { + electronAPI: ElectronAPI; + } +} +``` + +--- + +## Auto-Update Integration + +### Complete Update Service + +```typescript +// src/main/services/updater.ts +import { + autoUpdater, + UpdateInfo, + ProgressInfo, + UpdateDownloadedEvent, +} from "electron-updater"; +import { BrowserWindow, dialog, Notification } from "electron"; +import log from "electron-log"; + +export interface UpdateServiceOptions { + /** Check for updates on startup */ + checkOnStartup?: boolean; + /** Auto-download updates */ + autoDownload?: boolean; + /** Auto-install on quit */ + autoInstallOnAppQuit?: boolean; + /** Check interval in milliseconds (default: 1 hour) */ + checkInterval?: number; +} + +export class UpdateService { + private mainWindow: BrowserWindow | null = null; + private checkIntervalId: NodeJS.Timeout | null = null; + + constructor( + private options: UpdateServiceOptions = { + checkOnStartup: true, + autoDownload: false, + autoInstallOnAppQuit: true, + checkInterval: 3600000, + }, + ) { + // Configure logging + autoUpdater.logger = log; + log.transports.file.level = "info"; + } + + initialize(window: BrowserWindow): void { + this.mainWindow = window; + + // Configure auto-updater + autoUpdater.autoDownload = this.options.autoDownload ?? false; + autoUpdater.autoInstallOnAppQuit = + this.options.autoInstallOnAppQuit ?? true; + + // Set up event handlers + this.setupEventHandlers(); + + // Check on startup if enabled + if (this.options.checkOnStartup) { + // Delay initial check to let app settle + setTimeout(() => this.checkForUpdates(), 5000); + } + + // Set up periodic checking + if (this.options.checkInterval && this.options.checkInterval > 0) { + this.checkIntervalId = setInterval( + () => this.checkForUpdates(), + this.options.checkInterval, + ); + } + } + + private setupEventHandlers(): void { + autoUpdater.on("checking-for-update", () => { + log.info("Checking for updates..."); + }); + + autoUpdater.on("update-available", (info: UpdateInfo) => { + log.info("Update available:", info.version); + this.notifyUpdateAvailable(info); + }); + + autoUpdater.on("update-not-available", () => { + log.info("No updates available"); + }); + + autoUpdater.on("error", (error: Error) => { + log.error("Update error:", error.message); + this.notifyError(error); + }); + + autoUpdater.on("download-progress", (progress: ProgressInfo) => { + log.info(`Download progress: ${progress.percent.toFixed(1)}%`); + this.mainWindow?.webContents.send("app:update-progress", { + percent: progress.percent, + transferred: progress.transferred, + total: progress.total, + }); + }); + + autoUpdater.on("update-downloaded", (event: UpdateDownloadedEvent) => { + log.info("Update downloaded:", event.version); + this.notifyUpdateDownloaded(event); + }); + } + + async checkForUpdates(): Promise { + try { + await autoUpdater.checkForUpdates(); + } catch (error) { + log.error("Failed to check for updates:", error); + } + } + + async downloadUpdate(): Promise { + try { + await autoUpdater.downloadUpdate(); + } catch (error) { + log.error("Failed to download update:", error); + } + } + + installUpdate(): void { + autoUpdater.quitAndInstall(false, true); + } + + private async notifyUpdateAvailable(info: UpdateInfo): Promise { + // Send to renderer + this.mainWindow?.webContents.send("app:update-available", { + version: info.version, + releaseNotes: + typeof info.releaseNotes === "string" + ? info.releaseNotes + : (info.releaseNotes + ?.map((n) => `${n.version}: ${n.note}`) + .join("\n") ?? ""), + }); + + // Show system notification if supported + if (Notification.isSupported()) { + new Notification({ + title: "Update Available", + body: `Version ${info.version} is available for download.`, + }).show(); + } + + // Show dialog + const result = await dialog.showMessageBox(this.mainWindow!, { + type: "info", + title: "Update Available", + message: `A new version (${info.version}) is available.`, + detail: "Would you like to download and install it?", + buttons: ["Download", "Later"], + defaultId: 0, + cancelId: 1, + }); + + if (result.response === 0) { + this.downloadUpdate(); + } + } + + private async notifyUpdateDownloaded( + event: UpdateDownloadedEvent, + ): Promise { + // Send to renderer + this.mainWindow?.webContents.send("app:update-downloaded", { + version: event.version, + }); + + // Show dialog + const result = await dialog.showMessageBox(this.mainWindow!, { + type: "info", + title: "Update Ready", + message: `Version ${event.version} has been downloaded.`, + detail: "Would you like to restart and install it now?", + buttons: ["Restart Now", "Later"], + defaultId: 0, + cancelId: 1, + }); + + if (result.response === 0) { + this.installUpdate(); + } + } + + private notifyError(error: Error): void { + dialog.showErrorBox( + "Update Error", + `An error occurred while updating: ${error.message}`, + ); + } + + dispose(): void { + if (this.checkIntervalId) { + clearInterval(this.checkIntervalId); + this.checkIntervalId = null; + } + } +} +``` + +--- + +## System Tray and Native Menu + +### System Tray Service + +```typescript +// src/main/services/tray.ts +import { + Tray, + Menu, + MenuItemConstructorOptions, + app, + nativeImage, + BrowserWindow, +} from "electron"; +import { join } from "path"; +import { is } from "@electron-toolkit/utils"; + +export class TrayService { + private tray: Tray | null = null; + private mainWindow: BrowserWindow | null = null; + + initialize(mainWindow: BrowserWindow): void { + this.mainWindow = mainWindow; + + // Create tray icon + const iconPath = is.dev + ? join(__dirname, "../../resources/icons/tray.png") + : join(process.resourcesPath, "icons/tray.png"); + + // Use template icon on macOS + const icon = nativeImage.createFromPath(iconPath); + if (process.platform === "darwin") { + icon.setTemplateImage(true); + } + + this.tray = new Tray(icon); + this.tray.setToolTip(app.getName()); + + // Set context menu + this.updateContextMenu(); + + // Click behavior + this.tray.on("click", () => { + this.toggleMainWindow(); + }); + + // Double-click to show (Windows) + this.tray.on("double-click", () => { + this.showMainWindow(); + }); + } + + updateContextMenu(additionalItems: MenuItemConstructorOptions[] = []): void { + if (!this.tray) return; + + const contextMenu = Menu.buildFromTemplate([ + { + label: "Show App", + click: () => this.showMainWindow(), + }, + { type: "separator" }, + ...additionalItems, + { type: "separator" }, + { + label: "Preferences", + accelerator: "CmdOrCtrl+,", + click: () => this.openPreferences(), + }, + { type: "separator" }, + { + label: "Check for Updates", + click: () => this.checkForUpdates(), + }, + { type: "separator" }, + { + label: "Quit", + accelerator: "CmdOrCtrl+Q", + click: () => app.quit(), + }, + ]); + + this.tray.setContextMenu(contextMenu); + } + + private toggleMainWindow(): void { + if (!this.mainWindow) return; + + if (this.mainWindow.isVisible()) { + if (this.mainWindow.isFocused()) { + this.mainWindow.hide(); + } else { + this.mainWindow.focus(); + } + } else { + this.showMainWindow(); + } + } + + private showMainWindow(): void { + if (!this.mainWindow) return; + + this.mainWindow.show(); + this.mainWindow.focus(); + + // Restore if minimized + if (this.mainWindow.isMinimized()) { + this.mainWindow.restore(); + } + } + + private openPreferences(): void { + // Emit event or open preferences window + this.mainWindow?.webContents.send("app:open-preferences"); + } + + private checkForUpdates(): void { + this.mainWindow?.webContents.send("app:check-updates"); + } + + setBadge(text: string): void { + if (process.platform === "darwin") { + app.dock.setBadge(text); + } else if (this.tray) { + // Update tray title/tooltip for other platforms + this.tray.setTitle(text); + } + } + + destroy(): void { + this.tray?.destroy(); + this.tray = null; + } +} +``` + +### Application Menu + +```typescript +// src/main/services/menu.ts +import { + Menu, + app, + shell, + BrowserWindow, + MenuItemConstructorOptions, +} from "electron"; + +export function createApplicationMenu(mainWindow: BrowserWindow): void { + const isMac = process.platform === "darwin"; + + const template: MenuItemConstructorOptions[] = [ + // App menu (macOS only) + ...(isMac + ? [ + { + label: app.name, + submenu: [ + { role: "about" as const }, + { type: "separator" as const }, + { + label: "Preferences", + accelerator: "CmdOrCtrl+,", + click: () => + mainWindow.webContents.send("app:open-preferences"), + }, + { type: "separator" as const }, + { role: "services" as const }, + { type: "separator" as const }, + { role: "hide" as const }, + { role: "hideOthers" as const }, + { role: "unhide" as const }, + { type: "separator" as const }, + { role: "quit" as const }, + ], + } as MenuItemConstructorOptions, + ] + : []), + + // File menu + { + label: "File", + submenu: [ + { + label: "New", + accelerator: "CmdOrCtrl+N", + click: () => mainWindow.webContents.send("file:new"), + }, + { + label: "Open...", + accelerator: "CmdOrCtrl+O", + click: () => mainWindow.webContents.send("file:open"), + }, + { type: "separator" }, + { + label: "Save", + accelerator: "CmdOrCtrl+S", + click: () => mainWindow.webContents.send("file:save"), + }, + { + label: "Save As...", + accelerator: "CmdOrCtrl+Shift+S", + click: () => mainWindow.webContents.send("file:save-as"), + }, + { type: "separator" }, + isMac ? { role: "close" } : { role: "quit" }, + ], + }, + + // Edit menu + { + label: "Edit", + submenu: [ + { role: "undo" }, + { role: "redo" }, + { type: "separator" }, + { role: "cut" }, + { role: "copy" }, + { role: "paste" }, + { role: "delete" }, + { type: "separator" }, + { role: "selectAll" }, + ...(isMac + ? [ + { type: "separator" as const }, + { + label: "Speech", + submenu: [ + { role: "startSpeaking" as const }, + { role: "stopSpeaking" as const }, + ], + }, + ] + : []), + ], + }, + + // View menu + { + label: "View", + submenu: [ + { role: "reload" }, + { role: "forceReload" }, + { role: "toggleDevTools" }, + { type: "separator" }, + { role: "resetZoom" }, + { role: "zoomIn" }, + { role: "zoomOut" }, + { type: "separator" }, + { role: "togglefullscreen" }, + ], + }, + + // Window menu + { + label: "Window", + submenu: [ + { role: "minimize" }, + { role: "zoom" }, + ...(isMac + ? [ + { type: "separator" as const }, + { role: "front" as const }, + { type: "separator" as const }, + { role: "window" as const }, + ] + : [{ role: "close" as const }]), + ], + }, + + // Help menu + { + label: "Help", + submenu: [ + { + label: "Documentation", + click: () => shell.openExternal("https://docs.example.com"), + }, + { + label: "Release Notes", + click: () => + shell.openExternal("https://github.com/example/repo/releases"), + }, + { type: "separator" }, + { + label: "Report Issue", + click: () => + shell.openExternal("https://github.com/example/repo/issues"), + }, + ], + }, + ]; + + const menu = Menu.buildFromTemplate(template); + Menu.setApplicationMenu(menu); +} +``` + +--- + +## Window State Persistence + +### Window Manager with State + +```typescript +// src/main/windows/window-manager.ts +import { + BrowserWindow, + BrowserWindowConstructorOptions, + screen, + Rectangle, +} from "electron"; +import Store from "electron-store"; +import { join } from "path"; + +interface WindowState { + width: number; + height: number; + x: number | undefined; + y: number | undefined; + isMaximized: boolean; + isFullScreen: boolean; +} + +interface WindowConfig { + id: string; + defaultWidth: number; + defaultHeight: number; + minWidth?: number; + minHeight?: number; +} + +const windowStateStore = new Store>({ + name: "window-state", +}); + +export class WindowManager { + private windows = new Map(); + private stateUpdateDebounce = new Map(); + + createWindow( + id: string, + options: BrowserWindowConstructorOptions = {}, + ): BrowserWindow { + // Get saved state or calculate default + const savedState = this.getWindowState(id); + const { width, height } = screen.getPrimaryDisplay().workAreaSize; + + const defaultConfig: WindowConfig = { + id, + defaultWidth: Math.floor(width * 0.8), + defaultHeight: Math.floor(height * 0.8), + minWidth: options.minWidth ?? 400, + minHeight: options.minHeight ?? 300, + }; + + // Calculate initial bounds + const bounds = this.calculateBounds(savedState, defaultConfig); + + const window = new BrowserWindow({ + ...bounds, + show: false, + ...options, + webPreferences: { + preload: join(__dirname, "../preload/index.js"), + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + ...options.webPreferences, + }, + }); + + // Restore maximized/fullscreen state + if (savedState?.isMaximized) { + window.maximize(); + } + if (savedState?.isFullScreen) { + window.setFullScreen(true); + } + + // Show when ready + window.once("ready-to-show", () => { + window.show(); + }); + + // Track state changes + this.trackWindowState(id, window); + + // Handle close + window.on("closed", () => { + this.windows.delete(id); + const timeout = this.stateUpdateDebounce.get(id); + if (timeout) { + clearTimeout(timeout); + this.stateUpdateDebounce.delete(id); + } + }); + + this.windows.set(id, window); + return window; + } + + getWindow(id: string): BrowserWindow | undefined { + const window = this.windows.get(id); + if (window && !window.isDestroyed()) { + return window; + } + return undefined; + } + + getAllWindows(): BrowserWindow[] { + return Array.from(this.windows.values()).filter((w) => !w.isDestroyed()); + } + + closeWindow(id: string): void { + const window = this.getWindow(id); + if (window) { + window.close(); + } + } + + closeAll(): void { + for (const window of this.getAllWindows()) { + window.close(); + } + } + + private getWindowState(id: string): WindowState | undefined { + return windowStateStore.get(id); + } + + private saveWindowState(id: string, state: WindowState): void { + windowStateStore.set(id, state); + } + + private calculateBounds( + savedState: WindowState | undefined, + config: WindowConfig, + ): Rectangle { + const { width, height, x, y } = screen.getPrimaryDisplay().workAreaSize; + + // Use saved state if available and valid + if (savedState && this.isValidBounds(savedState)) { + return { + width: savedState.width, + height: savedState.height, + x: savedState.x ?? Math.floor((width - savedState.width) / 2), + y: savedState.y ?? Math.floor((height - savedState.height) / 2), + }; + } + + // Center window with default size + return { + width: config.defaultWidth, + height: config.defaultHeight, + x: Math.floor((width - config.defaultWidth) / 2), + y: Math.floor((height - config.defaultHeight) / 2), + }; + } + + private isValidBounds(state: WindowState): boolean { + const displays = screen.getAllDisplays(); + + // Check if window is visible on any display + return displays.some((display) => { + const { x, y, width, height } = display.bounds; + const windowX = state.x ?? 0; + const windowY = state.y ?? 0; + + return ( + windowX >= x - state.width && + windowX <= x + width && + windowY >= y - state.height && + windowY <= y + height + ); + }); + } + + private trackWindowState(id: string, window: BrowserWindow): void { + const saveState = (): void => { + // Debounce state updates + const existing = this.stateUpdateDebounce.get(id); + if (existing) { + clearTimeout(existing); + } + + this.stateUpdateDebounce.set( + id, + setTimeout(() => { + if (window.isDestroyed()) return; + + const bounds = window.getBounds(); + this.saveWindowState(id, { + width: bounds.width, + height: bounds.height, + x: bounds.x, + y: bounds.y, + isMaximized: window.isMaximized(), + isFullScreen: window.isFullScreen(), + }); + }, 500), + ); + }; + + window.on("resize", saveState); + window.on("move", saveState); + window.on("maximize", saveState); + window.on("unmaximize", saveState); + window.on("enter-full-screen", saveState); + window.on("leave-full-screen", saveState); + + // Save on close + window.on("close", () => { + if (!window.isDestroyed()) { + const bounds = window.getBounds(); + this.saveWindowState(id, { + width: bounds.width, + height: bounds.height, + x: bounds.x, + y: bounds.y, + isMaximized: window.isMaximized(), + isFullScreen: window.isFullScreen(), + }); + } + }); + } +} + +export const windowManager = new WindowManager(); +``` + +--- + +## Secure File Operations + +### File Service with Validation + +```typescript +// src/main/services/file-service.ts +import { + readFile, + writeFile, + access, + mkdir, + stat, + readdir, + unlink, + rename, +} from "fs/promises"; +import { constants, createReadStream, createWriteStream } from "fs"; +import { join, dirname, basename, extname, normalize, resolve } from "path"; +import { app } from "electron"; +import { z } from "zod"; +import { pipeline } from "stream/promises"; +import { createHash } from "crypto"; + +// Validation schemas +const SafePathSchema = z + .string() + .min(1) + .max(4096) + .refine( + (path) => { + const normalized = normalize(path); + // Prevent path traversal + return !normalized.includes("..") && !normalized.includes("\0"); + }, + { message: "Invalid path: potential path traversal detected" }, + ); + +const FileNameSchema = z + .string() + .min(1) + .max(255) + .regex(/^[^<>:"/\\|?*\x00-\x1f]+$/, { + message: "Invalid filename: contains reserved characters", + }); + +export interface FileResult { + success: boolean; + data?: T; + error?: string; +} + +export interface FileMetadata { + name: string; + path: string; + size: number; + isDirectory: boolean; + created: Date; + modified: Date; + extension: string; +} + +export class FileService { + private allowedDirectories: string[]; + + constructor() { + // Define allowed directories for file operations + this.allowedDirectories = [ + app.getPath("documents"), + app.getPath("downloads"), + app.getPath("userData"), + app.getPath("temp"), + ]; + } + + async read(filePath: string): Promise> { + try { + const validPath = this.validatePath(filePath); + const content = await readFile(validPath, "utf-8"); + return { success: true, data: content }; + } catch (error) { + return this.handleError(error); + } + } + + async readBinary(filePath: string): Promise> { + try { + const validPath = this.validatePath(filePath); + const content = await readFile(validPath); + return { success: true, data: content }; + } catch (error) { + return this.handleError(error); + } + } + + async write( + filePath: string, + content: string | Buffer, + ): Promise> { + try { + const validPath = this.validatePath(filePath); + + // Ensure directory exists + await mkdir(dirname(validPath), { recursive: true }); + + await writeFile(validPath, content); + return { success: true }; + } catch (error) { + return this.handleError(error); + } + } + + async exists(filePath: string): Promise { + try { + const validPath = this.validatePath(filePath); + await access(validPath, constants.F_OK); + return true; + } catch { + return false; + } + } + + async getMetadata(filePath: string): Promise> { + try { + const validPath = this.validatePath(filePath); + const stats = await stat(validPath); + + return { + success: true, + data: { + name: basename(validPath), + path: validPath, + size: stats.size, + isDirectory: stats.isDirectory(), + created: stats.birthtime, + modified: stats.mtime, + extension: extname(validPath), + }, + }; + } catch (error) { + return this.handleError(error); + } + } + + async listDirectory(dirPath: string): Promise> { + try { + const validPath = this.validatePath(dirPath); + const entries = await readdir(validPath, { withFileTypes: true }); + + const metadata: FileMetadata[] = await Promise.all( + entries.map(async (entry) => { + const entryPath = join(validPath, entry.name); + const stats = await stat(entryPath); + + return { + name: entry.name, + path: entryPath, + size: stats.size, + isDirectory: entry.isDirectory(), + created: stats.birthtime, + modified: stats.mtime, + extension: entry.isDirectory() ? "" : extname(entry.name), + }; + }), + ); + + return { success: true, data: metadata }; + } catch (error) { + return this.handleError(error); + } + } + + async delete(filePath: string): Promise> { + try { + const validPath = this.validatePath(filePath); + await unlink(validPath); + return { success: true }; + } catch (error) { + return this.handleError(error); + } + } + + async move(sourcePath: string, destPath: string): Promise> { + try { + const validSource = this.validatePath(sourcePath); + const validDest = this.validatePath(destPath); + + await mkdir(dirname(validDest), { recursive: true }); + await rename(validSource, validDest); + + return { success: true }; + } catch (error) { + return this.handleError(error); + } + } + + async copy(sourcePath: string, destPath: string): Promise> { + try { + const validSource = this.validatePath(sourcePath); + const validDest = this.validatePath(destPath); + + await mkdir(dirname(validDest), { recursive: true }); + + await pipeline( + createReadStream(validSource), + createWriteStream(validDest), + ); + + return { success: true }; + } catch (error) { + return this.handleError(error); + } + } + + async getHash( + filePath: string, + algorithm: "md5" | "sha256" = "sha256", + ): Promise> { + try { + const validPath = this.validatePath(filePath); + const hash = createHash(algorithm); + const stream = createReadStream(validPath); + + return new Promise((resolve) => { + stream.on("data", (chunk) => hash.update(chunk)); + stream.on("end", () => { + resolve({ success: true, data: hash.digest("hex") }); + }); + stream.on("error", (error) => { + resolve(this.handleError(error)); + }); + }); + } catch (error) { + return this.handleError(error); + } + } + + private validatePath(filePath: string): string { + // Validate path format + const safePath = SafePathSchema.parse(filePath); + + // Resolve to absolute path + const absolutePath = resolve(safePath); + + // Validate filename if applicable + const fileName = basename(absolutePath); + if (fileName && !fileName.startsWith(".")) { + FileNameSchema.parse(fileName); + } + + // Optional: Check if path is within allowed directories + // Uncomment if you want to restrict file access + // this.validateAllowedDirectory(absolutePath); + + return absolutePath; + } + + private validateAllowedDirectory(absolutePath: string): void { + const isAllowed = this.allowedDirectories.some((dir) => + absolutePath.startsWith(dir), + ); + + if (!isAllowed) { + throw new Error(`Access denied: path is outside allowed directories`); + } + } + + private handleError(error: unknown): FileResult { + const message = + error instanceof Error ? error.message : "Unknown error occurred"; + return { success: false, error: message }; + } +} + +export const fileService = new FileService(); +``` + +--- + +## React Renderer Integration + +### Electron API Hook + +```typescript +// src/renderer/src/hooks/useElectron.ts +import { useEffect, useState, useCallback } from "react"; + +// Type from preload +type ElectronAPI = Window["electronAPI"]; + +export function useElectron(): ElectronAPI { + if (!window.electronAPI) { + throw new Error("Electron API not available. Are you running in Electron?"); + } + return window.electronAPI; +} + +export function useWindowMaximized(): boolean { + const [isMaximized, setIsMaximized] = useState(false); + const electron = useElectron(); + + useEffect(() => { + // Get initial state + electron.window.isMaximized().then(setIsMaximized); + + // Subscribe to changes + const unsubscribe = electron.window.onMaximizeChange(setIsMaximized); + return unsubscribe; + }, [electron]); + + return isMaximized; +} + +export function useAutoUpdate() { + const electron = useElectron(); + const [updateAvailable, setUpdateAvailable] = useState<{ + version: string; + releaseNotes: string; + } | null>(null); + const [downloadProgress, setDownloadProgress] = useState<{ + percent: number; + transferred: number; + total: number; + } | null>(null); + const [updateReady, setUpdateReady] = useState(false); + + useEffect(() => { + const unsubAvailable = electron.update.onAvailable((info) => { + setUpdateAvailable(info); + }); + + const unsubProgress = electron.update.onProgress((progress) => { + setDownloadProgress(progress); + }); + + const unsubDownloaded = electron.update.onDownloaded(() => { + setUpdateReady(true); + setDownloadProgress(null); + }); + + return () => { + unsubAvailable(); + unsubProgress(); + unsubDownloaded(); + }; + }, [electron]); + + const checkForUpdates = useCallback(() => { + electron.update.check(); + }, [electron]); + + const downloadUpdate = useCallback(() => { + electron.update.download(); + }, [electron]); + + const installUpdate = useCallback(() => { + electron.update.install(); + }, [electron]); + + return { + updateAvailable, + downloadProgress, + updateReady, + checkForUpdates, + downloadUpdate, + installUpdate, + }; +} + +export function useStorage( + key: string, + defaultValue: T, +): [T, (value: T) => Promise, boolean] { + const electron = useElectron(); + const [value, setValue] = useState(defaultValue); + const [loading, setLoading] = useState(true); + + useEffect(() => { + electron.storage.get(key).then((stored) => { + if (stored !== undefined) { + setValue(stored); + } + setLoading(false); + }); + }, [electron, key]); + + const updateValue = useCallback( + async (newValue: T) => { + setValue(newValue); + await electron.storage.set(key, newValue); + }, + [electron, key], + ); + + return [value, updateValue, loading]; +} +``` + +### Custom Title Bar Component + +```tsx +// src/renderer/src/components/TitleBar.tsx +import { useElectron, useWindowMaximized } from "../hooks/useElectron"; +import styles from "./TitleBar.module.css"; + +interface TitleBarProps { + title?: string; +} + +export function TitleBar({ title = "My App" }: TitleBarProps) { + const electron = useElectron(); + const isMaximized = useWindowMaximized(); + + return ( +
+ {/* Drag region */} +
+ {title} +
+ + {/* Window controls */} +
+ {!electron.platform.isMac && ( + <> + + + + + )} +
+
+ ); +} + +// Icon components +function MinimizeIcon() { + return ( + + + + ); +} + +function MaximizeIcon() { + return ( + + + + ); +} + +function RestoreIcon() { + return ( + + + + + ); +} + +function CloseIcon() { + return ( + + + + ); +} +``` + +```css +/* src/renderer/src/components/TitleBar.module.css */ +.titleBar { + display: flex; + height: 32px; + background: var(--titlebar-bg, #2d2d2d); + color: var(--titlebar-color, #ffffff); + user-select: none; +} + +.dragRegion { + flex: 1; + display: flex; + align-items: center; + padding-left: 12px; + -webkit-app-region: drag; +} + +.title { + font-size: 12px; + font-weight: 500; +} + +.windowControls { + display: flex; + -webkit-app-region: no-drag; +} + +.controlButton { + width: 46px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + color: inherit; + cursor: pointer; + transition: background-color 0.1s; +} + +.controlButton:hover { + background: rgba(255, 255, 255, 0.1); +} + +.controlButton:active { + background: rgba(255, 255, 255, 0.2); +} + +.closeButton:hover { + background: #e81123; +} +``` + +--- + +## Testing with Playwright + +### E2E Test Setup + +```typescript +// e2e/electron.spec.ts +import { test, expect, _electron as electron } from "@playwright/test"; +import type { ElectronApplication, Page } from "@playwright/test"; + +let app: ElectronApplication; +let page: Page; + +test.beforeAll(async () => { + // Launch Electron app + app = await electron.launch({ + args: ["."], + env: { + ...process.env, + NODE_ENV: "test", + }, + }); + + // Get the first window + page = await app.firstWindow(); + + // Wait for app to be ready + await page.waitForLoadState("domcontentloaded"); +}); + +test.afterAll(async () => { + await app.close(); +}); + +test.describe("Main Window", () => { + test("should display title", async () => { + const title = await page.title(); + expect(title).toBe("My App"); + }); + + test("should have correct dimensions", async () => { + const { width, height } = page.viewportSize()!; + expect(width).toBeGreaterThanOrEqual(800); + expect(height).toBeGreaterThanOrEqual(600); + }); + + test("should show main content", async () => { + await expect(page.locator("#app")).toBeVisible(); + }); +}); + +test.describe("Window Controls", () => { + test("should minimize window", async () => { + // Click minimize button + await page.click('[aria-label="Minimize"]'); + + // Verify window is minimized + const isMinimized = await app.evaluate(({ BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0]; + return window.isMinimized(); + }); + + expect(isMinimized).toBe(true); + + // Restore for next tests + await app.evaluate(({ BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0]; + window.restore(); + }); + }); + + test("should maximize/restore window", async () => { + // Click maximize button + await page.click('[aria-label="Maximize"]'); + + // Verify window is maximized + let isMaximized = await app.evaluate(({ BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0]; + return window.isMaximized(); + }); + + expect(isMaximized).toBe(true); + + // Click restore button + await page.click('[aria-label="Restore"]'); + + // Verify window is not maximized + isMaximized = await app.evaluate(({ BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0]; + return window.isMaximized(); + }); + + expect(isMaximized).toBe(false); + }); +}); + +test.describe("IPC Communication", () => { + test("should get app version", async () => { + const version = await page.evaluate(async () => { + return window.electronAPI.app.getVersion(); + }); + + expect(version).toMatch(/^\d+\.\d+\.\d+$/); + }); + + test("should access storage", async () => { + // Set value + await page.evaluate(async () => { + await window.electronAPI.storage.set("test-key", { foo: "bar" }); + }); + + // Get value + const value = await page.evaluate(async () => { + return window.electronAPI.storage.get("test-key"); + }); + + expect(value).toEqual({ foo: "bar" }); + + // Clean up + await page.evaluate(async () => { + await window.electronAPI.storage.delete("test-key"); + }); + }); +}); + +test.describe("File Operations", () => { + test("should check if file exists", async () => { + const exists = await page.evaluate(async () => { + return window.electronAPI.file.exists("package.json"); + }); + + expect(exists).toBe(true); + }); +}); +``` + +### Playwright Configuration + +```typescript +// playwright.config.ts +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + timeout: 30000, + expect: { + timeout: 5000, + }, + fullyParallel: false, // Electron tests should run sequentially + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: "html", + use: { + trace: "on-first-retry", + screenshot: "only-on-failure", + }, +}); +``` + +--- + +Version: 1.1.0 +Last Updated: 2026-01-10 +Changes: Aligned with SKILL.md v1.1.0 updates diff --git a/.agent/skills/moai-framework-electron/reference.md b/.agent/skills/moai-framework-electron/reference.md new file mode 100644 index 0000000..6ed352a --- /dev/null +++ b/.agent/skills/moai-framework-electron/reference.md @@ -0,0 +1,1649 @@ +# Electron Framework Reference Guide + +## Platform Version Matrix + +### Electron 33 (October 2024) - Current Stable + +- Chromium: 130 +- Node.js: 20.18.0 +- V8: 13.0 +- Key Features: + - Enhanced security defaults with sandbox enabled by default + - Improved context isolation patterns + - Native ESM support in main process + - Service Worker support in renderer + - WebGPU API support for GPU-accelerated graphics + - Improved auto-updater with differential updates + - Utility process enhancements for background tasks + - Better crash reporting integration + +### Electron 32 (August 2024) + +- Chromium: 128 +- Node.js: 20.16.0 +- Key Features: + - Utility process improvements + - Enhanced file system access + - Better macOS notarization support + - Improved window management APIs + +### Electron 31 (June 2024) + +- Chromium: 126 +- Node.js: 20.14.0 +- Key Features: + - Performance improvements for large apps + - Enhanced IPC serialization + - Better TypeScript support + +## Context7 Library Mappings + +### Core Framework + +``` +/electron/electron - Electron framework +/electron/forge - Electron Forge tooling +/electron-userland/electron-builder - App packaging +``` + +### Build Tools + +``` +/nickmeinhold/electron-vite - Vite integration +/nickmeinhold/electron-esbuild - esbuild integration +``` + +### Native Modules + +``` +/nickmeinhold/better-sqlite3 - SQLite database +/nickmeinhold/keytar - Secure credential storage +/nickmeinhold/node-pty - Terminal emulation +``` + +### Auto-Update + +``` +/electron-userland/electron-updater - Auto-update support +``` + +### Testing + +``` +/nickmeinhold/spectron - E2E testing (deprecated) +/nickmeinhold/playwright - Modern E2E testing +``` + +--- + +## Architecture Patterns + +### Process Model + +Electron Process Architecture: + +``` + ┌─────────────────────────────────────┐ + │ Main Process │ + │ - Single instance per app │ + │ - Full Node.js access │ + │ - Creates BrowserWindows │ + │ - Manages app lifecycle │ + │ - Native OS integration │ + └─────────────┬───────────────────────┘ + │ + ┌─────────────────────┼─────────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐ +│ Renderer Process │ │ Renderer Process │ │ Utility Process │ +│ - Web content │ │ - Web content │ │ - Background │ +│ - Sandboxed │ │ - Sandboxed │ │ tasks │ +│ - No Node.js │ │ - No Node.js │ │ - Node.js access │ +│ (default) │ │ (default) │ │ - No GUI │ +└───────────────────┘ └───────────────────┘ └───────────────────┘ +``` + +### Recommended Project Structure + +Directory Layout: + +``` +electron-app/ +├── src/ +│ ├── main/ # Main process code +│ │ ├── index.ts # Entry point +│ │ ├── app.ts # App lifecycle +│ │ ├── ipc/ # IPC handlers +│ │ │ ├── index.ts +│ │ │ ├── file-handlers.ts +│ │ │ └── window-handlers.ts +│ │ ├── services/ # Business logic +│ │ │ ├── storage.ts +│ │ │ └── updater.ts +│ │ └── windows/ # Window management +│ │ ├── main-window.ts +│ │ └── settings-window.ts +│ ├── preload/ # Preload scripts +│ │ ├── index.ts # Main preload +│ │ └── api.ts # Exposed APIs +│ ├── renderer/ # React/Vue/Svelte app +│ │ ├── src/ +│ │ ├── index.html +│ │ └── vite.config.ts +│ └── shared/ # Shared types/constants +│ ├── types.ts +│ └── constants.ts +├── resources/ # App resources +│ ├── icons/ +│ └── locales/ +├── electron.vite.config.ts +├── electron-builder.yml +└── package.json +``` + +--- + +## Main Process APIs + +### App Lifecycle + +```typescript +// src/main/app.ts +import { app, BrowserWindow, session } from "electron"; +import { join } from "path"; + +class Application { + private mainWindow: BrowserWindow | null = null; + + async initialize(): Promise { + // Set app user model ID for Windows + if (process.platform === "win32") { + app.setAppUserModelId(app.getName()); + } + + // Prevent multiple instances + const gotSingleLock = app.requestSingleInstanceLock(); + if (!gotSingleLock) { + app.quit(); + return; + } + + app.on("second-instance", () => { + if (this.mainWindow) { + if (this.mainWindow.isMinimized()) { + this.mainWindow.restore(); + } + this.mainWindow.focus(); + } + }); + + // macOS: Re-create window when dock icon clicked + app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) { + this.createMainWindow(); + } + }); + + // Quit when all windows closed (except macOS) + app.on("window-all-closed", () => { + if (process.platform !== "darwin") { + app.quit(); + } + }); + + // Wait for ready + await app.whenReady(); + + // Configure session + this.configureSession(); + + // Create main window + this.createMainWindow(); + } + + private configureSession(): void { + // Configure Content Security Policy + session.defaultSession.webRequest.onHeadersReceived((details, callback) => { + callback({ + responseHeaders: { + ...details.responseHeaders, + "Content-Security-Policy": [ + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'", + ], + }, + }); + }); + + // Configure permissions + session.defaultSession.setPermissionRequestHandler( + (webContents, permission, callback) => { + const allowedPermissions = ["notifications", "clipboard-read"]; + callback(allowedPermissions.includes(permission)); + }, + ); + } + + private createMainWindow(): void { + this.mainWindow = new BrowserWindow({ + width: 1200, + height: 800, + minWidth: 800, + minHeight: 600, + show: false, + webPreferences: { + preload: join(__dirname, "../preload/index.js"), + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + webSecurity: true, + }, + }); + + // Show window when ready + this.mainWindow.on("ready-to-show", () => { + this.mainWindow?.show(); + }); + + // Load app content + if (process.env.NODE_ENV === "development") { + this.mainWindow.loadURL("http://localhost:5173"); + this.mainWindow.webContents.openDevTools(); + } else { + this.mainWindow.loadFile(join(__dirname, "../renderer/index.html")); + } + } +} + +export const application = new Application(); +``` + +### Window Management + +```typescript +// src/main/windows/window-manager.ts +import { + BrowserWindow, + BrowserWindowConstructorOptions, + screen, +} from "electron"; +import { join } from "path"; + +interface WindowState { + width: number; + height: number; + x?: number; + y?: number; + isMaximized: boolean; +} + +export class WindowManager { + private windows = new Map(); + private stateStore: Map = new Map(); + + createWindow( + id: string, + options: BrowserWindowConstructorOptions = {}, + ): BrowserWindow { + // Restore previous state + const savedState = this.stateStore.get(id); + const { width, height } = screen.getPrimaryDisplay().workAreaSize; + + const defaultOptions: BrowserWindowConstructorOptions = { + width: savedState?.width ?? Math.floor(width * 0.8), + height: savedState?.height ?? Math.floor(height * 0.8), + x: savedState?.x, + y: savedState?.y, + show: false, + webPreferences: { + preload: join(__dirname, "../preload/index.js"), + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + }, + }; + + const window = new BrowserWindow({ + ...defaultOptions, + ...options, + webPreferences: { + ...defaultOptions.webPreferences, + ...options.webPreferences, + }, + }); + + // Restore maximized state + if (savedState?.isMaximized) { + window.maximize(); + } + + // Save state on close + window.on("close", () => { + this.saveWindowState(id, window); + }); + + window.on("closed", () => { + this.windows.delete(id); + }); + + this.windows.set(id, window); + return window; + } + + getWindow(id: string): BrowserWindow | undefined { + return this.windows.get(id); + } + + closeWindow(id: string): void { + const window = this.windows.get(id); + if (window && !window.isDestroyed()) { + window.close(); + } + } + + private saveWindowState(id: string, window: BrowserWindow): void { + const bounds = window.getBounds(); + this.stateStore.set(id, { + width: bounds.width, + height: bounds.height, + x: bounds.x, + y: bounds.y, + isMaximized: window.isMaximized(), + }); + } +} + +export const windowManager = new WindowManager(); +``` + +--- + +## IPC Communication + +### Type-Safe IPC Pattern + +```typescript +// src/shared/ipc-types.ts +export interface IpcChannels { + // Main -> Renderer + "app:update-available": { version: string }; + "app:update-downloaded": void; + + // Renderer -> Main (invoke) + "file:open": { path: string }; + "file:save": { path: string; content: string }; + "file:read": string; // Returns file content + "window:minimize": void; + "window:maximize": void; + "window:close": void; + "storage:get": { key: string }; + "storage:set": { key: string; value: unknown }; +} + +export type IpcChannel = keyof IpcChannels; +export type IpcPayload = IpcChannels[C]; +``` + +### Main Process Handlers + +```typescript +// src/main/ipc/index.ts +import { ipcMain, dialog, BrowserWindow } from "electron"; +import { readFile, writeFile } from "fs/promises"; +import Store from "electron-store"; + +const store = new Store(); + +export function registerIpcHandlers(): void { + // File operations + ipcMain.handle("file:open", async () => { + const result = await dialog.showOpenDialog({ + properties: ["openFile"], + filters: [ + { name: "All Files", extensions: ["*"] }, + { name: "Text", extensions: ["txt", "md"] }, + ], + }); + + if (result.canceled || result.filePaths.length === 0) { + return null; + } + + const filePath = result.filePaths[0]; + const content = await readFile(filePath, "utf-8"); + return { path: filePath, content }; + }); + + ipcMain.handle( + "file:save", + async (_event, { path, content }: { path: string; content: string }) => { + await writeFile(path, content, "utf-8"); + return { success: true }; + }, + ); + + ipcMain.handle("file:read", async (_event, path: string) => { + return readFile(path, "utf-8"); + }); + + // Window operations + ipcMain.handle("window:minimize", (event) => { + const window = BrowserWindow.fromWebContents(event.sender); + window?.minimize(); + }); + + ipcMain.handle("window:maximize", (event) => { + const window = BrowserWindow.fromWebContents(event.sender); + if (window?.isMaximized()) { + window.unmaximize(); + } else { + window?.maximize(); + } + }); + + ipcMain.handle("window:close", (event) => { + const window = BrowserWindow.fromWebContents(event.sender); + window?.close(); + }); + + // Storage operations + ipcMain.handle("storage:get", (_event, { key }: { key: string }) => { + return store.get(key); + }); + + ipcMain.handle( + "storage:set", + (_event, { key, value }: { key: string; value: unknown }) => { + store.set(key, value); + return { success: true }; + }, + ); +} +``` + +### Preload Script + +```typescript +// src/preload/index.ts +import { contextBridge, ipcRenderer } from "electron"; + +// Expose protected methods for renderer +const api = { + // Window controls + window: { + minimize: () => ipcRenderer.invoke("window:minimize"), + maximize: () => ipcRenderer.invoke("window:maximize"), + close: () => ipcRenderer.invoke("window:close"), + }, + + // File operations + file: { + open: () => ipcRenderer.invoke("file:open"), + save: (path: string, content: string) => + ipcRenderer.invoke("file:save", { path, content }), + read: (path: string) => ipcRenderer.invoke("file:read", path), + }, + + // Storage + storage: { + get: (key: string): Promise => + ipcRenderer.invoke("storage:get", { key }), + set: (key: string, value: unknown) => + ipcRenderer.invoke("storage:set", { key, value }), + }, + + // App events + onUpdateAvailable: (callback: (version: string) => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + { version }: { version: string }, + ) => { + callback(version); + }; + ipcRenderer.on("app:update-available", handler); + return () => ipcRenderer.removeListener("app:update-available", handler); + }, + + onUpdateDownloaded: (callback: () => void) => { + const handler = () => callback(); + ipcRenderer.on("app:update-downloaded", handler); + return () => ipcRenderer.removeListener("app:update-downloaded", handler); + }, +}; + +contextBridge.exposeInMainWorld("electronAPI", api); + +// Type declaration for renderer +declare global { + interface Window { + electronAPI: typeof api; + } +} +``` + +--- + +## Auto-Update + +### Update Service + +```typescript +// src/main/services/updater.ts +import { autoUpdater, UpdateInfo } from "electron-updater"; +import { BrowserWindow, dialog } from "electron"; +import log from "electron-log"; + +export class UpdateService { + private mainWindow: BrowserWindow | null = null; + + initialize(window: BrowserWindow): void { + this.mainWindow = window; + + // Configure logging + autoUpdater.logger = log; + autoUpdater.autoDownload = false; + autoUpdater.autoInstallOnAppQuit = true; + + // Event handlers + autoUpdater.on("checking-for-update", () => { + log.info("Checking for updates..."); + }); + + autoUpdater.on("update-available", (info: UpdateInfo) => { + log.info("Update available:", info.version); + this.mainWindow?.webContents.send("app:update-available", { + version: info.version, + }); + this.promptForUpdate(info); + }); + + autoUpdater.on("update-not-available", () => { + log.info("No updates available"); + }); + + autoUpdater.on("error", (error) => { + log.error("Update error:", error); + }); + + autoUpdater.on("download-progress", (progress) => { + log.info(`Download progress: ${progress.percent.toFixed(1)}%`); + }); + + autoUpdater.on("update-downloaded", () => { + log.info("Update downloaded"); + this.mainWindow?.webContents.send("app:update-downloaded"); + this.promptForRestart(); + }); + } + + async checkForUpdates(): Promise { + try { + await autoUpdater.checkForUpdates(); + } catch (error) { + log.error("Failed to check for updates:", error); + } + } + + private async promptForUpdate(info: UpdateInfo): Promise { + const result = await dialog.showMessageBox(this.mainWindow!, { + type: "info", + title: "Update Available", + message: `Version ${info.version} is available. Would you like to download it?`, + buttons: ["Download", "Later"], + }); + + if (result.response === 0) { + autoUpdater.downloadUpdate(); + } + } + + private async promptForRestart(): Promise { + const result = await dialog.showMessageBox(this.mainWindow!, { + type: "info", + title: "Update Ready", + message: + "A new version has been downloaded. Restart to apply the update?", + buttons: ["Restart Now", "Later"], + }); + + if (result.response === 0) { + autoUpdater.quitAndInstall(false, true); + } + } +} + +export const updateService = new UpdateService(); +``` + +--- + +## Security Best Practices + +### Security Checklist + +Mandatory Security Settings: + +- contextIsolation: true (always enable) +- nodeIntegration: false (never enable in renderer) +- sandbox: true (always enable) +- webSecurity: true (never disable) + +IPC Security Rules: + +- Validate all inputs from renderer +- Never expose Node.js APIs directly +- Use invoke/handle pattern (not send/on for sensitive operations) +- Whitelist allowed operations + +Content Security Policy: + +- Restrict script sources to 'self' +- Disable unsafe-inline for scripts +- Use nonce or hash for inline scripts if needed + +### Input Validation + +```typescript +// src/main/ipc/validators.ts +import { z } from "zod"; + +const FilePathSchema = z.string().refine( + (path) => { + // Prevent path traversal + const normalized = path.replace(/\\/g, "/"); + return !normalized.includes("..") && !normalized.startsWith("/"); + }, + { message: "Invalid file path" }, +); + +const StorageKeySchema = z + .string() + .min(1) + .max(100) + .regex(/^[a-zA-Z0-9_.-]+$/); + +export const validators = { + filePath: (path: unknown) => FilePathSchema.parse(path), + storageKey: (key: unknown) => StorageKeySchema.parse(key), +}; +``` + +--- + +## Native Integration + +### System Tray + +```typescript +// src/main/services/tray.ts +import { Tray, Menu, app, nativeImage } from "electron"; +import { join } from "path"; + +export class TrayService { + private tray: Tray | null = null; + + initialize(): void { + const iconPath = join(__dirname, "../../resources/icons/tray.png"); + const icon = nativeImage.createFromPath(iconPath); + + this.tray = new Tray(icon); + this.tray.setToolTip(app.getName()); + + const contextMenu = Menu.buildFromTemplate([ + { + label: "Show App", + click: () => { + const { windowManager } = require("./window-manager"); + const mainWindow = windowManager.getWindow("main"); + mainWindow?.show(); + mainWindow?.focus(); + }, + }, + { type: "separator" }, + { + label: "Preferences", + accelerator: "CmdOrCtrl+,", + click: () => { + // Open preferences + }, + }, + { type: "separator" }, + { + label: "Quit", + accelerator: "CmdOrCtrl+Q", + click: () => app.quit(), + }, + ]); + + this.tray.setContextMenu(contextMenu); + + // macOS: Click to show app + this.tray.on("click", () => { + const { windowManager } = require("./window-manager"); + const mainWindow = windowManager.getWindow("main"); + mainWindow?.show(); + mainWindow?.focus(); + }); + } + + destroy(): void { + this.tray?.destroy(); + this.tray = null; + } +} + +export const trayService = new TrayService(); +``` + +### Native Menu + +```typescript +// src/main/services/menu.ts +import { Menu, app, shell, MenuItemConstructorOptions } from "electron"; + +export function createApplicationMenu(): void { + const isMac = process.platform === "darwin"; + + const template: MenuItemConstructorOptions[] = [ + // App menu (macOS only) + ...(isMac + ? [ + { + label: app.name, + submenu: [ + { role: "about" as const }, + { type: "separator" as const }, + { role: "services" as const }, + { type: "separator" as const }, + { role: "hide" as const }, + { role: "hideOthers" as const }, + { role: "unhide" as const }, + { type: "separator" as const }, + { role: "quit" as const }, + ], + }, + ] + : []), + + // File menu + { + label: "File", + submenu: [ + { + label: "New", + accelerator: "CmdOrCtrl+N", + click: () => { + // Handle new file + }, + }, + { + label: "Open...", + accelerator: "CmdOrCtrl+O", + click: () => { + // Handle open + }, + }, + { type: "separator" }, + { + label: "Save", + accelerator: "CmdOrCtrl+S", + click: () => { + // Handle save + }, + }, + { type: "separator" }, + isMac ? { role: "close" } : { role: "quit" }, + ], + }, + + // Edit menu + { + label: "Edit", + submenu: [ + { role: "undo" }, + { role: "redo" }, + { type: "separator" }, + { role: "cut" }, + { role: "copy" }, + { role: "paste" }, + { role: "selectAll" }, + ], + }, + + // View menu + { + label: "View", + submenu: [ + { role: "reload" }, + { role: "forceReload" }, + { role: "toggleDevTools" }, + { type: "separator" }, + { role: "resetZoom" }, + { role: "zoomIn" }, + { role: "zoomOut" }, + { type: "separator" }, + { role: "togglefullscreen" }, + ], + }, + + // Help menu + { + label: "Help", + submenu: [ + { + label: "Documentation", + click: () => shell.openExternal("https://docs.example.com"), + }, + { + label: "Report Issue", + click: () => + shell.openExternal("https://github.com/example/repo/issues"), + }, + ], + }, + ]; + + const menu = Menu.buildFromTemplate(template); + Menu.setApplicationMenu(menu); +} +``` + +--- + +## Configuration + +### Electron Forge Configuration + +```javascript +// forge.config.js +module.exports = { + packagerConfig: { + asar: true, + darwinDarkModeSupport: true, + executableName: "my-app", + appBundleId: "com.example.myapp", + appCategoryType: "public.app-category.developer-tools", + icon: "./resources/icons/icon", + osxSign: { + identity: "Developer ID Application: Your Name (TEAM_ID)", + "hardened-runtime": true, + entitlements: "./entitlements.plist", + "entitlements-inherit": "./entitlements.plist", + "signature-flags": "library", + }, + osxNotarize: { + appleId: process.env.APPLE_ID, + appleIdPassword: process.env.APPLE_PASSWORD, + teamId: process.env.APPLE_TEAM_ID, + }, + }, + rebuildConfig: {}, + makers: [ + { + name: "@electron-forge/maker-squirrel", + config: { + name: "my_app", + setupIcon: "./resources/icons/icon.ico", + }, + }, + { + name: "@electron-forge/maker-zip", + platforms: ["darwin"], + }, + { + name: "@electron-forge/maker-dmg", + config: { + icon: "./resources/icons/icon.icns", + format: "ULFO", + }, + }, + { + name: "@electron-forge/maker-deb", + config: { + options: { + maintainer: "Your Name", + homepage: "https://example.com", + }, + }, + }, + { + name: "@electron-forge/maker-rpm", + config: {}, + }, + ], + plugins: [ + { + name: "@electron-forge/plugin-vite", + config: { + build: [ + { + entry: "src/main/index.ts", + config: "vite.main.config.ts", + }, + { + entry: "src/preload/index.ts", + config: "vite.preload.config.ts", + }, + ], + renderer: [ + { + name: "main_window", + config: "vite.renderer.config.ts", + }, + ], + }, + }, + ], + publishers: [ + { + name: "@electron-forge/publisher-github", + config: { + repository: { + owner: "your-username", + name: "your-repo", + }, + prerelease: false, + }, + }, + ], +}; +``` + +### Electron Builder Configuration + +```yaml +# electron-builder.yml +appId: com.example.myapp +productName: My App +copyright: Copyright (c) 2025 Your Name + +directories: + output: dist + buildResources: resources + +files: + - "!**/.vscode/*" + - "!src/*" + - "!docs/*" + - "!*.md" + +extraResources: + - from: resources/ + to: resources/ + filter: + - "**/*" + +asar: true +compression: maximum + +mac: + category: public.app-category.developer-tools + icon: resources/icons/icon.icns + hardenedRuntime: true + gatekeeperAssess: false + entitlements: entitlements.mac.plist + entitlementsInherit: entitlements.mac.plist + notarize: + teamId: ${APPLE_TEAM_ID} + target: + - target: dmg + arch: [x64, arm64] + - target: zip + arch: [x64, arm64] + +dmg: + sign: false + contents: + - x: 130 + y: 220 + - x: 410 + y: 220 + type: link + path: /Applications + +win: + icon: resources/icons/icon.ico + signingHashAlgorithms: [sha256] + signAndEditExecutable: true + target: + - target: nsis + arch: [x64] + - target: portable + arch: [x64] + +nsis: + oneClick: false + allowToChangeInstallationDirectory: true + installerIcon: resources/icons/icon.ico + uninstallerIcon: resources/icons/icon.ico + installerHeaderIcon: resources/icons/icon.ico + createDesktopShortcut: true + createStartMenuShortcut: true + +linux: + icon: resources/icons + category: Development + target: + - target: AppImage + arch: [x64] + - target: deb + arch: [x64] + - target: rpm + arch: [x64] + +publish: + provider: github + owner: your-username + repo: your-repo +``` + +--- + +## Utility Process (Electron 33+) + +### Background Task Worker + +Utility processes run in a separate Node.js environment for CPU-intensive tasks without blocking the main process: + +```typescript +// src/main/workers/utility-worker.ts +import { utilityProcess, MessageChannelMain } from "electron"; +import { join } from "path"; + +export class UtilityWorker { + private worker: Electron.UtilityProcess | null = null; + private port: Electron.MessagePortMain | null = null; + + async spawn(): Promise { + const { port1, port2 } = new MessageChannelMain(); + + this.worker = utilityProcess.fork( + join(__dirname, "workers/image-processor.js"), + [], + { + serviceName: "image-processor", + allowLoadingUnsignedLibraries: false, + } + ); + + this.worker.postMessage({ type: "init" }, [port1]); + this.port = port2; + + this.worker.on("exit", (code) => { + console.log(`Utility process exited with code ${code}`); + this.worker = null; + }); + } + + async processImage(imagePath: string): Promise { + return new Promise((resolve, reject) => { + if (!this.port) { + reject(new Error("Worker not initialized")); + return; + } + + const handler = (event: Electron.MessageEvent) => { + if (event.data.type === "result") { + this.port?.removeListener("message", handler); + resolve(Buffer.from(event.data.buffer)); + } else if (event.data.type === "error") { + this.port?.removeListener("message", handler); + reject(new Error(event.data.message)); + } + }; + + this.port.on("message", handler); + this.port.postMessage({ type: "process", path: imagePath }); + }); + } + + terminate(): void { + this.worker?.kill(); + this.worker = null; + this.port = null; + } +} +``` + +### Utility Process Script + +```typescript +// src/main/workers/image-processor.js +const sharp = require("sharp"); + +process.parentPort.on("message", async (event) => { + const [port] = event.ports; + + port.on("message", async (msgEvent) => { + const { type, path } = msgEvent.data; + + if (type === "process") { + try { + const buffer = await sharp(path) + .resize(800, 600, { fit: "inside" }) + .webp({ quality: 80 }) + .toBuffer(); + + port.postMessage({ type: "result", buffer }); + } catch (error) { + port.postMessage({ type: "error", message: error.message }); + } + } + }); + + port.start(); +}); +``` + +--- + +## Protocol Handlers and Deep Linking + +### Custom Protocol Registration + +```typescript +// src/main/protocol.ts +import { app, protocol, net } from "electron"; +import { join } from "path"; +import { pathToFileURL } from "url"; + +const PROTOCOL_NAME = "myapp"; + +export function registerProtocols(): void { + // Register as default protocol client (for deep linking) + if (process.defaultApp) { + if (process.argv.length >= 2) { + app.setAsDefaultProtocolClient(PROTOCOL_NAME, process.execPath, [ + join(process.argv[1]), + ]); + } + } else { + app.setAsDefaultProtocolClient(PROTOCOL_NAME); + } + + // Register custom protocol handler for local resources + protocol.handle("app", (request) => { + const url = new URL(request.url); + const filePath = join(__dirname, "../renderer", url.pathname); + return net.fetch(pathToFileURL(filePath).toString()); + }); +} + +export function handleProtocolUrl(url: string): void { + // Parse the URL: myapp://action/path?query=value + const parsedUrl = new URL(url); + + switch (parsedUrl.hostname) { + case "open": + handleOpenAction(parsedUrl.pathname, parsedUrl.searchParams); + break; + case "auth": + handleAuthCallback(parsedUrl.searchParams); + break; + default: + console.warn("Unknown protocol action:", parsedUrl.hostname); + } +} + +function handleOpenAction( + path: string, + params: URLSearchParams +): void { + // Handle open file/project action + const filePath = decodeURIComponent(path.slice(1)); + // Send to renderer or process directly +} + +function handleAuthCallback(params: URLSearchParams): void { + // Handle OAuth callback + const code = params.get("code"); + const state = params.get("state"); + // Process authentication +} +``` + +### macOS Deep Link Handling + +```typescript +// src/main/index.ts +app.on("open-url", (event, url) => { + event.preventDefault(); + handleProtocolUrl(url); +}); + +// Handle deep link on Windows/Linux (second instance) +app.on("second-instance", (_event, commandLine) => { + const url = commandLine.find((arg) => arg.startsWith(`${PROTOCOL_NAME}://`)); + if (url) { + handleProtocolUrl(url); + } + + // Focus the main window + const mainWindow = windowManager.getWindow("main"); + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); + } +}); +``` + +--- + +## Security Hardening (OWASP Aligned) + +### Comprehensive Security Configuration + +```typescript +// src/main/security.ts +import { app, session, shell, BrowserWindow } from "electron"; + +export function configureSecurity(): void { + // 1. Disable remote module (deprecated but ensure disabled) + app.on("remote-get-builtin", (event) => event.preventDefault()); + app.on("remote-get-current-web-contents", (event) => event.preventDefault()); + app.on("remote-get-current-window", (event) => event.preventDefault()); + + // 2. Block navigation to untrusted origins + app.on("web-contents-created", (_event, contents) => { + // Block navigation + contents.on("will-navigate", (event, url) => { + const allowedOrigins = ["http://localhost", "file://"]; + const isAllowed = allowedOrigins.some((origin) => url.startsWith(origin)); + if (!isAllowed) { + event.preventDefault(); + console.warn("Blocked navigation to:", url); + } + }); + + // Block new windows + contents.setWindowOpenHandler(({ url }) => { + // Open external URLs in default browser + if (url.startsWith("https://") || url.startsWith("http://")) { + shell.openExternal(url); + } + return { action: "deny" }; + }); + + // Block webview creation + contents.on("will-attach-webview", (event) => { + event.preventDefault(); + console.warn("Blocked webview creation"); + }); + }); +} + +export function configureSessionSecurity(): void { + const ses = session.defaultSession; + + // Content Security Policy + ses.webRequest.onHeadersReceived((details, callback) => { + callback({ + responseHeaders: { + ...details.responseHeaders, + "Content-Security-Policy": [ + [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: https:", + "font-src 'self' data:", + "connect-src 'self' https://api.example.com", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + ].join("; "), + ], + "X-Content-Type-Options": ["nosniff"], + "X-Frame-Options": ["DENY"], + "X-XSS-Protection": ["1; mode=block"], + }, + }); + }); + + // Permission request handler + ses.setPermissionRequestHandler((webContents, permission, callback) => { + const allowedPermissions: Electron.PermissionType[] = [ + "notifications", + "clipboard-read", + ]; + + const denied: Electron.PermissionType[] = [ + "geolocation", + "media", + "mediaKeySystem", + "midi", + "pointerLock", + "fullscreen", + ]; + + if (denied.includes(permission)) { + console.warn(`Denied permission: ${permission}`); + callback(false); + return; + } + + callback(allowedPermissions.includes(permission)); + }); + + // Certificate error handler (for development, not production) + if (process.env.NODE_ENV !== "development") { + ses.setCertificateVerifyProc((request, callback) => { + // Reject invalid certificates in production + if (request.errorCode !== 0) { + console.error("Certificate error:", request.hostname); + callback(-2); // Reject + return; + } + callback(0); // Accept + }); + } +} +``` + +### Secure BrowserWindow Factory + +```typescript +// src/main/windows/secure-window.ts +import { BrowserWindow, BrowserWindowConstructorOptions } from "electron"; +import { join } from "path"; + +export function createSecureWindow( + options: BrowserWindowConstructorOptions = {} +): BrowserWindow { + const secureDefaults: BrowserWindowConstructorOptions = { + webPreferences: { + // Security: Isolate renderer from Node.js + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + + // Security: Disable dangerous features + webSecurity: true, + allowRunningInsecureContent: false, + experimentalFeatures: false, + enableWebSQL: false, + + // Security: Preload script for safe API exposure + preload: join(__dirname, "../preload/index.js"), + + // Security: Disable devtools in production + devTools: process.env.NODE_ENV === "development", + + // Performance: Disable unused features + spellcheck: false, + backgroundThrottling: true, + }, + + // Security: Prevent title from showing sensitive data + title: "My App", + }; + + return new BrowserWindow({ + ...secureDefaults, + ...options, + webPreferences: { + ...secureDefaults.webPreferences, + ...options.webPreferences, + }, + }); +} +``` + +--- + +## Crash Reporting and Telemetry + +### Crash Reporter Setup + +```typescript +// src/main/crash-reporter.ts +import { crashReporter, app } from "electron"; +import { join } from "path"; + +export function initializeCrashReporter(): void { + crashReporter.start({ + productName: app.getName(), + companyName: "Your Company", + submitURL: "https://your-crash-server.com/submit", + uploadToServer: true, + ignoreSystemCrashHandler: false, + rateLimit: true, + compress: true, + extra: { + version: app.getVersion(), + platform: process.platform, + arch: process.arch, + }, + }); + + // Log crash reports location for debugging + console.log("Crash reports path:", app.getPath("crashDumps")); +} + +export function addCrashContext(key: string, value: string): void { + crashReporter.addExtraParameter(key, value); +} +``` + +### Error Boundary in Main Process + +```typescript +// src/main/error-handler.ts +import { dialog, app } from "electron"; +import log from "electron-log"; + +export function setupErrorHandlers(): void { + // Unhandled promise rejections + process.on("unhandledRejection", (reason, promise) => { + log.error("Unhandled Rejection:", reason); + + if (process.env.NODE_ENV === "development") { + dialog.showErrorBox( + "Unhandled Promise Rejection", + String(reason) + ); + } + }); + + // Uncaught exceptions + process.on("uncaughtException", (error) => { + log.error("Uncaught Exception:", error); + + dialog.showErrorBox( + "Application Error", + `An unexpected error occurred: ${error.message}\n\nThe application will now quit.` + ); + + app.quit(); + }); + + // Renderer process crashes + app.on("render-process-gone", (event, webContents, details) => { + log.error("Renderer process gone:", details); + + if (details.reason === "crashed") { + const options = { + type: "error" as const, + title: "Window Crashed", + message: "This window has crashed.", + buttons: ["Reload", "Close"], + }; + + dialog.showMessageBox(options).then((result) => { + if (result.response === 0) { + webContents.reload(); + } + }); + } + }); + + // GPU process crashes + app.on("child-process-gone", (event, details) => { + log.error("Child process gone:", details); + + if (details.type === "GPU" && details.reason === "crashed") { + log.warn("GPU process crashed, app may have rendering issues"); + } + }); +} +``` + +--- + +## Native Module Integration + +### Rebuilding Native Modules + +```json +// package.json scripts +{ + "scripts": { + "postinstall": "electron-builder install-app-deps", + "rebuild": "electron-rebuild -f -w better-sqlite3,keytar" + } +} +``` + +### Native Module Usage Pattern + +```typescript +// src/main/services/database.ts +import Database from "better-sqlite3"; +import { app } from "electron"; +import { join } from "path"; + +export class DatabaseService { + private db: Database.Database; + + constructor() { + const dbPath = join(app.getPath("userData"), "app.db"); + this.db = new Database(dbPath, { + verbose: process.env.NODE_ENV === "development" ? console.log : undefined, + }); + + // Enable WAL mode for better concurrency + this.db.pragma("journal_mode = WAL"); + + // Initialize schema + this.initializeSchema(); + } + + private initializeSchema(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER DEFAULT (strftime('%s', 'now')) + ); + + CREATE TABLE IF NOT EXISTS documents ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + content TEXT, + created_at INTEGER DEFAULT (strftime('%s', 'now')), + updated_at INTEGER DEFAULT (strftime('%s', 'now')) + ); + `); + } + + getSetting(key: string): T | undefined { + const row = this.db + .prepare("SELECT value FROM settings WHERE key = ?") + .get(key) as { value: string } | undefined; + return row ? JSON.parse(row.value) : undefined; + } + + setSetting(key: string, value: T): void { + this.db + .prepare( + "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, strftime('%s', 'now'))" + ) + .run(key, JSON.stringify(value)); + } + + close(): void { + this.db.close(); + } +} +``` + +--- + +## Troubleshooting + +### Common Issues + +Issue: "Electron could not be found" error +Symptoms: App fails to start, module not found +Solution: + +- Ensure electron is in devDependencies +- Run npm rebuild electron +- Check NODE_ENV is set correctly + +Issue: White screen on launch +Symptoms: Window opens but content doesn't load +Solution: + +- Check preload script path is correct +- Verify loadFile/loadURL path +- Enable devTools to see console errors +- Check for CSP blocking scripts + +Issue: IPC not working +Symptoms: invoke returns undefined, no response +Solution: + +- Verify channel names match exactly +- Check handler is registered before window loads +- Ensure contextBridge is used correctly +- Verify preload script is loaded + +Issue: Native modules fail to load +Symptoms: "Module was compiled against different Node.js version" +Solution: + +- Run electron-rebuild after npm install +- Match Electron Node.js version +- Use postinstall script for automatic rebuild + +Issue: Auto-update not working +Symptoms: No update notification, silent failure +Solution: + +- Check app is signed (required for updates) +- Verify publish configuration +- Check network/firewall settings +- Enable electron-updater logging + +--- + +## External Resources + +### Official Documentation + +- Electron Documentation: https://www.electronjs.org/docs +- Electron Forge: https://www.electronforge.io/ +- Electron Builder: https://www.electron.build/ + +### Security + +- Security Checklist: https://www.electronjs.org/docs/tutorial/security +- Context Isolation: https://www.electronjs.org/docs/tutorial/context-isolation + +### Build & Distribution + +- Code Signing: https://www.electronjs.org/docs/tutorial/code-signing +- Auto Updates: https://www.electronjs.org/docs/tutorial/updates +- macOS Notarization: https://www.electronjs.org/docs/tutorial/mac-app-store-submission-guide + +### Testing + +- Playwright: https://playwright.dev/ +- Testing Guide: https://www.electronjs.org/docs/tutorial/testing-on-headless-ci + +--- + +Version: 1.1.0 +Last Updated: 2026-01-10 +Changes: Added Utility Process patterns, Protocol Handlers, Deep Linking, OWASP-aligned Security Hardening, Crash Reporting, Native Module Integration diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..bddb3fd --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,29 @@ +{ + "permissions": { + "allow": [ + "Bash(tree:*)", + "Bash(git log:*)", + "Bash(ls:*)", + "Bash(dir:*)", + "Bash(pnpm list:*)", + "Bash(pnpm add prosemirror-state prosemirror-view prosemirror-model prosemirror-transform prosemirror-commands prosemirror-keymap prosemirror-history prosemirror-inputrules prosemirror-schema-list prosemirror-dropcursor prosemirror-gapcursor --save)", + "Bash(npx tsc --noEmit --skipLibCheck src/core/index.ts)", + "Bash(pnpm add @codemirror/lang-javascript @codemirror/lang-python @codemirror/lang-html @codemirror/lang-css @codemirror/lang-json --save)", + "Bash(npx tsc --noEmit --skipLibCheck)", + "Bash(timeout 30 pnpm dev:*)", + "Bash(timeout 20 pnpm dev:*)", + "Bash(timeout 25 pnpm dev:*)", + "Bash(findstr:*)", + "Bash(pnpm add katex)", + "Bash(pnpm add -D @types/katex)", + "Bash(pnpm run build)", + "Bash(pnpm add @lezer/highlight)", + "Bash(git add:*)", + "Bash(pnpm run build:core)", + "Bash(pnpm run typecheck)", + "Bash(pnpm install)", + "Bash(node test-version-compare.js)", + "Bash(git show:*)" + ] + } +} diff --git a/lang/index.json b/lang/index.json index 5621a11..196bf89 100644 --- a/lang/index.json +++ b/lang/index.json @@ -1729,766 +1729,1670 @@ }, "cvjdluk": { "zh-cn": "ir-ProzillaOS-选用-_0]", + "ja": "ir-ProzillaOS-選択-_0]", + "ko": "ir-ProzillaOS-선택-_0]", + "ru": "ir-ProzillaOS-выбор-_0]", + "en": "ir-ProzillaOS-selection-_0]", + "fr": "ir-ProzillaOS-sélection-_0]" + }, + "nbek3z7": { + "zh-cn": "编辑器字体大小", + "ja": "エディタのフォントサイズ", + "ko": "에디터 글꼴 크기", + "ru": "размер шрифта редактора", + "en": "Editor font size", + "fr": "Taille de police de l'éditeur" + }, + "b372gwa": { + "zh-cn": "文本编辑器的字体大小", + "ja": "テキストエディタのフォントサイズ", + "ko": "텍스트 에디터의 글꼴 크기", + "ru": "размер шрифта текстового редактора", + "en": "Font size of text editor", + "fr": "Taille de police de l'éditeur de texte" + }, + "rihv6q6": { + "zh-cn": "代码字体大小", + "ja": "コードのフォントサイズ", + "ko": "코드 글꼴 크기", + "ru": "размер шрифта кода", + "en": "Code font size", + "fr": "Taille de police du code" + }, + "qwmffy9": { + "zh-cn": "代码显示的字体大小", + "ja": "コード表示のフォントサイズ", + "ko": "코드 표시 글꼴 크기", + "ru": "размер шрифта отображения кода", + "en": "Font size of code display", + "fr": "Taille de police de l'affichage du code" + }, + "z8ochc9": { + "zh-cn": "一级标题的字体大小", + "ja": "レベル1見出しのフォントサイズ", + "ko": "1레벨 제목 글꼴 크기", + "ru": "размер шрифта заголовка первого уровня", + "en": "Font size of level 1 heading", + "fr": "Taille de police du titre de niveau 1" + }, + "tao6g49": { + "zh-cn": "二级标题的字体大小", + "ja": "レベル2見出しのフォントサイズ", + "ko": "2레벨 제목 글꼴 크기", + "ru": "размер шрифта заголовка второго уровня", + "en": "Font size of level 2 heading", + "fr": "Taille de police du titre de niveau 2" + }, + "k58bon9": { + "zh-cn": "三级标题的字体大小", + "ja": "レベル3見出しのフォントサイズ", + "ko": "3레벨 제목 글꼴 크기", + "ru": "размер шрифта заголовка третьего уровня", + "en": "Font size of level 3 heading", + "fr": "Taille de police du titre de niveau 3" + }, + "y4mch79": { + "zh-cn": "四级标题的字体大小", + "ja": "レベル4見出しのフォントサイズ", + "ko": "4레벨 제목 글꼴 크기", + "ru": "размер шрифта заголовка четвертого уровня", + "en": "Font size of level 4 heading", + "fr": "Taille de police du titre de niveau 4" + }, + "fovv509": { + "zh-cn": "五级标题的字体大小", + "ja": "レベル5見出しのフォントサイズ", + "ko": "5레벨 제목 글꼴 크기", + "ru": "размер шрифта заголовка пятого уровня", + "en": "Font size of level 5 heading", + "fr": "Taille de police du titre de niveau 5" + }, + "ux0q4d9": { + "zh-cn": "六级标题的字体大小", + "ja": "レベル6見出しのフォントサイズ", + "ko": "6레벨 제목 글꼴 크기", + "ru": "размер шрифта заголовка шестого уровня", + "en": "Font size of level 6 heading", + "fr": "Taille de police du titre de niveau 6" + }, + "bv1d1o4": { + "zh-cn": "字体设置", + "ja": "フォント設定", + "ko": "글꼴 설정", + "ru": "настройка шрифта", + "en": "Font settings", + "fr": "Paramètres de police" + }, + "9obzlmf": { + "zh-cn": " 配置编辑器和代码的字体样式 ", + "ja": " エディターとコードのフォントスタイルを設定 ", + "ko": " 에디터와 코드의 글꼴 스타일 구성 ", + "ru": " Настройка стиля шрифта редактора и кода ", + "en": " Configure the font style of editor and code ", + "fr": " Configurer le style de police de l'éditeur et du code " + }, + "rqjnhg6": { + "zh-cn": " 字体选择 ", + "ja": " フォントの選択 ", + "ko": " 글꼴 선택 ", + "ru": " Выбор шрифта ", + "en": " Font selection ", + "fr": " Sélection de police " + }, + "p1j42": { + "zh-cn": "选择", + "ja": "選択", + "ko": "선택", + "ru": "выбор", + "en": "Selection", + "fr": "Sélection" + }, + "bvptyo4": { + "zh-cn": "字号设置", + "ja": "フォントサイズ設定", + "ko": "글꼴 크기 설정", + "ru": "настройка размера шрифта", + "en": "Font size settings", + "fr": "Paramètres de taille de police" + }, + "yzdvief": { + "zh-cn": " 配置不同文本元素的字体大小 ", + "ja": " 異なるテキスト要素のフォントサイズを設定 ", + "ko": " 다양한 텍스트 요소의 글꼴 크기 구성 ", + "ru": " Настройка размера шрифта различных текстовых элементов ", + "en": " Configure the font size of different text elements ", + "fr": " Configurer la taille de police des différents éléments de texte " + }, + "lzsxbc6": { + "zh-cn": " 正文内容 ", + "ja": " 本文コンテンツ ", + "ko": " 본문 내용 ", + "ru": " Основной текст ", + "en": " Main content ", + "fr": " Contenu principal " + }, + "ilv7mg6": { + "zh-cn": " 一级标题 ", + "ja": " レベル1見出し ", + "ko": " 1레벨 제목 ", + "ru": " Заголовок первого уровня ", + "en": " Level 1 heading ", + "fr": " Titre de niveau 1 " + }, + "ggw0jw6": { + "zh-cn": " 二级标题 ", + "ja": " レベル2見出し ", + "ko": " 2레벨 제목 ", + "ru": " Заголовок второго уровня ", + "en": " Level 2 heading ", + "fr": " Titre de niveau 2 " + }, + "igx2a76": { + "zh-cn": " 三级标题 ", + "ja": " レベル3見出し ", + "ko": " 3레벨 제목 ", + "ru": " Заголовок третьего уровня ", + "en": " Level 3 heading ", + "fr": " Titre de niveau 3 " + }, + "g0mdj76": { + "zh-cn": " 四级标题 ", + "ja": " レベル4見出し ", + "ko": " 4레벨 제목 ", + "ru": " Заголовок четвертого уровня ", + "en": " Level 4 heading ", + "fr": " Titre de niveau 4 " + }, + "gchnt06": { + "zh-cn": " 五级标题 ", + "ja": " レベル5見出し ", + "ko": " 5레벨 제목 ", + "ru": " Заголовок пятого уровня ", + "en": " Level 5 heading ", + "fr": " Titre de niveau 5 " + }, + "57nn8r6": { + "zh-cn": " 六级标题 ", + "ja": " レベル6見出し ", + "ko": " 6레벨 제목 ", + "ru": " Заголовок шестого уровня ", + "en": " Level 6 heading ", + "fr": " Titre de niveau 6 " + }, + "rih3a46": { + "zh-cn": " 字体大小 ", + "ja": " フォントサイズ ", + "ko": " 글꼴 크기 ", + "ru": " размер шрифта ", + "en": " Font size ", + "fr": " Taille de police " + }, + "w5t755": { + "zh-cn": " [只读]", + "ja": "[読み取り専用]", + "ko": "[읽기 전용]", + "ru": "[только для чтения]", + "en": "[Read-only]", + "fr": "[Lecture seule]" + }, + "rp05676": { + "zh-cn": " [只读] ", + "ja": " [読み取り専用] ", + "ko": " [읽기 전용] ", + "ru": " [только для чтения] ", + "en": " [Read-only] ", + "fr": " [Lecture seule] " + }, + "cjk8jz5": { + "zh-cn": "[只读] ", + "ja": "[読み取り専用] ", + "ko": "[읽기 전용] ", + "ru": "[только для чтения] ", + "en": "[Read-only] ", + "fr": "[Lecture seule] " + }, + "z3sj0e6": { + "zh-cn": "保存文件失败", + "ja": "ファイルの保存に失敗しました", + "ko": "파일 저장에 실패했습니다", + "ru": "Не удалось сохранить файл", + "en": "Failed to save file", + "fr": "Échec de la sauvegarde du fichier" + }, + "9rm15ze": { + "zh-cn": "保存文件失败,请检查写入权限", + "ja": "ファイルの保存に失敗しました。書き込み権限を確認してください", + "ko": "파일 저장에 실패했습니다. 쓰기 권한을 확인하세요", + "ru": "Не удалось сохранить файл. Проверьте права на запись", + "en": "Failed to save file, please check write permissions", + "fr": "Échec de la sauvegarde du fichier, veuillez vérifier les autorisations d'écriture" + }, + "ccpga9z": { + "zh-cn": "文件已被外部修改,但您有未保存的更改。请先保存或丢弃更改后再重新加载。", + "ja": "ファイルは外部から変更されましたが、保存されていない変更があります。変更を保存または破棄してから再読み込みしてください。", + "ko": "파일이 외부에서 수정되었지만 저장되지 않은 변경 사항이 있습니다. 변경 사항을 저장하거나 폐기한 후 다시 로드하세요.", + "ru": "Файл был изменен извне, но у вас есть несохраненные изменения. Сохраните или отбросьте изменения перед повторной загрузкой.", + "en": "The file has been modified externally, but you have unsaved changes. Please save or discard changes before reloading.", + "fr": "Le fichier a été modifié externement, mais vous avez des modifications non enregistrées. Veuillez enregistrer ou abandonner les modifications avant de recharger." + }, + "d4k9sh4": { + "zh-cn": "文件 \"", + "ja": "ファイル「", + "ko": "파일 \"", + "ru": "Файл «", + "en": "File \"", + "fr": "Fichier «" + }, + "t04it76": { + "zh-cn": "\" 已被删除", + "ja": "」は削除されました", + "ko": "\"이 삭제되었습니다", + "ru": "» был удален", + "en": "\" has been deleted", + "fr": "» a été supprimé" + }, + "uje4506": { + "zh-cn": "监听文件 \"", + "ja": "ファイル「の監視中", + "ko": "파일 \"모니터링 중", + "ru": "При мониторинге файла «", + "en": "Error monitoring file \"", + "fr": "Erreur lors de la surveillance du fichier «" + }, + "egxfh7": { + "zh-cn": "\" 时出错: ", + "ja": "エラーが発生しました: ", + "ko": "오류 발생: ", + "ru": "возникла ошибка: ", + "en": "\": ", + "fr": "\": " + }, + "diprg14": { + "zh-cn": "未知错误", + "ja": "不明なエラー", + "ko": "알 수 없는 오류", + "ru": "Неизвестная ошибка", + "en": "Unknown error", + "fr": "Erreur inconnue" + }, + "6lbcrh7": { + "zh-cn": "文件已重新加载", + "ja": "ファイルは再読み込みされました", + "ko": "파일이 다시 로드되었습니다", + "ru": "Файл был перезагружен", + "en": "File has been reloaded", + "fr": "Le fichier a été rechargé" + }, + "r2ntmg7": { + "zh-cn": " 文件已更改 ", + "ja": " ファイルが変更されました ", + "ko": " 파일이 변경되었습니다 ", + "ru": " Файл был изменен ", + "en": " File has been changed ", + "fr": " Le fichier a été modifié " + }, + "i8xfh8c": { + "zh-cn": "\" 已被其他程序修改。 ", + "ja": "」は他のプログラムによって変更されました。 ", + "ko": "\"이 다른 프로그램에 의해 수정되었습니다. ", + "ru": "» был изменен другой программой. ", + "en": "\" has been modified by another program. ", + "fr": "\" a été modifié par un autre programme. " + }, + "cbnuzvd": { + "zh-cn": " 文件已被其他程序修改。 ", + "ja": " ファイルは他のプログラムによって変更されました。 ", + "ko": " 파일이 다른 프로그램에 의해 수정되었습니다. ", + "ru": " Файл был изменен другой программой. ", + "en": " File has been modified by another program. ", + "fr": " Le fichier a été modifié par un autre programme. " + }, + "ygx5m7r": { + "zh-cn": " 是否要重新加载文件内容?当前未保存的更改将会丢失。 ", + "ja": " ファイルの内容を再読み込みしますか?現在保存されていない変更は失われます。 ", + "ko": " 파일 내용을 다시 로드하시겠습니까? 현재 저장되지 않은 변경 사항이 손실됩니다. ", + "ru": " Перезагрузить содержимое файла? Несохраненные изменения будут потеряны. ", + "en": " Do you want to reload the file content? Unsaved changes will be lost. ", + "fr": " Voulez-vous recharger le contenu du fichier? Les modifications non enregistrées seront perdues. " + }, + "u5udsw6": { + "zh-cn": " 重新加载 ", + "ja": " 再読み込み ", + "ko": " 다시 로드 ", + "ru": " Перезагрузить ", + "en": " Reload ", + "fr": " Recharger " + }, + "qzrwgd7": { + "zh-cn": " 文件已变动 ", + "ja": " ファイルが変更されました ", + "ko": " 파일이 변경되었습니다 ", + "ru": " Файл был изменен ", + "en": " File has changed ", + "fr": " Le fichier a changé " + }, + "umfap0k": { + "zh-cn": "\" 已经变动,是否覆盖当前编辑的内容? ", + "ja": "」は変更されました。現在編集中の内容を上書きしますか? ", + "ko": "\"이 변경되었습니다. 현재 편집 중인 내용을 덮어쓰시겠습니까? ", + "ru": "» был изменен. Перезаписать текущее редактируемое содержимое? ", + "en": "\" has changed, overwrite the current edited content? ", + "fr": "\" a changé, écraser le contenu actuellement édité? " + }, + "fdal1pl": { + "zh-cn": " 文件已经变动,是否覆盖当前编辑的内容? ", + "ja": " ファイルが変更されました。現在編集中の内容を上書きしますか? ", + "ko": " 파일이 변경되었습니다. 현재 편집 중인 내용을 덮어쓰시겠습니까? ", + "ru": " Файл был изменен. Перезаписать текущее редактируемое содержимое? ", + "en": " The file has changed, overwrite the current edited content? ", + "fr": " Le fichier a changé, écraser le contenu actuellement édité? " + }, + "aroz724": { + "zh-cn": "切换语言", + "ja": "言語を切り替える", + "ko": "언어 전환", + "ru": "Сменить язык", + "en": "Switch language", + "fr": "Changer de langue" + }, + "pmyml5i": { + "zh-cn": "切换语言需要重启应用,是否现在重启?", + "ja": "言語を切り替えるにはアプリケーションの再起動が必要です。今すぐ再起動しますか?", + "ko": "언어 전환을 위해 앱을 다시 시작해야 합니다. 지금 다시 시작하시겠습니까?", + "ru": "Для смены языка требуется перезапуск приложения. Перезапустить сейчас?", + "en": "Switching language requires restarting the app, restart now?", + "fr": "Le changement de langue nécessite le redémarrage de l'application, redémarrer maintenant?" + }, + "p9fm2": { + "zh-cn": "重启", + "ja": "再起動", + "ko": "재시작", + "ru": "Перезапуск", + "en": "Restart", + "fr": "Redémarrer" + }, + "ev022": { + "zh-cn": "取消", + "ja": "キャンセル", + "ko": "취소", + "ru": "Отмена", + "en": "Cancel", + "fr": "Annuler" + }, + "g93bpeb": { + "zh-cn": "请确保所有工作已经保存", + "ja": "すべての作業が保存されていることを確認してください", + "ko": "모든 작업이 저장되었는지 확인하세요", + "ru": "Убедитесь, что вся работа сохранена", + "en": "Please ensure all work has been saved", + "fr": "Veuillez vous assurer que tout le travail a été enregistré" + }, + "rq3kv48": { + "zh-cn": " 稍后手动重启 ", + "ja": " 後で手動で再起動 ", + "ko": " 나중에 수동으로 재시작 ", + "ru": " Перезапустить вручную позже ", + "en": " Restart manually later ", + "fr": " Redémarrer manuellement plus tard " + }, + "i4rsca6": { + "zh-cn": " 现在重启 ", + "ja": " 今すぐ再起動 ", + "ko": " 지금 재시작 ", + "ru": " Перезапустить сейчас ", + "en": " Restart now ", + "fr": " Redémarrer maintenant " + }, + "830mn1d": { + "zh-cn": "请确保所有工作已经保存! ", + "ja": "すべての作業が保存されていることを確認してください! ", + "ko": "모든 작업이 저장되었는지 확인하세요! ", + "ru": "Убедитесь, что вся работа сохранена! ", + "en": "Please ensure all work has been saved! ", + "fr": "Veuillez vous assurer que tout le travail a été enregistré! " + }, + "6m2p0dc": { + "zh-cn": "请确保所有工作已经保存!", + "ja": "すべての作業が保存されていることを確認してください!", + "ko": "모든 작업이 저장되었는지 확인하세요!", + "ru": "Убедитесь, что вся работа сохранена!", + "en": "Please ensure all work has been saved!", + "fr": "Veuillez vous assurer que tout le travail a été enregistré!" + }, + "5mi5h2e": { + "zh-cn": " 更新语言设置需要重启应用 ", + "ja": " 言語設定を更新するにはアプリケーションの再起動が必要です ", + "ko": " 언어 설정 업데이트를 위해 앱 재시작이 필요합니다 ", + "ru": " Для обновления языковых настроек требуется перезапуск приложения ", + "en": " Updating language settings requires restarting the app ", + "fr": " La mise à jour des paramètres de langue nécessite le redémarrage de l'application " + }, + "onktj1e": { + "zh-cn": " 请确保所有工作已经保存! ", + "ja": " すべての作業が保存されていることを確認してください! ", + "ko": " 모든 작업이 저장되었는지 확인하세요! ", + "ru": " Убедитесь, что вся работа сохранена! ", + "en": " Please ensure all work has been saved! ", + "fr": " Veuillez vous assurer que tout le travail a été enregistré! " + }, + "kywilbf": { + "zh-cn": "更新语言设置需要重启应用后生效", + "ja": "言語設定の更新はアプリケーションを再起動した後に有効になります", + "ko": "언어 설정 업데이트는 앱을 재시작한 후에 적용됩니다", + "ru": "Обновление языковых настроек вступит в силу после перезапуска приложения", + "en": "Updating language settings will take effect after restarting the app", + "fr": "La mise à jour des paramètres de langue prendra effet après le redémarrage de l'application" + }, + "eae82": { + "zh-cn": "其他", + "ja": "その他", + "ko": "기타", + "ru": "Другое", + "en": "Other", + "fr": "Autre" + }, + "5d6g4k6": { + "zh-cn": "编辑器内边距", + "ja": "エディタのパディング", + "ko": "에디터 패딩", + "ru": "Отступы редактора", + "en": "Editor padding", + "fr": "Remplissage de l'éditeur" + }, + "7xnwd2f": { + "zh-cn": " 配置编辑器内容区域的内边距 ", + "ja": " エディタのコンテンツ領域のパディングを設定 ", + "ko": " 에디터 콘텐츠 영역의 패딩 구성 ", + "ru": " Настройка отступов области содержимого редактора ", + "en": " Configure padding for the editor content area ", + "fr": " Configurer le remplissage de la zone de contenu de l'éditeur " + }, + "j5j5y58": { + "zh-cn": " 预览内容区域 ", + "ja": " プレビューコンテンツ領域 ", + "ko": " 미리보기 콘텐츠 영역 ", + "ru": " Область предварительного просмотра содержимого ", + "en": " Preview content area ", + "fr": " Zone de contenu de prévisualisation " + }, + "7puh5l7": { + "zh-cn": " 内边距设置 ", + "ja": " パディング設定 ", + "ko": " 패딩 설정 ", + "ru": " Настройка отступов ", + "en": " Padding settings ", + "fr": " Paramètres de remplissage " + }, + "xm43kg11": { + "zh-cn": " 设置编辑器内容区域的内边距,支持 CSS 单位(如 px、rem、%) ", + "ja": " エディタのコンテンツ領域のパディングを設定します。CSS単位(px、rem、%など)に対応しています ", + "ko": " 에디터 콘텐츠 영역의 패딩을 설정합니다. CSS 단위(px, rem, % 등)를 지원합니다 ", + "ru": " Настройка отступов области содержимого редактора, поддерживаются единицы CSS (например, px, rem, %) ", + "en": " Set padding for the editor content area, supports CSS units (e.g., px, rem, %) ", + "fr": " Définir le remplissage de la zone de contenu de l'éditeur, prend en charge les unités CSS (par exemple, px, rem, %) " + }, + "cn2q13": { + "zh-cn": "内边距", + "ja": "パディング", + "ko": "패딩", + "ru": "Отступы", + "en": "Padding", + "fr": "Remplissage" + }, + "dfykg38": { + "zh-cn": "例如: 20px", + "ja": "例: 20px", + "ko": "예: 20px", + "ru": "Например: 20px", + "en": "Example: 20px", + "fr": "Exemple: 20px" + }, + "rm0wns6": { + "zh-cn": " 当前值: ", + "ja": " 現在の値: ", + "ko": " 현재 값: ", + "ru": " Текущее значение: ", + "en": " Current value: ", + "fr": " Valeur actuelle: " + }, + "ok35ur7": { + "zh-cn": "编辑器其他设置", + "ja": "エディタのその他の設定", + "ko": "에디터 기타 설정", + "ru": "Другие настройки редактора", + "en": "Other editor settings", + "fr": "Autres paramètres de l'éditeur" + }, + "qiczjwb": { + "zh-cn": " 配置编辑器其他设置 ", + "ja": " エディタのその他の設定を構成 ", + "ko": " 에디터 기타 설정 구성 ", + "ru": " Настроить другие параметры редактора ", + "en": " Configure other editor settings ", + "fr": " Configurer d'autres paramètres de l'éditeur " + }, + "btd1wc4": { + "zh-cn": "外观设置", + "ja": "外観設定", + "ko": "외관 설정", + "ru": "Настройки внешнего вида", + "en": "Appearance settings", + "fr": "Paramètres d'apparence" + }, + "oauiif9": { + "zh-cn": "编辑器其他外观设置", + "ja": "エディタのその他の外観設定", + "ko": "에디터 기타 외관 설정", + "ru": "Другие настройки внешнего вида редактора", + "en": "Other appearance settings of editor", + "fr": "Autres paramètres d'apparence de l'éditeur" + }, + "u5f2sod": { + "zh-cn": " 配置编辑器其他外观设置 ", + "ja": " エディタのその他の外観設定を構成 ", + "ko": " 에디터 기타 외관 설정 구성 ", + "ru": " Настроить другие параметры внешнего вида редактора ", + "en": " Configure other appearance settings of editor ", + "fr": " Configurer d'autres paramètres d'apparence de l'éditeur " + }, + "2j8e0g5": { + "zh-cn": "请输入数字", + "ja": "数字を入力してください", + "ko": "숫자를 입력하세요", + "ru": "Введите число", + "en": "Please enter a number", + "fr": "Veuillez entrer un nombre" + }, + "rt9pmq7": { + "zh-cn": "内边距(PX)", + "ja": "パディング(PX)", + "ko": "패딩(PX)", + "ru": "Отступы (PX)", + "en": "Padding (PX)", + "fr": "Remplissage (PX)" + }, + "3o81my8": { + "zh-cn": "左右边距(PX)", + "ja": "左右のパディング(PX)", + "ko": "좌우 패딩(PX)", + "ru": "Лево-правые отступы (PX)", + "en": "Left and right padding (PX)", + "fr": "Remplissage gauche et droit (PX)" + }, + "7hktj26": { + "zh-cn": "渲染中...", + "ja": "レンダリング中...", + "ko": "렌더링 중...", + "ru": "Отрисовка...", + "en": "Rendering...", + "fr": "Rendu en cours..." + }, + "glbv83": { + "zh-cn": "流程图", + "ja": "フローチャート", + "ko": "플로우차트", + "ru": "Блок-схема", + "en": "Flow chart", + "fr": "Diagramme de flux" + }, + "fjmy2": { + "zh-cn": "图表", + "ja": "チャート", + "ko": "차트", + "ru": "Диаграмма", + "en": "Chart", + "fr": "Graphique" + }, + "fe75x3": { + "zh-cn": "时序图", + "ja": "シーケンスダイアグラム", + "ko": "시퀀스 다이어그램", + "ru": "Последовательная диаграмма", + "en": "Sequence diagram", + "fr": "Diagramme de séquence" + }, + "aftmxy4": { + "zh-cn": "下载失败", + "ja": "ダウンロードに失敗しました", + "ko": "다운로드에 실패했습니다", + "ru": "Не удалось скачать", + "en": "Download failed", + "fr": "Échec du téléchargement" + }, + "utnnr56": { + "zh-cn": "更新出错: ", + "ja": "更新中にエラーが発生しました: ", + "ko": "업데이트 중 오류 발생: ", + "ru": "Ошибка обновления: ", + "en": "Update error: ", + "fr": "Erreur de mise à jour: " + }, + "aftsuu4": { + "zh-cn": "下载完成", + "ja": "ダウンロードが完了しました", + "ko": "다운로드가 완료되었습니다", + "ru": "Загрузка завершена", + "en": "Download completed", + "fr": "Téléchargement terminé" + }, + "97iatz8": { + "zh-cn": "正在下载... ", + "ja": "ダウンロード中... ", + "ko": "다운로드 중... ", + "ru": "Загрузка... ", + "en": "Downloading... ", + "fr": "Téléchargement en cours... " + }, + "90d1z06": { + "zh-cn": " 立即更新 ", + "ja": " 今すぐ更新 ", + "ko": " 즉시 업데이트 ", + "ru": " Обновить сейчас ", + "en": " Update now ", + "fr": " Mettre à jour maintenant " + }, + "v1i5cj8": { + "zh-cn": " 下载中... ", + "ja": " ダウンロード中... ", + "ko": " 다운로드 중... ", + "ru": " Загрузка... ", + "en": " Downloading... ", + "fr": " Téléchargement en cours... " + }, + "rzmztu6": { + "zh-cn": " 重启安装 ", + "ja": " 再起動してインストール ", + "ko": " 재시작하여 설치 ", + "ru": " Перезапустить для установки ", + "en": " Restart to install ", + "fr": " Redémarrer pour installer " + }, + "d3b5r78": { + "zh-cn": "点击恢复下载弹窗", + "ja": "ダウンロード復元ポップアップをクリック", + "ko": "다운로드 복원 팝업 클릭", + "ru": "Нажмите на всплывающее окно восстановления загрузки", + "en": "Click to restore download popup", + "fr": "Cliquez pour restaurer la fenêtre contextuelle de téléchargement" + }, + "50uczt5": { + "zh-cn": "正在下载 ", + "ja": "ダウンロード中 ", + "ko": "다운로드 중 ", + "ru": "Загрузка ", + "en": "Downloading ", + "fr": "Téléchargement en cours " + }, + "5f4uk49": { + "zh-cn": "下载完成,点击安装", + "ja": "ダウンロードが完了しました。クリックしてインストールしてください", + "ko": "다운로드가 완료되었습니다. 클릭하여 설치하세요", + "ru": "Загрузка завершена, нажмите для установки", + "en": "Download completed, click to install", + "fr": "Téléchargement terminé, cliquez pour installer" + }, + "vz4lz7f": { + "zh-cn": "milkup 新版本现已发布!", + "ja": "milkup の新しいバージョンがリリースされました!", + "ko": "milkup 새로운 버전이 출시되었습니다!", + "ru": "Новая версия milkup выпущена!", + "en": "A new version of milkup is now available!", + "fr": "Une nouvelle version de milkup est maintenant disponible!" + }, + "aik7f46": { + "zh-cn": "前往发布页 ", + "ja": " リリースページに移動 ", + "ko": " 릴리즈 페이지로 이동 ", + "ru": " Перейти на страницу выпуска ", + "en": " Go to release page ", + "fr": " Aller à la page de publication " + }, + "2l6izt7": { + "zh-cn": "正在下载...", + "ja": "ダウンロード中...", + "ko": "다운로드 중...", + "ru": "Загрузка...", + "en": "Downloading...", + "fr": "Téléchargement en cours..." + }, + "fj8br3": { + "zh-cn": "最小化", + "ja": "最小化", + "ko": "최소화", + "ru": "Свернуть", + "en": "Minimize", + "fr": "Minimiser" + }, + "l0k0ei8": { + "zh-cn": "当前已为最新版本", + "ja": "現在最新バージョンです", + "ko": "현재 최신 버전입니다", + "ru": "В настоящее время установлена последняя версия", + "en": "Currently the latest version", + "fr": "Version actuelle est la plus récente" + }, + "f7u97p8": { + "zh-cn": "检查更新失败: ", + "ja": "更新の確認に失敗しました: ", + "ko": "업데이트 확인에 실패했습니다: ", + "ru": "Не удалось проверить обновления: ", + "en": "Failed to check for updates: ", + "fr": "Échec de la vérification des mises à jour: " + }, + "lxz2q6h": { + "zh-cn": "milkup 是完全免费开源的软件", + "ja": "milkup は完全に無料のオープンソースソフトウェアです", + "ko": "milkup은 완전 무료 오픈소스 소프트웨어입니다", + "ru": "milkup — это полностью бесплатное программное обеспечение с открытым исходным кодом", + "en": "milkup is completely free and open-source software", + "fr": "milkup est un logiciel entièrement gratuit et open source" + }, + "qy6ip4b": { + "zh-cn": "配置编辑器其他外观设置", + "ja": "エディタのその他の外観設定を構成", + "ko": "에디터 기타 외관 설정 구성", + "ru": "Настроить другие параметры внешнего вида редактора", + "en": "Configure other appearance settings of editor", + "fr": "Configurer d'autres paramètres d'apparence de l'éditeur" + }, + "wzudk98": { + "zh-cn": "双击修改 URL", + "ja": "ダブルクリックでURLを編集", + "ko": "더블 클릭하여 URL 수정", + "ru": "Дважды щелкните, чтобы изменить URL", + "en": "Double-click to edit URL", + "fr": "Double-cliquez pour modifier l'URL" + }, + "v66gef9": { + "zh-cn": "修改链接 URL:", + "ja": "リンクURLを編集:", + "ko": "링크 URL 수정:", + "ru": "Изменить URL ссылки:", + "en": "Edit link URL:", + "fr": "Modifier l'URL du lien :" + }, + "a6fdb94": { + "zh-cn": "上传文件", + "ja": "ファイルをアップロード", + "ko": "파일 업로드", + "ru": "Загрузить файл", + "en": "Upload file", + "fr": "Télécharger un fichier" + }, + "l9122": { + "zh-cn": "确认", + "ja": "確認", + "ko": "확인", + "ru": "Подтвердить", + "en": "Confirm", + "fr": "Confirmer" + }, + "ieee177": { + "zh-cn": "粘贴链接...", + "ja": "リンクを貼り付け...", + "ko": "링크 붙여넣기...", + "ru": "Вставить ссылку...", + "en": "Paste link...", + "fr": "Coller le lien..." + }, + "ikzkgf4": { + "zh-cn": "选择文件", + "ja": "ファイルを選択", + "ko": "파일 선택", + "ru": "Выбрать файл", + "en": "Select file", + "fr": "Sélectionner un fichier" + }, + "bsy8dm5": { + "zh-cn": "未命名文档", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "nbek3z7": { - "zh-cn": "编辑器字体大小", + "i7ej2": { + "zh-cn": "未知", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "b372gwa": { - "zh-cn": "文本编辑器的字体大小", + "m16cho4w": { + "zh-cn": "你是一个技术文档续写助手。\n严格只输出以下 JSON,**不要有任何前缀、后缀、markdown、换行、解释**:\n\n{\"continuation\": \"接下来只写3–35个汉字的自然衔接内容\"}\n\n另外,如果 API 支持 structured outputs / json_schema / response_format 则在 API 层级限制。", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "rihv6q6": { - "zh-cn": "代码字体大小", + "tq3vltb": { + "zh-cn": "2-20 个汉字的续写", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "qwmffy9": { - "zh-cn": "代码显示的字体大小", + "ogrq8qa": { + "zh-cn": "上下文:\n文章标题:", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "z8ochc9": { - "zh-cn": "一级标题的字体大小", + "buavkc5": { + "zh-cn": "\n大标题:", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "tao6g49": { - "zh-cn": "二级标题的字体大小", + "ud32b87": { + "zh-cn": "\n本小节标题:", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "k58bon9": { - "zh-cn": "三级标题的字体大小", + "z351ktd": { + "zh-cn": "\n前面内容(请紧密衔接):", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "y4mch79": { - "zh-cn": "四级标题的字体大小", + "b11tpw7": { + "zh-cn": "AI 续写设置", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "fovv509": { - "zh-cn": "五级标题的字体大小", + "78ytez5": { + "zh-cn": "连接成功!", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "ux0q4d9": { - "zh-cn": "六级标题的字体大小", + "ikbexi4": { + "zh-cn": "连接成功", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "bv1d1o4": { - "zh-cn": "字体设置", + "q9hql0a": { + "zh-cn": "连接失败,请检查配置", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "9obzlmf": { - "zh-cn": " 配置编辑器和代码的字体样式 ", + "ika8634": { + "zh-cn": "连接失败", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "rqjnhg6": { - "zh-cn": " 字体选择 ", + "9u4wy46": { + "zh-cn": "连接出错: ", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "p1j42": { - "zh-cn": "选择", + "jdhqxo4": { + "zh-cn": "错误: ", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "bvptyo4": { - "zh-cn": "字号设置", + "n63u0z8": { + "zh-cn": "启用 AI 续写", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "yzdvief": { - "zh-cn": " 配置不同文本元素的字体大小 ", + "jkvovdg": { + "zh-cn": "服务提供商 (Provider)", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "lzsxbc6": { - "zh-cn": " 正文内容 ", + "vwo89ea": { + "zh-cn": "模型 (Model)", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "ilv7mg6": { - "zh-cn": " 一级标题 ", + "mennovb": { + "zh-cn": "正在加载模型列表...", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "ggw0jw6": { - "zh-cn": " 二级标题 ", + "a1m9965": { + "zh-cn": "未找到模型", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "igx2a76": { - "zh-cn": " 三级标题 ", + "61g4yc6": { + "zh-cn": "刷新模型列表", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "g0mdj76": { - "zh-cn": " 四级标题 ", + "o9xxdrj": { + "zh-cn": "随机性 (Temperature): ", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "gchnt06": { - "zh-cn": " 五级标题 ", + "nxfz0r6": { + "zh-cn": "测试中...", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "57nn8r6": { - "zh-cn": " 六级标题 ", + "edfmk14": { + "zh-cn": "测试连接", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "rih3a46": { - "zh-cn": " 字体大小 ", + "fy1g2": { + "zh-cn": "失败", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "w5t755": { - "zh-cn": " [只读]", + "q4mu2": { + "zh-cn": "错误", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "rp05676": { - "zh-cn": " [只读] ", + "u4t3ke8": { + "zh-cn": "获取模型列表失败", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "cjk8jz5": { - "zh-cn": "[只读] ", + "cf4xcp5": { + "zh-cn": "服务提供商", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "z3sj0e6": { - "zh-cn": "保存文件失败", + "f91vmhh": { + "zh-cn": "随机性 (Temperature)", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "9rm15ze": { - "zh-cn": "保存文件失败,请检查写入权限", + "9ofd4sf": { + "zh-cn": "触发延迟 (Debounce)", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "ccpga9z": { - "zh-cn": "文件已被外部修改,但您有未保存的更改。请先保存或丢弃更改后再重新加载。", + "mer6ks6": { + "zh-cn": "快 (1s)", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "d4k9sh4": { - "zh-cn": "文件 \"", + "n8m1it7": { + "zh-cn": "适中 (2s)", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "t04it76": { - "zh-cn": "\" 已被删除", + "d2x0tb6": { + "zh-cn": "慢 (3s)", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "uje4506": { - "zh-cn": "监听文件 \"", + "fx6eua2r": { + "zh-cn": "你是一个技术文档续写助手。\n严格只输出以下 JSON,**不要有任何前缀、后缀、markdown、换行、解释**:\n\n{\"continuation\": \"接下来只写3–35个汉字的自然衔接内容\"}\n", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "egxfh7": { - "zh-cn": "\" 时出错: ", + "a0oapib": { + "zh-cn": "3–35 个汉字的续写", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "diprg14": { - "zh-cn": "未知错误", + "cvzdn9p": { + "zh-cn": "图片将自动转为 base64(可能会增大文件体积)", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "6lbcrh7": { - "zh-cn": "文件已重新加载", + "68tpr19": { + "zh-cn": " 全部保存并重启 ", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "r2ntmg7": { - "zh-cn": " 文件已更改 ", + "5ievmo6": { + "zh-cn": "稍后手动重启", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "i8xfh8c": { - "zh-cn": "\" 已被其他程序修改。 ", + "cbo7lp7": { + "zh-cn": "全部保存并重启", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "cbnuzvd": { - "zh-cn": " 文件已被其他程序修改。 ", + "9kagif1g": { + "zh-cn": "
Mermaid 渲染错误
", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "ygx5m7r": { - "zh-cn": " 是否要重新加载文件内容?当前未保存的更改将会丢失。 ", + "dcev9f9": { + "zh-cn": "输入数学公式...", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "u5udsw6": { - "zh-cn": " 重新加载 ", + "dksrvi15": { + "zh-cn": "图片加载失败: ", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "qzrwgd7": { - "zh-cn": " 文件已变动 ", + "m2x6wu7": { + "zh-cn": "请输入图片地址", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "umfap0k": { - "zh-cn": "\" 已经变动,是否覆盖当前编辑的内容? ", + "3dlqhm4v": { + "zh-cn": "\n 🖼️\n 图片加载失败\n ", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "fdal1pl": { - "zh-cn": " 文件已经变动,是否覆盖当前编辑的内容? ", + "hxe78ca": { + "zh-cn": "请输入完整的图片语法", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "aroz724": { - "zh-cn": "切换语言", + "e2ri2": { + "zh-cn": "代码", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "pmyml5i": { - "zh-cn": "切换语言需要重启应用,是否现在重启?", + "j5yp2": { + "zh-cn": "混合", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "p9fm2": { - "zh-cn": "重启", + "egv12": { + "zh-cn": "剪切", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "ev022": { - "zh-cn": "取消", + "lyu42": { + "zh-cn": "粘贴", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "g93bpeb": { - "zh-cn": "请确保所有工作已经保存", + "gsl8t46": { + "zh-cn": "取消下载失败", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "rq3kv48": { - "zh-cn": " 稍后手动重启 ", + "villim5": { + "zh-cn": "下载已取消", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "i4rsca6": { - "zh-cn": " 现在重启 ", + "8wksrz8": { + "zh-cn": "写点什么吧...", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "830mn1d": { - "zh-cn": "请确保所有工作已经保存! ", + "lml82": { + "zh-cn": "粗体", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "6m2p0dc": { - "zh-cn": "请确保所有工作已经保存!", + "hpvb2": { + "zh-cn": "斜体", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "5mi5h2e": { - "zh-cn": " 更新语言设置需要重启应用 ", + "hj7bp34": { + "zh-cn": "行内代码", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "onktj1e": { - "zh-cn": " 请确保所有工作已经保存! ", + "crbi33": { + "zh-cn": "删除线", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "kywilbf": { - "zh-cn": "更新语言设置需要重启应用后生效", + "qrpy2": { + "zh-cn": "高亮", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "eae82": { - "zh-cn": "其他", + "j1ns2": { + "zh-cn": "段落", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "5d6g4k6": { - "zh-cn": "编辑器内边距", + "b15xfh4": { + "zh-cn": "取消嵌套", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "7xnwd2f": { - "zh-cn": " 配置编辑器内容区域的内边距 ", + "d6dimx4": { + "zh-cn": "数学公式", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "j5j5y58": { - "zh-cn": " 预览内容区域 ", + "eg9hk94": { + "zh-cn": "源码视图", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "7puh5l7": { - "zh-cn": " 内边距设置 ", + "hxp82": { + "zh-cn": "撤销", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "xm43kg11": { - "zh-cn": " 设置编辑器内容区域的内边距,支持 CSS 单位(如 px、rem、%) ", + "p8od2": { + "zh-cn": "重做", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "cn2q13": { - "zh-cn": "内边距", + "atgagy4": { + "zh-cn": "内联格式", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "dfykg38": { - "zh-cn": "例如: 20px", + "bjmutf4": { + "zh-cn": "块级格式", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "rm0wns6": { - "zh-cn": " 当前值: ", + "hfbn2": { + "zh-cn": "插入", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "ok35ur7": { - "zh-cn": "编辑器其他设置", + "i5qyrg5": { + "zh-cn": "编辑器操作", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "qiczjwb": { - "zh-cn": " 配置编辑器其他设置 ", + "ejide3": { + "zh-cn": "快捷键", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "btd1wc4": { - "zh-cn": "外观设置", + "hmmkw79": { + "zh-cn": "搜索功能名称...", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "oauiif9": { - "zh-cn": "编辑器其他外观设置", + "3rhvpka": { + "zh-cn": "按下快捷键搜索...", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "u5f2sod": { - "zh-cn": " 配置编辑器其他外观设置 ", + "k9szc78": { + "zh-cn": "与以下功能冲突:", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "2j8e0g5": { - "zh-cn": "请输入数字", + "412ezha": { + "zh-cn": "请按下新快捷键...", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "rt9pmq7": { - "zh-cn": "内边距(PX)", + "2q1ayx6": { + "zh-cn": "重置为默认值", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "3o81my8": { - "zh-cn": "左右边距(PX)", + "kkvm9k9": { + "zh-cn": " 重置所有快捷键 ", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "7hktj26": { - "zh-cn": "渲染中...", + "dcc4lk7": { + "zh-cn": "重置所有快捷键", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "glbv83": { - "zh-cn": "流程图", + "thqafk5": { + "zh-cn": "主题编辑器", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "fjmy2": { - "zh-cn": "图表", + "tdufl9a": { + "zh-cn": "设置主题的名称和描述", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "fe75x3": { - "zh-cn": "时序图", + "3lnpsx8": { + "zh-cn": "快速应用预设主题", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "aftmxy4": { - "zh-cn": "下载失败", + "grhnucb": { + "zh-cn": "配置应用界面的颜色主题", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "utnnr56": { - "zh-cn": "更新出错: ", + "9am2d6i": { + "zh-cn": "配置Markdown编辑器的颜色主题", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "aftsuu4": { - "zh-cn": "下载完成", + "phz52": { + "zh-cn": "重置", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "97iatz8": { - "zh-cn": "正在下载... ", + "agefiw4": { + "zh-cn": "保存主题", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "90d1z06": { - "zh-cn": " 立即更新 ", + "9mw03zc": { + "zh-cn": "Mermaid 图表设置", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "v1i5cj8": { - "zh-cn": " 下载中... ", + "v68bp3l": { + "zh-cn": "配置 Mermaid 代码块的默认显示模式", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "rzmztu6": { - "zh-cn": " 重启安装 ", + "4vzyp66": { + "zh-cn": "默认显示模式", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "d3b5r78": { - "zh-cn": "点击恢复下载弹窗", + "oflg3": { + "zh-cn": " 表格", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "50uczt5": { - "zh-cn": "正在下载 ", + "g72m745": { + "zh-cn": "向上插入行", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "5f4uk49": { - "zh-cn": "下载完成,点击安装", + "g71z7l5": { + "zh-cn": "向下插入行", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "vz4lz7f": { - "zh-cn": "milkup 新版本现已发布!", + "u5xz106": { + "zh-cn": "在末尾添加行", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "aik7f46": { - "zh-cn": "前往发布页 ", + "e72i5t5": { + "zh-cn": "向左插入列", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "2l6izt7": { - "zh-cn": "正在下载...", + "fg8tr25": { + "zh-cn": "向右插入列", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "fj8br3": { - "zh-cn": "最小化", + "u5y9qh6": { + "zh-cn": "在末尾添加列", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "l0k0ei8": { - "zh-cn": "当前已为最新版本", + "eoh5zm5": { + "zh-cn": "删除当前行", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "f7u97p8": { - "zh-cn": "检查更新失败: ", + "eohgp35": { + "zh-cn": "删除当前列", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "lxz2q6h": { - "zh-cn": "milkup 是完全免费开源的软件", + "cxttk74": { + "zh-cn": "插入表格", "ja": "", "ko": "", "ru": "", "en": "", "fr": "" }, - "qy6ip4b": { - "zh-cn": "配置编辑器其他外观设置", + "3qkpww5": { + "zh-cn": "复制代码块", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "arihfi4": { + "zh-cn": "切换替换", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "hpqe2": { + "zh-cn": "搜索", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "4mfs835": { + "zh-cn": "正则表达式", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "fjbpl3": { + "zh-cn": "无结果", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "sn9tbej": { + "zh-cn": "上一个匹配 (Shift+Enter)", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "l755pad": { + "zh-cn": "下一个匹配 (Enter)", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "doreq66": { + "zh-cn": "在选区内搜索", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "2u0y62b": { + "zh-cn": "关闭 (Escape)", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "avd03n4": { + "zh-cn": "全部替换", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "ov8y2": { + "zh-cn": "输入", + "ja": "", + "ko": "", + "ru": "", + "en": "", + "fr": "" + }, + "cuovnz5": { + "zh-cn": "+左击访问", "ja": "", "ko": "", "ru": "", diff --git a/package.json b/package.json index aed3892..ddace75 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "milkup", - "version": "0.4.12", - "description": "A Markdown editor built with Milkdown and Vue.js", + "version": "0.6.0-milkupcore", + "description": "A Markdown editor built with Milkup core and Vue.js", "keywords": [ "Markdown", "markdown", @@ -38,27 +38,18 @@ "@codemirror/autocomplete": "^6.18.7", "@codemirror/commands": "^6.10.0", "@codemirror/comment": "^0.19.1", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-json": "^6.0.2", "@codemirror/lang-markdown": "^6.3.4", + "@codemirror/lang-python": "^6.2.1", "@codemirror/lang-vue": "^0.1.3", "@codemirror/language": "^6.12.1", "@codemirror/lsp-client": "^6.1.2", "@codemirror/state": "^6.5.2", "@codemirror/view": "^6.38.1", - "@milkdown/core": "^7.17.1", - "@milkdown/crepe": "^7.17.1", - "@milkdown/exception": "^7.17.1", - "@milkdown/kit": "^7.17.1", - "@milkdown/plugin-automd": "^7.17.1", - "@milkdown/plugin-diagram": "^7.7.0", - "@milkdown/plugin-listener": "^7.17.1", - "@milkdown/plugin-prism": "^7.17.1", - "@milkdown/preset-commonmark": "^7.17.1", - "@milkdown/preset-gfm": "^7.17.1", - "@milkdown/prose": "^7.17.1", - "@milkdown/theme-nord": "^7.17.1", - "@milkdown/transformer": "^7.17.1", - "@milkdown/utils": "^7.17.1", - "@milkdown/vue": "^7.17.1", + "@lezer/highlight": "^1.2.3", "@vue/runtime-dom": "^3.5.19", "@vueuse/core": "^14.0.0", "autodialog.js": "^0.0.9", @@ -70,11 +61,24 @@ "electron-updater": "^6.7.3", "font-list": "^1.5.1", "he": "^1.2.0", + "katex": "^0.16.28", "mdast": "^3.0.0", "mermaid": "^11.10.0", "mitt": "^3.0.1", "nanoid": "^5.1.5", + "prosemirror-commands": "^1.7.1", + "prosemirror-dropcursor": "^1.8.2", + "prosemirror-gapcursor": "^1.4.0", + "prosemirror-history": "^1.5.0", + "prosemirror-inputrules": "^1.5.1", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-schema-list": "^1.5.1", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.11.0", + "prosemirror-view": "^1.41.5", "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", "remark-directive": "^4.0.0", "remark-flexible-containers": "^1.5.0", "remark-html": "^16.0.1", @@ -92,6 +96,7 @@ }, "devDependencies": { "@types/he": "^1.2.3", + "@types/katex": "^0.16.8", "@types/mdast": "^4.0.4", "@types/node": "^24.3.0", "@types/unist": "^3.0.3", @@ -139,10 +144,7 @@ "electron-winstaller", "esbuild", "simple-git-hooks" - ], - "patchedDependencies": { - "@milkdown/crepe": "patches/@milkdown__crepe.patch" - } + ] }, "build": { "appId": "com.auto-plugin.milkup", diff --git a/patches/@milkdown__crepe.patch b/patches/@milkdown__crepe.patch deleted file mode 100644 index aff9f7e..0000000 --- a/patches/@milkdown__crepe.patch +++ /dev/null @@ -1,151 +0,0 @@ -diff --git a/lib/esm/index.js b/lib/esm/index.js -index 8690d488a1e176033b252a8e734fa2718e667868..e48b4579dabf8b3673c35010ed1bb62aa512672c 100644 ---- a/lib/esm/index.js -+++ b/lib/esm/index.js -@@ -823,6 +823,9 @@ _getGroupInstance = new WeakMap(); - - function getGroups$1(filter, config, ctx) { - var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z, _A, _B, _C, _D, _E, _F, _G, _H, _I, _J, _K, _L, _M, _N, _O, _P, _Q, _R, _S, _T, _U, _V, _W, _X, _Y, _Z, __, _$, _aa, _ba, _ca, _da, _ea, _fa, _ga, _ha, _ia, _ja, _ka, _la, _ma, _na, _oa, _pa, _qa, _ra, _sa, _ta, _ua, _va, _wa, _xa, _ya, _za, _Aa, _Ba, _Ca, _Da, _Ea, _Fa, _Ga, _Ha, _Ia, _Ja, _Ka, _La, _Ma, _Na, _Oa, _Pa, _Qa, _Ra, _Sa, _Ta, _Ua, _Va, _Wa, _Xa, _Ya, _Za, __a, _$a, _ab, _bb, _cb, _db, _eb, _fb, _gb, _hb, _ib, _jb, _kb; -+ var _tt1, _tt2, _tt3, _h11, _h12, _h13, _h21, _h22, _h23, _h31, _h32, _h33, _h41, _h42, _h43, _h51, _h52, _h53, _h61, _h62, _h63, _qu1, _qu2, _qu3, _d1, _d2, _d3; -+ var _bl1, _bl2, _bl3, _ol1, _ol2, _ol3, _tl1, _tl2, _tl3; -+ var _i1, _i2, _i3, _c1, _c2, _c3, _ta1, _ta2, _ta3, _m1, _m2, _m3; - const flags = ctx && useCrepeFeatures(ctx).get(); - const isLatexEnabled = flags == null ? void 0 : flags.includes(CrepeFeature.Latex); - const isImageBlockEnabled = flags == null ? void 0 : flags.includes(CrepeFeature.ImageBlock); -@@ -837,6 +840,7 @@ function getGroups$1(filter, config, ctx) { - textGroup.addItem("text", { - label: (_f = (_e = (_d = config == null ? void 0 : config.textGroup) == null ? void 0 : _d.text) == null ? void 0 : _e.label) != null ? _f : "Text", - icon: (_i = (_h = (_g = config == null ? void 0 : config.textGroup) == null ? void 0 : _g.text) == null ? void 0 : _h.icon) != null ? _i : textIcon, -+ abbr: (_tt3 = (_tt2 = (_tt1 = config == null ? void 0 : config.textGroup) == null ? void 0 : _tt1.text) == null ? void 0 : _tt2.abbr) != null ? _tt3 : [], - onRun: (ctx2) => { - const commands = ctx2.get(commandsCtx); - const paragraph = paragraphSchema.type(ctx2); -@@ -851,6 +855,7 @@ function getGroups$1(filter, config, ctx) { - textGroup.addItem("h1", { - label: (_m = (_l = (_k = config == null ? void 0 : config.textGroup) == null ? void 0 : _k.h1) == null ? void 0 : _l.label) != null ? _m : "Heading 1", - icon: (_p = (_o = (_n = config == null ? void 0 : config.textGroup) == null ? void 0 : _n.h1) == null ? void 0 : _o.icon) != null ? _p : h1Icon, -+ abbr: (_h13 = (_h12 = (_h11 = config == null ? void 0 : config.textGroup) == null ? void 0 : _h11.h1) == null ? void 0 : _h12.abbr) != null ? _h13 : [], - onRun: (ctx2) => { - const commands = ctx2.get(commandsCtx); - const heading = headingSchema.type(ctx2); -@@ -868,6 +873,7 @@ function getGroups$1(filter, config, ctx) { - textGroup.addItem("h2", { - label: (_t = (_s = (_r = config == null ? void 0 : config.textGroup) == null ? void 0 : _r.h2) == null ? void 0 : _s.label) != null ? _t : "Heading 2", - icon: (_w = (_v = (_u = config == null ? void 0 : config.textGroup) == null ? void 0 : _u.h2) == null ? void 0 : _v.icon) != null ? _w : h2Icon, -+ abbr: (_h23 = (_h22 = (_h21 = config == null ? void 0 : config.textGroup) == null ? void 0 : _h21.h2) == null ? void 0 : _h22.abbr) != null ? _h23 : [], - onRun: (ctx2) => { - const commands = ctx2.get(commandsCtx); - const heading = headingSchema.type(ctx2); -@@ -885,6 +891,7 @@ function getGroups$1(filter, config, ctx) { - textGroup.addItem("h3", { - label: (_A = (_z = (_y = config == null ? void 0 : config.textGroup) == null ? void 0 : _y.h3) == null ? void 0 : _z.label) != null ? _A : "Heading 3", - icon: (_D = (_C = (_B = config == null ? void 0 : config.textGroup) == null ? void 0 : _B.h3) == null ? void 0 : _C.icon) != null ? _D : h3Icon, -+ abbr: (_h33 = (_h32 = (_h31 = config == null ? void 0 : config.textGroup) == null ? void 0 : _h31.h3) == null ? void 0 : _h32.abbr) != null ? _h33 : [], - onRun: (ctx2) => { - const commands = ctx2.get(commandsCtx); - const heading = headingSchema.type(ctx2); -@@ -902,6 +909,7 @@ function getGroups$1(filter, config, ctx) { - textGroup.addItem("h4", { - label: (_H = (_G = (_F = config == null ? void 0 : config.textGroup) == null ? void 0 : _F.h4) == null ? void 0 : _G.label) != null ? _H : "Heading 4", - icon: (_K = (_J = (_I = config == null ? void 0 : config.textGroup) == null ? void 0 : _I.h4) == null ? void 0 : _J.icon) != null ? _K : h4Icon, -+ abbr: (_h43 = (_h42 = (_h41 = config == null ? void 0 : config.textGroup) == null ? void 0 : _h41.h4) == null ? void 0 : _h42.abbr) != null ? _h43 : [], - onRun: (ctx2) => { - const commands = ctx2.get(commandsCtx); - const heading = headingSchema.type(ctx2); -@@ -919,6 +927,7 @@ function getGroups$1(filter, config, ctx) { - textGroup.addItem("h5", { - label: (_O = (_N = (_M = config == null ? void 0 : config.textGroup) == null ? void 0 : _M.h5) == null ? void 0 : _N.label) != null ? _O : "Heading 5", - icon: (_R = (_Q = (_P = config == null ? void 0 : config.textGroup) == null ? void 0 : _P.h5) == null ? void 0 : _Q.icon) != null ? _R : h5Icon, -+ abbr: (_h53 = (_h52 = (_h51 = config == null ? void 0 : config.textGroup) == null ? void 0 : _h51.h5) == null ? void 0 : _h52.abbr) != null ? _h53 : [], - onRun: (ctx2) => { - const commands = ctx2.get(commandsCtx); - const heading = headingSchema.type(ctx2); -@@ -936,6 +945,7 @@ function getGroups$1(filter, config, ctx) { - textGroup.addItem("h6", { - label: (_V = (_U = (_T = config == null ? void 0 : config.textGroup) == null ? void 0 : _T.h6) == null ? void 0 : _U.label) != null ? _V : "Heading 6", - icon: (_Y = (_X = (_W = config == null ? void 0 : config.textGroup) == null ? void 0 : _W.h6) == null ? void 0 : _X.icon) != null ? _Y : h6Icon, -+ abbr: (_h63 = (_h62 = (_h61 = config == null ? void 0 : config.textGroup) == null ? void 0 : _h61.h6) == null ? void 0 : _h62.abbr) != null ? _h63 : [], - onRun: (ctx2) => { - const commands = ctx2.get(commandsCtx); - const heading = headingSchema.type(ctx2); -@@ -953,6 +963,7 @@ function getGroups$1(filter, config, ctx) { - textGroup.addItem("quote", { - label: (_aa = (_$ = (__ = config == null ? void 0 : config.textGroup) == null ? void 0 : __.quote) == null ? void 0 : _$.label) != null ? _aa : "Quote", - icon: (_da = (_ca = (_ba = config == null ? void 0 : config.textGroup) == null ? void 0 : _ba.quote) == null ? void 0 : _ca.icon) != null ? _da : quoteIcon, -+ abbr: (_qu3 = (_qu2 = (_qu1 = config == null ? void 0 : config.textGroup) == null ? void 0 : _qu1.quote) == null ? void 0 : _qu2.abbr) != null ? _qu3 : [], - onRun: (ctx2) => { - const commands = ctx2.get(commandsCtx); - const blockquote = blockquoteSchema.type(ctx2); -@@ -967,6 +978,7 @@ function getGroups$1(filter, config, ctx) { - textGroup.addItem("divider", { - label: (_ha = (_ga = (_fa = config == null ? void 0 : config.textGroup) == null ? void 0 : _fa.divider) == null ? void 0 : _ga.label) != null ? _ha : "Divider", - icon: (_ka = (_ja = (_ia = config == null ? void 0 : config.textGroup) == null ? void 0 : _ia.divider) == null ? void 0 : _ja.icon) != null ? _ka : dividerIcon, -+ abbr: (_d3 = (_d2 = (_d1 = config == null ? void 0 : config.textGroup) == null ? void 0 : _d1.divider) == null ? void 0 : _d2.abbr) != null ? _d3 : [], - onRun: (ctx2) => { - const commands = ctx2.get(commandsCtx); - const hr = hrSchema.type(ctx2); -@@ -987,6 +999,7 @@ function getGroups$1(filter, config, ctx) { - listGroup.addItem("bullet-list", { - label: (_qa = (_pa = (_oa = config == null ? void 0 : config.listGroup) == null ? void 0 : _oa.bulletList) == null ? void 0 : _pa.label) != null ? _qa : "Bullet List", - icon: (_ta = (_sa = (_ra = config == null ? void 0 : config.listGroup) == null ? void 0 : _ra.bulletList) == null ? void 0 : _sa.icon) != null ? _ta : bulletListIcon, -+ abbr: (_bl3 = (_bl2 = (_bl1 = config == null ? void 0 : config.listGroup) == null ? void 0 : _bl1.bulletList) == null ? void 0 : _bl2.abbr) != null ? _bl3 : [], - onRun: (ctx2) => { - const commands = ctx2.get(commandsCtx); - const bulletList = bulletListSchema.type(ctx2); -@@ -1001,6 +1014,7 @@ function getGroups$1(filter, config, ctx) { - listGroup.addItem("ordered-list", { - label: (_xa = (_wa = (_va = config == null ? void 0 : config.listGroup) == null ? void 0 : _va.orderedList) == null ? void 0 : _wa.label) != null ? _xa : "Ordered List", - icon: (_Aa = (_za = (_ya = config == null ? void 0 : config.listGroup) == null ? void 0 : _ya.orderedList) == null ? void 0 : _za.icon) != null ? _Aa : orderedListIcon, -+ abbr: (_ol3 = (_ol2 = (_ol1 = config == null ? void 0 : config.listGroup) == null ? void 0 : _ol1.orderedList) == null ? void 0 : _ol2.abbr) != null ? _ol3 : [], - onRun: (ctx2) => { - const commands = ctx2.get(commandsCtx); - const orderedList = orderedListSchema.type(ctx2); -@@ -1015,6 +1029,7 @@ function getGroups$1(filter, config, ctx) { - listGroup.addItem("task-list", { - label: (_Ea = (_Da = (_Ca = config == null ? void 0 : config.listGroup) == null ? void 0 : _Ca.taskList) == null ? void 0 : _Da.label) != null ? _Ea : "Task List", - icon: (_Ha = (_Ga = (_Fa = config == null ? void 0 : config.listGroup) == null ? void 0 : _Fa.taskList) == null ? void 0 : _Ga.icon) != null ? _Ha : todoListIcon, -+ abbr: (_tl3 = (_tl2 = (_tl1 = config == null ? void 0 : config.listGroup) == null ? void 0 : _tl1.taskList) == null ? void 0 : _tl2.abbr) != null ? _tl3 : [], - onRun: (ctx2) => { - const commands = ctx2.get(commandsCtx); - const listItem = listItemSchema.type(ctx2); -@@ -1036,6 +1051,7 @@ function getGroups$1(filter, config, ctx) { - advancedGroup.addItem("image", { - label: (_Na = (_Ma = (_La = config == null ? void 0 : config.advancedGroup) == null ? void 0 : _La.image) == null ? void 0 : _Ma.label) != null ? _Na : "Image", - icon: (_Qa = (_Pa = (_Oa = config == null ? void 0 : config.advancedGroup) == null ? void 0 : _Oa.image) == null ? void 0 : _Pa.icon) != null ? _Qa : imageIcon, -+ abbr: (_i3 = (_i2 = (_i1 = config == null ? void 0 : config.advancedGroup) == null ? void 0 : _i1.image) == null ? void 0 : _i2.abbr) != null ? _i3 : [], - onRun: (ctx2) => { - const commands = ctx2.get(commandsCtx); - const imageBlock = imageBlockSchema.type(ctx2); -@@ -1050,6 +1066,7 @@ function getGroups$1(filter, config, ctx) { - advancedGroup.addItem("code", { - label: (_Ua = (_Ta = (_Sa = config == null ? void 0 : config.advancedGroup) == null ? void 0 : _Sa.codeBlock) == null ? void 0 : _Ta.label) != null ? _Ua : "Code", - icon: (_Xa = (_Wa = (_Va = config == null ? void 0 : config.advancedGroup) == null ? void 0 : _Va.codeBlock) == null ? void 0 : _Wa.icon) != null ? _Xa : codeIcon, -+ abbr: (_c3 = (_c2 = (_c1 = config == null ? void 0 : config.advancedGroup) == null ? void 0 : _c1.codeBlock) == null ? void 0 : _c2.abbr) != null ? _c3 : [], - onRun: (ctx2) => { - const commands = ctx2.get(commandsCtx); - const codeBlock = codeBlockSchema.type(ctx2); -@@ -1064,6 +1081,7 @@ function getGroups$1(filter, config, ctx) { - advancedGroup.addItem("table", { - label: (_$a = (__a = (_Za = config == null ? void 0 : config.advancedGroup) == null ? void 0 : _Za.table) == null ? void 0 : __a.label) != null ? _$a : "Table", - icon: (_cb = (_bb = (_ab = config == null ? void 0 : config.advancedGroup) == null ? void 0 : _ab.table) == null ? void 0 : _bb.icon) != null ? _cb : tableIcon, -+ abbr: (_ta3 = (_ta2 = (_ta1 = config == null ? void 0 : config.advancedGroup) == null ? void 0 : _ta1.table) == null ? void 0 : _ta2.abbr) != null ? _ta3 : [], - onRun: (ctx2) => { - const commands = ctx2.get(commandsCtx); - const view = ctx2.get(editorViewCtx); -@@ -1082,6 +1100,7 @@ function getGroups$1(filter, config, ctx) { - advancedGroup.addItem("math", { - label: (_gb = (_fb = (_eb = config == null ? void 0 : config.advancedGroup) == null ? void 0 : _eb.math) == null ? void 0 : _fb.label) != null ? _gb : "Math", - icon: (_jb = (_ib = (_hb = config == null ? void 0 : config.advancedGroup) == null ? void 0 : _hb.math) == null ? void 0 : _ib.icon) != null ? _jb : functionsIcon, -+ abbr: (_m3 = (_m2 = (_m1 = config == null ? void 0 : config.advancedGroup) == null ? void 0 : _m1.math) == null ? void 0 : _m2.abbr) != null ? _m3 : [], - onRun: (ctx2) => { - const commands = ctx2.get(commandsCtx); - const codeBlock = codeBlockSchema.type(ctx2); -@@ -1099,7 +1118,7 @@ function getGroups$1(filter, config, ctx) { - if (filter) { - groups = groups.map((group) => { - const items2 = group.items.filter( -- (item) => item.label.toLowerCase().includes(filter.toLowerCase()) -+ (item) => item.label.toLowerCase().includes(filter.toLowerCase()) || (Array.isArray(item.abbr) && item.abbr.some((abbr) => abbr.toLowerCase().includes(filter.toLowerCase()))) - ); - return { - ...group, diff --git a/src/core/commands/enhanced-commands.ts b/src/core/commands/enhanced-commands.ts new file mode 100644 index 0000000..7fbe8a8 --- /dev/null +++ b/src/core/commands/enhanced-commands.ts @@ -0,0 +1,247 @@ +/** + * 增强的编辑器命令 + * + * 通过插入 Markdown 语法文本实现格式化, + * 让 syntax-detector 和 heading-sync 等插件自动处理渲染。 + */ + +import { EditorState, Transaction, TextSelection } from "prosemirror-state"; +import { MarkType } from "prosemirror-model"; + +type Command = (state: EditorState, dispatch?: (tr: Transaction) => void) => boolean; + +/** Mark 类型对应的 Markdown 语法标记 */ +const MARK_SYNTAX: Record = { + strong: { prefix: "**", suffix: "**" }, + emphasis: { prefix: "*", suffix: "*" }, + code_inline: { prefix: "`", suffix: "`" }, + strikethrough: { prefix: "~~", suffix: "~~" }, + highlight: { prefix: "==", suffix: "==" }, +}; + +/** + * 创建增强的 toggleMark 命令 + * + * 始终通过插入 Markdown 语法文本实现,让 syntax-detector 自动处理渲染。 + * - 有选区:在选区两端插入语法标记 + * - 无选区:插入语法标记对,光标定位到中间 + */ +export function createEnhancedToggleMark(markType: MarkType): Command { + const syntax = MARK_SYNTAX[markType.name]; + + return (state: EditorState, dispatch?: (tr: Transaction) => void) => { + if (!syntax) return false; + + const { from, to, empty } = state.selection; + + if (dispatch) { + if (empty) { + // 无选区:插入标记对,光标在中间 + const text = syntax.prefix + syntax.suffix; + const tr = state.tr.insertText(text, from); + tr.setSelection(TextSelection.create(tr.doc, from + syntax.prefix.length)); + dispatch(tr); + } else { + // 有选区:检查是否已有该语法,如果是则移除,否则添加 + const tr = state.tr; + const selectedText = state.doc.textBetween(from, to); + const prefixLen = syntax.prefix.length; + const suffixLen = syntax.suffix.length; + + // 情况1:选区外侧紧邻语法标记(如选中 a,两侧是 **a**) + const beforeFrom = Math.max(0, from - prefixLen); + const afterTo = Math.min(state.doc.content.size, to + suffixLen); + const textBefore = state.doc.textBetween(beforeFrom, from); + const textAfter = state.doc.textBetween(to, afterTo); + + if (textBefore === syntax.prefix && textAfter === syntax.suffix) { + // 移除外侧语法标记(先删后面的,再删前面的,避免位置偏移) + tr.delete(to, afterTo); + tr.delete(beforeFrom, from); + tr.setSelection( + TextSelection.create(tr.doc, beforeFrom, beforeFrom + selectedText.length) + ); + dispatch(tr); + return true; + } + + // 情况2:选区本身包含语法标记(如选中 **a**) + if ( + selectedText.length >= prefixLen + suffixLen && + selectedText.startsWith(syntax.prefix) && + selectedText.endsWith(syntax.suffix) + ) { + const inner = selectedText.slice(prefixLen, selectedText.length - suffixLen); + tr.insertText(inner, from, to); + tr.setSelection(TextSelection.create(tr.doc, from, from + inner.length)); + dispatch(tr); + return true; + } + + // 情况3:未包裹,在选区两端插入语法标记 + tr.insertText(syntax.suffix, to); + tr.insertText(syntax.prefix, from); + tr.setSelection(TextSelection.create(tr.doc, from + prefixLen, to + prefixLen)); + dispatch(tr); + } + } + return true; + }; +} + +/** + * 创建设置标题命令 + * + * 通过插入/修改 `# ` 语法标记文本实现标题切换。 + * - 段落 → 标题:设置节点类型并插入 `# ` 前缀 + * - 标题(同级)→ 段落:移除 `# ` 前缀并转为段落 + * - 标题(不同级)→ 标题:替换 `# ` 前缀 + */ +export function createSetHeadingCommand(level: number): Command { + return (state: EditorState, dispatch?: (tr: Transaction) => void) => { + const { $from } = state.selection; + const parent = $from.parent; + const parentPos = $from.before($from.depth); + const schema = state.schema; + + if (!schema.nodes.heading) return false; + + // 只处理段落和标题 + if (parent.type.name !== "paragraph" && parent.type.name !== "heading") { + return false; + } + + if (dispatch) { + const syntaxMarkerType = schema.marks.syntax_marker; + const hashStr = "#".repeat(level); + + if (parent.type.name === "heading") { + if (parent.attrs.level === level) { + // 同级标题 → 段落:移除 # 前缀和后面的空格 + let removeEnd = 0; + let syntaxEnd = 0; + parent.forEach((child, offset) => { + if ( + child.marks.some( + (m: any) => m.type.name === "syntax_marker" && m.attrs.syntaxType === "heading" + ) + ) { + syntaxEnd = offset + child.nodeSize; + removeEnd = syntaxEnd; + } + }); + // 检查语法标记后面是否紧跟一个空格 + if (syntaxEnd < parent.content.size) { + const nextText = parent.textBetween( + syntaxEnd, + Math.min(syntaxEnd + 1, parent.content.size) + ); + if (nextText === " ") { + removeEnd = syntaxEnd + 1; + } + } + + const tr = state.tr; + tr.setBlockType(parentPos, parentPos + parent.nodeSize, schema.nodes.paragraph); + if (removeEnd > 0) { + tr.delete(parentPos + 1, parentPos + 1 + removeEnd); + } + dispatch(tr.scrollIntoView()); + } else { + // 不同级标题:只替换 # 部分(保留空格) + let syntaxFrom = -1; + let syntaxTo = -1; + parent.forEach((child, offset) => { + if ( + syntaxFrom === -1 && + child.marks.some( + (m: any) => m.type.name === "syntax_marker" && m.attrs.syntaxType === "heading" + ) + ) { + syntaxFrom = offset; + syntaxTo = offset + child.nodeSize; + } + }); + + const tr = state.tr; + const syntaxMark = syntaxMarkerType?.create({ syntaxType: "heading" }); + const newSyntaxText = syntaxMark + ? schema.text(hashStr, [syntaxMark]) + : schema.text(hashStr); + + if (syntaxFrom >= 0) { + tr.replaceWith(parentPos + 1 + syntaxFrom, parentPos + 1 + syntaxTo, newSyntaxText); + } + tr.setNodeMarkup(parentPos, schema.nodes.heading, { level }); + dispatch(tr.scrollIntoView()); + } + } else { + // 段落 → 标题:插入 # 和空格(分开) + const tr = state.tr; + tr.setBlockType(parentPos, parentPos + parent.nodeSize, schema.nodes.heading, { level }); + const syntaxMark = syntaxMarkerType?.create({ syntaxType: "heading" }); + const syntaxText = syntaxMark ? schema.text(hashStr, [syntaxMark]) : schema.text(hashStr); + // 先插入空格,再在空格前插入 #(因为 insert 在同一位置会按顺序排列) + tr.insert(parentPos + 1, syntaxText); + tr.insert(parentPos + 1 + hashStr.length, schema.text(" ")); + dispatch(tr.scrollIntoView()); + } + } + return true; + }; +} + +/** + * 创建设置段落命令 + * + * 如果当前是标题,移除 # 前缀并转为段落。 + */ +export function createSetParagraphCommand(): Command { + return (state: EditorState, dispatch?: (tr: Transaction) => void) => { + const { $from } = state.selection; + const parent = $from.parent; + const parentPos = $from.before($from.depth); + const schema = state.schema; + + if (!schema.nodes.paragraph) return false; + + if (parent.type.name === "paragraph") return false; // 已经是段落 + + if (parent.type.name === "heading") { + if (dispatch) { + let syntaxEnd = 0; + let removeEnd = 0; + parent.forEach((child, offset) => { + if ( + child.marks.some( + (m: any) => m.type.name === "syntax_marker" && m.attrs.syntaxType === "heading" + ) + ) { + syntaxEnd = offset + child.nodeSize; + removeEnd = syntaxEnd; + } + }); + // 检查语法标记后面是否紧跟一个空格 + if (syntaxEnd < parent.content.size) { + const nextText = parent.textBetween( + syntaxEnd, + Math.min(syntaxEnd + 1, parent.content.size) + ); + if (nextText === " ") { + removeEnd = syntaxEnd + 1; + } + } + + const tr = state.tr; + tr.setBlockType(parentPos, parentPos + parent.nodeSize, schema.nodes.paragraph); + if (removeEnd > 0) { + tr.delete(parentPos + 1, parentPos + 1 + removeEnd); + } + dispatch(tr.scrollIntoView()); + } + return true; + } + + return false; + }; +} diff --git a/src/core/commands/index.ts b/src/core/commands/index.ts new file mode 100644 index 0000000..d7dc5a9 --- /dev/null +++ b/src/core/commands/index.ts @@ -0,0 +1,531 @@ +/** + * Milkup 编辑器命令 + * + * 提供常用的编辑操作命令 + */ + +import { EditorState, Transaction, TextSelection } from "prosemirror-state"; +import { Node, Mark, MarkType, NodeType } from "prosemirror-model"; +import { toggleMark, setBlockType, wrapIn, lift } from "prosemirror-commands"; + +type Command = (state: EditorState, dispatch?: (tr: Transaction) => void) => boolean; + +/** + * 切换粗体 + */ +export function toggleStrong(state: EditorState, dispatch?: (tr: Transaction) => void): boolean { + const markType = state.schema.marks.strong; + if (!markType) return false; + return toggleMark(markType)(state, dispatch); +} + +/** + * 切换斜体 + */ +export function toggleEmphasis(state: EditorState, dispatch?: (tr: Transaction) => void): boolean { + const markType = state.schema.marks.emphasis; + if (!markType) return false; + return toggleMark(markType)(state, dispatch); +} + +/** + * 切换行内代码 + */ +export function toggleCodeInline( + state: EditorState, + dispatch?: (tr: Transaction) => void +): boolean { + const markType = state.schema.marks.code_inline; + if (!markType) return false; + return toggleMark(markType)(state, dispatch); +} + +/** + * 切换删除线 + */ +export function toggleStrikethrough( + state: EditorState, + dispatch?: (tr: Transaction) => void +): boolean { + const markType = state.schema.marks.strikethrough; + if (!markType) return false; + return toggleMark(markType)(state, dispatch); +} + +/** + * 切换高亮 + */ +export function toggleHighlight(state: EditorState, dispatch?: (tr: Transaction) => void): boolean { + const markType = state.schema.marks.highlight; + if (!markType) return false; + return toggleMark(markType)(state, dispatch); +} + +/** + * 设置标题级别 + */ +export function setHeading(level: number): Command { + return (state, dispatch) => { + const nodeType = state.schema.nodes.heading; + if (!nodeType) return false; + return setBlockType(nodeType, { level })(state, dispatch); + }; +} + +/** + * 设置为段落 + */ +export function setParagraph(state: EditorState, dispatch?: (tr: Transaction) => void): boolean { + const nodeType = state.schema.nodes.paragraph; + if (!nodeType) return false; + return setBlockType(nodeType)(state, dispatch); +} + +/** + * 设置为代码块 + */ +export function setCodeBlock(language = ""): Command { + return (state, dispatch) => { + const nodeType = state.schema.nodes.code_block; + if (!nodeType) return false; + return setBlockType(nodeType, { language })(state, dispatch); + }; +} + +/** + * 包装为引用块 + */ +export function wrapInBlockquote( + state: EditorState, + dispatch?: (tr: Transaction) => void +): boolean { + const nodeType = state.schema.nodes.blockquote; + if (!nodeType) return false; + return wrapIn(nodeType)(state, dispatch); +} + +/** + * 包装为无序列表 + */ +export function wrapInBulletList( + state: EditorState, + dispatch?: (tr: Transaction) => void +): boolean { + const nodeType = state.schema.nodes.bullet_list; + if (!nodeType) return false; + return wrapIn(nodeType)(state, dispatch); +} + +/** + * 包装为有序列表 + */ +export function wrapInOrderedList( + state: EditorState, + dispatch?: (tr: Transaction) => void +): boolean { + const nodeType = state.schema.nodes.ordered_list; + if (!nodeType) return false; + return wrapIn(nodeType)(state, dispatch); +} + +/** + * 取消包装(提升) + */ +export function liftBlock(state: EditorState, dispatch?: (tr: Transaction) => void): boolean { + return lift(state, dispatch); +} + +/** + * 插入分隔线 + */ +export function insertHorizontalRule( + state: EditorState, + dispatch?: (tr: Transaction) => void +): boolean { + const nodeType = state.schema.nodes.horizontal_rule; + if (!nodeType) return false; + + if (dispatch) { + const tr = state.tr.replaceSelectionWith(nodeType.create()); + dispatch(tr.scrollIntoView()); + } + return true; +} + +/** + * 插入图片 + */ +export function insertImage(src: string, alt = "", title = ""): Command { + return (state, dispatch) => { + const nodeType = state.schema.nodes.image; + if (!nodeType) return false; + + if (dispatch) { + const node = nodeType.create({ src, alt, title }); + const tr = state.tr.replaceSelectionWith(node); + dispatch(tr.scrollIntoView()); + } + return true; + }; +} + +/** + * 插入链接 + */ +export function insertLink(href: string, title = ""): Command { + return (state, dispatch) => { + const markType = state.schema.marks.link; + if (!markType) return false; + + const { from, to, empty } = state.selection; + + if (dispatch) { + const mark = markType.create({ href, title }); + let tr = state.tr; + + if (empty) { + // 没有选中文本,插入链接文本 + const text = title || href; + tr = tr.insertText(text, from); + tr = tr.addMark(from, from + text.length, mark); + } else { + // 有选中文本,添加链接 + tr = tr.addMark(from, to, mark); + } + + dispatch(tr.scrollIntoView()); + } + return true; + }; +} + +/** + * 移除链接 + */ +export function removeLink(state: EditorState, dispatch?: (tr: Transaction) => void): boolean { + const markType = state.schema.marks.link; + if (!markType) return false; + + const { from, to } = state.selection; + + if (dispatch) { + const tr = state.tr.removeMark(from, to, markType); + dispatch(tr); + } + return true; +} + +/** + * 插入表格 + */ +export function insertTable(rows = 3, cols = 3): Command { + return (state, dispatch) => { + const { table, table_row, table_header, table_cell, paragraph } = state.schema.nodes; + if (!table || !table_row || !table_header || !table_cell) return false; + + if (dispatch) { + const tableRows: Node[] = []; + + // 表头行 + const headerCells: Node[] = []; + for (let c = 0; c < cols; c++) { + headerCells.push(table_header.create(null, paragraph?.create())); + } + tableRows.push(table_row.create(null, headerCells)); + + // 数据行 + for (let r = 1; r < rows; r++) { + const cells: Node[] = []; + for (let c = 0; c < cols; c++) { + cells.push(table_cell.create(null, paragraph?.create())); + } + tableRows.push(table_row.create(null, cells)); + } + + const tableNode = table.create(null, tableRows); + const tr = state.tr.replaceSelectionWith(tableNode); + dispatch(tr.scrollIntoView()); + } + return true; + }; +} + +/** + * 查找光标所在的表格上下文 + */ +function findTableContext(state: EditorState) { + const { $from } = state.selection; + for (let depth = $from.depth; depth > 0; depth--) { + const node = $from.node(depth); + if (node.type.name === "table") { + // 找到 table_row 和 table_cell 的 depth + let rowDepth = -1; + let cellDepth = -1; + for (let d = $from.depth; d > depth; d--) { + const n = $from.node(d); + if (n.type.name === "table_row") rowDepth = d; + if (n.type.name === "table_cell" || n.type.name === "table_header") cellDepth = d; + } + if (rowDepth === -1 || cellDepth === -1) return null; + const rowIndex = $from.index(depth); + const cellIndex = $from.index(rowDepth); + return { + tableDepth: depth, + tableStart: $from.before(depth), + tableNode: node, + rowDepth, + rowIndex, + cellDepth, + cellIndex, + }; + } + } + return null; +} + +/** + * 创建一行新的 cells + */ +function createRow(state: EditorState, colCount: number, useHeader: boolean): Node { + const { table_row, table_header, table_cell, paragraph } = state.schema.nodes; + const cellType = useHeader ? table_header : table_cell; + const cells: Node[] = []; + for (let i = 0; i < colCount; i++) { + cells.push(cellType.create(null, paragraph.create())); + } + return table_row.create(null, cells); +} + +/** + * 在当前行上方插入一行 + */ +export function addRowBefore(state: EditorState, dispatch?: (tr: Transaction) => void): boolean { + const ctx = findTableContext(state); + if (!ctx) return false; + if (dispatch) { + const { $from } = state.selection; + const rowPos = $from.before(ctx.rowDepth); + const colCount = ctx.tableNode.child(0).childCount; + const newRow = createRow(state, colCount, false); + dispatch(state.tr.insert(rowPos, newRow).scrollIntoView()); + } + return true; +} + +/** + * 在当前行下方插入一行 + */ +export function addRowAfter(state: EditorState, dispatch?: (tr: Transaction) => void): boolean { + const ctx = findTableContext(state); + if (!ctx) return false; + if (dispatch) { + const { $from } = state.selection; + const rowPos = $from.after(ctx.rowDepth); + const colCount = ctx.tableNode.child(0).childCount; + const newRow = createRow(state, colCount, false); + dispatch(state.tr.insert(rowPos, newRow).scrollIntoView()); + } + return true; +} + +/** + * 在表格末尾追加一行 + */ +export function addRowAtEnd(state: EditorState, dispatch?: (tr: Transaction) => void): boolean { + const ctx = findTableContext(state); + if (!ctx) return false; + if (dispatch) { + const tableEnd = ctx.tableStart + ctx.tableNode.nodeSize - 1; + const colCount = ctx.tableNode.child(0).childCount; + const newRow = createRow(state, colCount, false); + dispatch(state.tr.insert(tableEnd, newRow).scrollIntoView()); + } + return true; +} + +/** + * 在当前列左侧插入一列 + */ +export function addColumnBefore(state: EditorState, dispatch?: (tr: Transaction) => void): boolean { + const ctx = findTableContext(state); + if (!ctx) return false; + if (dispatch) { + const { paragraph, table_header, table_cell } = state.schema.nodes; + let tr = state.tr; + const tableStart = ctx.tableStart + 1; // 进入 table 内部 + let offset = 0; + ctx.tableNode.forEach((row, rowOffset, rowIndex) => { + const isHeaderRow = rowIndex === 0; + const cellType = isHeaderRow ? table_header : table_cell; + const newCell = cellType.create(null, paragraph.create()); + // 找到该行中第 cellIndex 个 cell 的位置 + let cellPos = tableStart + rowOffset + 1; // +1 进入 row 内部 + for (let c = 0; c < ctx.cellIndex; c++) { + cellPos += row.child(c).nodeSize; + } + tr = tr.insert(cellPos + offset, newCell); + offset += newCell.nodeSize; + }); + dispatch(tr.scrollIntoView()); + } + return true; +} + +/** + * 在当前列右侧插入一列 + */ +export function addColumnAfter(state: EditorState, dispatch?: (tr: Transaction) => void): boolean { + const ctx = findTableContext(state); + if (!ctx) return false; + if (dispatch) { + const { paragraph, table_header, table_cell } = state.schema.nodes; + let tr = state.tr; + const tableStart = ctx.tableStart + 1; + let offset = 0; + ctx.tableNode.forEach((row, rowOffset, rowIndex) => { + const isHeaderRow = rowIndex === 0; + const cellType = isHeaderRow ? table_header : table_cell; + const newCell = cellType.create(null, paragraph.create()); + let cellPos = tableStart + rowOffset + 1; + for (let c = 0; c <= ctx.cellIndex; c++) { + cellPos += row.child(c).nodeSize; + } + tr = tr.insert(cellPos + offset, newCell); + offset += newCell.nodeSize; + }); + dispatch(tr.scrollIntoView()); + } + return true; +} + +/** + * 在表格最右侧追加一列 + */ +export function addColumnAtEnd(state: EditorState, dispatch?: (tr: Transaction) => void): boolean { + const ctx = findTableContext(state); + if (!ctx) return false; + if (dispatch) { + const { paragraph, table_header, table_cell } = state.schema.nodes; + let tr = state.tr; + const tableStart = ctx.tableStart + 1; + let offset = 0; + ctx.tableNode.forEach((row, rowOffset, rowIndex) => { + const isHeaderRow = rowIndex === 0; + const cellType = isHeaderRow ? table_header : table_cell; + const newCell = cellType.create(null, paragraph.create()); + // 插入到行末尾(row 结束标签前) + const rowEnd = tableStart + rowOffset + row.nodeSize - 1; + tr = tr.insert(rowEnd + offset, newCell); + offset += newCell.nodeSize; + }); + dispatch(tr.scrollIntoView()); + } + return true; +} + +/** + * 删除当前行 + */ +export function deleteRow(state: EditorState, dispatch?: (tr: Transaction) => void): boolean { + const ctx = findTableContext(state); + if (!ctx) return false; + // 至少保留一行 + if (ctx.tableNode.childCount <= 1) return false; + if (dispatch) { + const { $from } = state.selection; + const rowStart = $from.before(ctx.rowDepth); + const rowEnd = $from.after(ctx.rowDepth); + dispatch(state.tr.delete(rowStart, rowEnd).scrollIntoView()); + } + return true; +} + +/** + * 删除当前列 + */ +export function deleteColumn(state: EditorState, dispatch?: (tr: Transaction) => void): boolean { + const ctx = findTableContext(state); + if (!ctx) return false; + // 至少保留一列 + if (ctx.tableNode.child(0).childCount <= 1) return false; + if (dispatch) { + let tr = state.tr; + const tableStart = ctx.tableStart + 1; + let offset = 0; + ctx.tableNode.forEach((row, rowOffset) => { + let cellPos = tableStart + rowOffset + 1; + for (let c = 0; c < ctx.cellIndex; c++) { + cellPos += row.child(c).nodeSize; + } + const cellEnd = cellPos + row.child(ctx.cellIndex).nodeSize; + tr = tr.delete(cellPos + offset, cellEnd + offset); + offset -= row.child(ctx.cellIndex).nodeSize; + }); + dispatch(tr.scrollIntoView()); + } + return true; +} + +/** + * 插入数学块 + */ +export function insertMathBlock(content = ""): Command { + return (state, dispatch) => { + const nodeType = state.schema.nodes.math_block; + if (!nodeType) return false; + + if (dispatch) { + const node = nodeType.create({ content }); + const tr = state.tr.replaceSelectionWith(node); + dispatch(tr.scrollIntoView()); + } + return true; + }; +} + +/** + * 插入容器 + */ +export function insertContainer(type = "note", title = ""): Command { + return (state, dispatch) => { + const { container, paragraph } = state.schema.nodes; + if (!container) return false; + + if (dispatch) { + const node = container.create({ type, title }, paragraph?.create()); + const tr = state.tr.replaceSelectionWith(node); + dispatch(tr.scrollIntoView()); + } + return true; + }; +} + +export const commands = { + toggleStrong, + toggleEmphasis, + toggleCodeInline, + toggleStrikethrough, + toggleHighlight, + setHeading, + setParagraph, + setCodeBlock, + wrapInBlockquote, + wrapInBulletList, + wrapInOrderedList, + liftBlock, + insertHorizontalRule, + insertImage, + insertLink, + removeLink, + insertTable, + addRowBefore, + addRowAfter, + addRowAtEnd, + addColumnBefore, + addColumnAfter, + addColumnAtEnd, + deleteRow, + deleteColumn, + insertMathBlock, + insertContainer, +}; diff --git a/src/core/decorations/index.ts b/src/core/decorations/index.ts new file mode 100644 index 0000000..a3c82f4 --- /dev/null +++ b/src/core/decorations/index.ts @@ -0,0 +1,672 @@ +/** + * Milkup 装饰系统 v2 + * + * 基于 syntax_marker mark 的即时渲染装饰系统 + * 语法标记是真实的文本内容,光标可以自由移动 + * 装饰只控制显示/隐藏,不改变文档结构 + */ + +import { Decoration, DecorationSet } from "prosemirror-view"; +import { EditorState, Plugin, PluginKey } from "prosemirror-state"; +import { Node } from "prosemirror-model"; +import type { SyntaxType } from "../types"; +import { renderInlineMath } from "../nodeviews/math-block"; +import { + convertBlocksToParagraphs, + convertParagraphsToBlocks, +} from "../plugins/source-view-transform"; + +// ============ 源码模式状态管理器 ============ + +/** 源码模式状态变化监听器 */ +export type SourceViewListener = (sourceView: boolean) => void; + +/** 源码模式状态管理器 */ +class SourceViewManager { + private listeners: Set = new Set(); + private currentState: boolean = false; + + /** 订阅状态变化 */ + subscribe(listener: SourceViewListener): () => void { + this.listeners.add(listener); + // 立即通知当前状态 + listener(this.currentState); + // 返回取消订阅函数 + return () => this.listeners.delete(listener); + } + + /** 更新状态并通知所有监听器 */ + setState(sourceView: boolean): void { + if (this.currentState !== sourceView) { + this.currentState = sourceView; + this.listeners.forEach((listener) => listener(sourceView)); + } + } + + /** 获取当前状态 */ + getState(): boolean { + return this.currentState; + } +} + +/** 全局源码模式状态管理器实例 */ +export const sourceViewManager = new SourceViewManager(); + +/** 装饰插件状态 */ +export interface DecorationPluginState { + decorations: DecorationSet; + activeRegions: SyntaxMarkerRegion[]; + sourceView: boolean; + cachedSyntaxRegions: SyntaxMarkerRegion[]; + cachedMathInlineRegions: MathInlineRegion[]; +} + +/** 语法标记区域 */ +export interface SyntaxMarkerRegion { + from: number; + to: number; + syntaxType: string; +} + +/** Mark 区域(兼容旧接口) */ +export interface MarkRegion { + type: string; + from: number; + to: number; + mark: any; +} + +/** 语法区域(兼容旧接口) */ +export interface SyntaxRegion { + type: SyntaxType; + from: number; + to: number; + contentFrom: number; + contentTo: number; + prefix: string; + suffix: string; +} + +/** 装饰插件 Key */ +export const decorationPluginKey = new PluginKey("milkup-decorations"); + +/** CSS 类名映射 */ +export const SYNTAX_CLASSES: Record = { + strong: "milkup-strong", + emphasis: "milkup-emphasis", + code_inline: "milkup-code-inline", + strikethrough: "milkup-strikethrough", + link: "milkup-link", + highlight: "milkup-highlight", + math_inline: "milkup-math-inline", + heading: "milkup-heading", // 标题 + strong_emphasis: "milkup-strong-emphasis", // 粗斜体 + escape: "milkup-escape", // 转义 +}; + +/** 语法类型关联映射 - 用于处理嵌套语法 */ +const SYNTAX_TYPE_RELATIONS: Record = { + strong_emphasis: ["strong", "emphasis"], + strong: ["strong", "strong_emphasis"], + emphasis: ["emphasis", "strong_emphasis"], + highlight: ["highlight"], + strikethrough: ["strikethrough"], + code_inline: ["code_inline"], + link: ["link"], + math_inline: ["math_inline"], + heading: ["heading"], + escape: ["escape"], +}; + +/** + * 查找文档中所有的 syntax_marker 区域 + */ +export function findSyntaxMarkerRegions(doc: Node): SyntaxMarkerRegion[] { + const regions: SyntaxMarkerRegion[] = []; + + doc.descendants((node, pos) => { + if (node.isText) { + const syntaxMark = node.marks.find((m) => m.type.name === "syntax_marker"); + if (syntaxMark) { + regions.push({ + from: pos, + to: pos + node.nodeSize, + syntaxType: syntaxMark.attrs.syntaxType, + }); + } + } + return true; + }); + + return regions; +} + +/** 行内数学公式区域 */ +export interface MathInlineRegion { + from: number; + to: number; + content: string; + contentFrom: number; + contentTo: number; +} + +/** + * 查找文档中所有的行内数学公式区域 + */ +export function findMathInlineRegions(doc: Node): MathInlineRegion[] { + const regions: MathInlineRegion[] = []; + + doc.descendants((node, pos) => { + if (node.isTextblock) { + // 在文本块中查找 math_inline mark 区域 + let offset = pos + 1; // +1 跳过节点开始标记 + let currentRegion: { + from: number; + to: number; + content: string; + contentFrom: number; + contentTo: number; + } | null = null; + + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + const childStart = offset; + const childEnd = offset + child.nodeSize; + + const hasMathMark = child.marks.some((m) => m.type.name === "math_inline"); + const hasSyntaxMark = child.marks.some( + (m) => m.type.name === "syntax_marker" && m.attrs.syntaxType === "math_inline" + ); + + if (hasMathMark) { + if (currentRegion === null) { + currentRegion = { + from: childStart, + to: childEnd, + content: "", + contentFrom: childStart, + contentTo: childEnd, + }; + } else { + currentRegion.to = childEnd; + } + + // 如果不是语法标记,则是内容 + if (!hasSyntaxMark && child.isText) { + if (currentRegion.content === "") { + currentRegion.contentFrom = childStart; + } + currentRegion.content += child.text || ""; + currentRegion.contentTo = childEnd; + } + } else { + if (currentRegion !== null) { + regions.push(currentRegion); + currentRegion = null; + } + } + + offset = childEnd; + } + + // 不要忘记最后一个区域 + if (currentRegion !== null) { + regions.push(currentRegion); + } + } + return true; + }); + + return regions; +} + +/** + * 查找包含指定位置的所有语义 Mark 区域 + * 用于判断光标是否在某个语法结构内 + * 返回所有相关的语义区域(支持嵌套语法) + */ +export function findSemanticRegionsAt( + doc: Node, + pos: number +): Array<{ type: string; from: number; to: number }> { + const $pos = doc.resolve(pos); + const parent = $pos.parent; + + if (!parent.isTextblock) return []; + + // 保存父节点内容的开始位置 + const parentStart = $pos.start(); + let offset = parentStart; + const regions: Array<{ type: string; from: number; to: number }> = []; + const foundTypes = new Set(); + + for (let i = 0; i < parent.childCount; i++) { + const child = parent.child(i); + const childStart = offset; + const childEnd = offset + child.nodeSize; + + if (pos >= childStart && pos <= childEnd) { + // 检查这个节点的所有 marks + for (const mark of child.marks) { + if ( + mark.type.name !== "syntax_marker" && + SYNTAX_CLASSES[mark.type.name] && + !foundTypes.has(mark.type.name) + ) { + // 找到语义 mark,现在需要找到整个区域 + const region = findFullMarkRegion(parent, mark.type.name, childStart, parentStart); + if (region) { + regions.push(region); + foundTypes.add(mark.type.name); + } + } + } + } + + offset = childEnd; + } + + return regions; +} + +/** + * 查找包含指定位置的语义 Mark 区域(兼容旧接口) + */ +export function findSemanticRegionAt( + doc: Node, + pos: number +): { type: string; from: number; to: number } | null { + const regions = findSemanticRegionsAt(doc, pos); + return regions.length > 0 ? regions[0] : null; +} + +/** + * 找到完整的 mark 区域(包括相邻的同类型 mark 节点) + * 确保找到包含 startHint 位置的连续区域 + */ +function findFullMarkRegion( + parent: Node, + markType: string, + startHint: number, + parentOffset: number +): { type: string; from: number; to: number } | null { + // 收集所有有该 mark 的连续区域 + const regions: Array<{ from: number; to: number }> = []; + let currentRegion: { from: number; to: number } | null = null; + let offset = parentOffset; + + for (let i = 0; i < parent.childCount; i++) { + const child = parent.child(i); + const childStart = offset; + const childEnd = offset + child.nodeSize; + + const hasMark = child.marks.some((m) => m.type.name === markType); + + if (hasMark) { + if (currentRegion === null) { + currentRegion = { from: childStart, to: childEnd }; + } else { + currentRegion.to = childEnd; + } + } else { + if (currentRegion !== null) { + regions.push(currentRegion); + currentRegion = null; + } + } + + offset = childEnd; + } + + // 不要忘记最后一个区域 + if (currentRegion !== null) { + regions.push(currentRegion); + } + + // 找到包含 startHint 的区域 + for (const region of regions) { + if (startHint >= region.from && startHint <= region.to) { + return { type: markType, from: region.from, to: region.to }; + } + } + + // 如果没找到,返回第一个区域(兜底) + if (regions.length > 0) { + return { type: markType, from: regions[0].from, to: regions[0].to }; + } + + return null; +} + +/** + * 检查光标是否在语法区域内 + */ +export function isCursorInSyntaxRegion( + doc: Node, + cursorPos: number, + syntaxRegions: SyntaxMarkerRegion[] +): boolean { + // 首先检查是否在 syntax_marker 内 + for (const region of syntaxRegions) { + if (cursorPos >= region.from && cursorPos <= region.to) { + return true; + } + } + + // 然后检查是否在语义 mark 区域内 + const semanticRegion = findSemanticRegionAt(doc, cursorPos); + return semanticRegion !== null; +} + +/** + * 获取光标所在的所有语义区域 + * 包括行内 marks 和块级节点(如标题) + */ +export function getActiveSemanticRegions( + doc: Node, + cursorPos: number +): Array<{ type: string; from: number; to: number }> { + const regions: Array<{ type: string; from: number; to: number }> = []; + + // 首先检查行内 mark 区域 + const inlineRegions = findSemanticRegionsAt(doc, cursorPos); + regions.push(...inlineRegions); + + // 检查块级节点(如标题) + const $pos = doc.resolve(cursorPos); + const parent = $pos.parent; + + // 如果父节点是标题,返回整个标题区域 + if (parent.type.name === "heading") { + const start = $pos.start(); + const end = $pos.end(); + regions.push({ type: "heading", from: start, to: end }); + } + + return regions; +} + +/** + * 获取光标所在的语义区域(兼容旧接口) + */ +export function getActiveSemanticRegion( + doc: Node, + cursorPos: number +): { type: string; from: number; to: number } | null { + const regions = getActiveSemanticRegions(doc, cursorPos); + return regions.length > 0 ? regions[0] : null; +} + +/** + * 检查语法类型是否与语义区域类型相关 + */ +function isSyntaxTypeRelated(syntaxType: string, semanticType: string): boolean { + const relatedTypes = SYNTAX_TYPE_RELATIONS[syntaxType] || [syntaxType]; + return relatedTypes.includes(semanticType); +} + +/** + * 计算装饰集 + */ +export function computeDecorations( + doc: Node, + cursorPos: number, + sourceView: boolean, + precomputedSyntaxRegions?: SyntaxMarkerRegion[], + precomputedMathRegions?: MathInlineRegion[] +): { + decorations: DecorationSet; + activeRegions: SyntaxMarkerRegion[]; + syntaxRegions: SyntaxMarkerRegion[]; + mathInlineRegions: MathInlineRegion[]; +} { + // 源码模式下跳过所有装饰计算: + // - 语法标记通过 .milkup-syntax-marker 类(mark 自带)已有正确样式 + // - 无需 hidden/visible 装饰切换 + // - 无需行内数学公式渲染 widget + if (sourceView) { + return { + decorations: DecorationSet.empty, + activeRegions: [], + syntaxRegions: precomputedSyntaxRegions ?? [], + mathInlineRegions: precomputedMathRegions ?? [], + }; + } + + const syntaxRegions = precomputedSyntaxRegions ?? findSyntaxMarkerRegions(doc); + const mathInlineRegions = precomputedMathRegions ?? findMathInlineRegions(doc); + const decorations: Decoration[] = []; + + // 获取光标所在的所有语义区域 + const activeSemanticRegions = getActiveSemanticRegions(doc, cursorPos); + + for (const region of syntaxRegions) { + // 判断这个语法标记是否应该显示 + let shouldShow = sourceView; + + if (!shouldShow && region.syntaxType === "escape") { + // escape 类型特殊处理:当光标在 `\` 或紧邻的被转义字符上时显示 + // region 是 `\` 的位置,被转义字符紧跟其后(region.to 位置) + if (cursorPos >= region.from && cursorPos <= region.to + 1) { + shouldShow = true; + } + } else if (!shouldShow && activeSemanticRegions.length > 0) { + // 如果光标在某个语义区域内,显示该区域的所有语法标记 + for (const activeRegion of activeSemanticRegions) { + // 检查这个 syntax_marker 是否属于当前活跃的语义区域 + if (isSyntaxTypeRelated(region.syntaxType, activeRegion.type)) { + // 检查位置是否在语义区域内(严格检查) + if (region.from >= activeRegion.from && region.to <= activeRegion.to) { + shouldShow = true; + break; + } + } + } + } + + if (!shouldShow) { + // 检查光标是否直接在这个 syntax_marker 内 + if (cursorPos >= region.from && cursorPos <= region.to) { + shouldShow = true; + } + } + + if (!shouldShow) { + // 隐藏语法标记 + if (region.syntaxType === "heading") { + // 标题语法标记特殊处理:只隐藏 # 字符,保留尾部空格可见 + const text = doc.textBetween(region.from, region.to); + const hashEnd = text.search(/[^#]/); + if (hashEnd > 0 && hashEnd < text.length) { + decorations.push( + Decoration.inline(region.from, region.from + hashEnd, { + class: "milkup-syntax-hidden", + }) + ); + } else { + decorations.push( + Decoration.inline(region.from, region.to, { + class: "milkup-syntax-hidden", + }) + ); + } + } else { + decorations.push( + Decoration.inline(region.from, region.to, { + class: "milkup-syntax-hidden", + }) + ); + } + } else { + // 显示语法标记 + decorations.push( + Decoration.inline(region.from, region.to, { + class: "milkup-syntax-visible", + }) + ); + } + } + + // 为行内数学公式添加渲染装饰 + for (const mathRegion of mathInlineRegions) { + // 检查光标是否在这个数学公式区域内 + const cursorInMath = cursorPos >= mathRegion.from && cursorPos <= mathRegion.to; + + if (!cursorInMath && !sourceView && mathRegion.content.trim()) { + // 光标不在公式内,隐藏源码并显示渲染结果 + // 隐藏整个公式源码 + decorations.push( + Decoration.inline(mathRegion.from, mathRegion.to, { + class: "milkup-math-source-hidden", + }) + ); + + // 在公式后面添加渲染后的 widget + const renderedHtml = renderInlineMath(mathRegion.content); + if (renderedHtml) { + const widget = document.createElement("span"); + widget.className = "milkup-math-rendered"; + widget.innerHTML = renderedHtml; + decorations.push(Decoration.widget(mathRegion.to, widget, { side: -1 })); + } + } + } + + return { + decorations: DecorationSet.create(doc, decorations), + activeRegions: syntaxRegions.filter((r) => cursorPos >= r.from && cursorPos <= r.to), + syntaxRegions, + mathInlineRegions, + }; +} + +/** + * 兼容旧接口 + */ +export function findSyntaxRegions(doc: Node): SyntaxRegion[] { + return []; +} + +export function findMarkRegions(doc: Node): MarkRegion[] { + return []; +} + +export function getActiveRegions(cursorPos: number, regions: any[]): any[] { + return regions.filter((r) => cursorPos >= r.from && cursorPos <= r.to); +} + +/** + * 创建装饰插件 + */ +export function createDecorationPlugin(initialSourceView = false): Plugin { + return new Plugin({ + key: decorationPluginKey, + + state: { + init(_, state) { + const { decorations, activeRegions, syntaxRegions, mathInlineRegions } = computeDecorations( + state.doc, + state.selection.head, + initialSourceView + ); + return { + decorations, + activeRegions, + sourceView: initialSourceView, + cachedSyntaxRegions: syntaxRegions, + cachedMathInlineRegions: mathInlineRegions, + }; + }, + + apply(tr, pluginState, oldState, newState) { + const selectionChanged = !oldState.selection.eq(newState.selection); + const docChanged = tr.docChanged; + + const meta = tr.getMeta(decorationPluginKey); + const sourceView = meta?.sourceView ?? pluginState.sourceView; + + if (docChanged || selectionChanged || meta?.sourceView !== undefined) { + // 仅在文档变化或源码模式切换时重新扫描区域,选区变化时复用缓存 + const needRescan = docChanged || meta?.sourceView !== undefined; + const syntaxRegions = needRescan ? undefined : pluginState.cachedSyntaxRegions; + const mathRegions = needRescan ? undefined : pluginState.cachedMathInlineRegions; + + const { + decorations, + activeRegions, + syntaxRegions: newSyntax, + mathInlineRegions: newMath, + } = computeDecorations( + newState.doc, + newState.selection.head, + sourceView, + syntaxRegions, + mathRegions + ); + return { + decorations, + activeRegions, + sourceView, + cachedSyntaxRegions: newSyntax, + cachedMathInlineRegions: newMath, + }; + } + + return pluginState; + }, + }, + + props: { + decorations(state) { + return this.getState(state)?.decorations ?? DecorationSet.empty; + }, + }, + }); +} + +/** + * 切换源码视图 + */ +export function toggleSourceView(state: EditorState, dispatch?: (tr: any) => void): boolean { + const pluginState = decorationPluginKey.getState(state); + if (!pluginState) return false; + + const newSourceView = !pluginState.sourceView; + + if (dispatch) { + const tr = state.tr.setMeta(decorationPluginKey, { + sourceView: newSourceView, + }); + // 将文档转换合并到同一个 transaction 中,避免 appendTransaction 产生第二轮插件应用 + if (newSourceView) { + convertBlocksToParagraphs(tr); + } else { + convertParagraphsToBlocks(tr); + } + dispatch(tr); + } + + // 通知状态管理器 + sourceViewManager.setState(newSourceView); + + return true; +} + +/** + * 设置源码视图状态 + */ +export function setSourceView( + state: EditorState, + enabled: boolean, + dispatch?: (tr: any) => void +): boolean { + if (dispatch) { + const tr = state.tr.setMeta(decorationPluginKey, { sourceView: enabled }); + dispatch(tr); + } + + // 通知状态管理器 + sourceViewManager.setState(enabled); + + return true; +} diff --git a/src/core/editor.ts b/src/core/editor.ts new file mode 100644 index 0000000..7b12c01 --- /dev/null +++ b/src/core/editor.ts @@ -0,0 +1,1673 @@ +/** + * Milkup 编辑器主类 + * + * 整合所有模块,提供统一的编辑器 API + */ + +import { EditorState, Plugin, Transaction, Selection, TextSelection } from "prosemirror-state"; +import { EditorView } from "prosemirror-view"; +import { Schema, Node, Slice, Fragment } from "prosemirror-model"; +import { history } from "prosemirror-history"; +import { dropCursor } from "prosemirror-dropcursor"; +import { gapCursor } from "prosemirror-gapcursor"; +import { baseKeymap } from "prosemirror-commands"; +import { keymap } from "prosemirror-keymap"; + +// 导入 KaTeX CSS +import "katex/dist/katex.min.css"; + +import { milkupSchema } from "./schema"; +import { parseMarkdown, MarkdownParser } from "./parser"; +import { serializeMarkdown, MarkdownSerializer } from "./serializer"; +import { createInstantRenderPlugin } from "./plugins/instant-render"; +import { createInputRulesPlugin } from "./plugins/input-rules"; +import { createSyntaxFixerPlugin } from "./plugins/syntax-fixer"; +import { createSyntaxDetectorPlugin } from "./plugins/syntax-detector"; +import { createHeadingSyncPlugin } from "./plugins/heading-sync"; +import { + createPastePlugin, + fileToBase64, + saveImageLocally, + ImagePasteMethod, +} from "./plugins/paste"; +import { createMathBlockSyncPlugin } from "./plugins/math-block-sync"; +import { createHtmlBlockSyncPlugin } from "./plugins/html-block-sync"; +import { createImageSyncPlugin } from "./plugins/image-sync"; +import { createAICompletionPlugin } from "./plugins/ai-completion"; +import { createPlaceholderPlugin } from "./plugins/placeholder"; +import { createLineNumbersPlugin } from "./plugins/line-numbers"; +import { createSourceViewTransformPlugin } from "./plugins/source-view-transform"; +import { + createSearchPlugin, + searchPluginKey, + updateSearch, + findNext, + findPrev, + replaceMatch, + replaceAll, + clearSearch, +} from "./plugins/search"; +import type { SearchOptions } from "./plugins/search"; +import { createKeymapPlugin, createDynamicKeymapPlugin } from "./keymap"; +import type { ShortcutKeyMap } from "./keymap"; +import { createCodeBlockNodeView } from "./nodeviews/code-block"; +import { createMathBlockNodeView } from "./nodeviews/math-block"; +import { createHtmlBlockNodeView } from "./nodeviews/html-block"; +import { createImageNodeView } from "./nodeviews/image"; +import { + createBulletListNodeView, + createOrderedListNodeView, + createListItemNodeView, + createTaskListNodeView, + createTaskItemNodeView, +} from "./nodeviews/list"; +import { toggleSourceView, setSourceView, decorationPluginKey } from "./decorations"; +import type { MilkupConfig, MilkupEditor as IMilkupEditor, MilkupPlugin } from "./types"; +import { + insertTable, + addRowBefore, + addRowAfter, + addRowAtEnd, + addColumnBefore, + addColumnAfter, + addColumnAtEnd, + deleteRow, + deleteColumn, +} from "./commands"; +import { DEFAULT_SHORTCUTS, buildActionCommandMap } from "./keymap"; +import type { ShortcutActionId } from "./keymap"; + +/** + * 将 ProseMirror 快捷键格式转为显示文本 + */ +function formatShortcutDisplay(key: string): string { + const isMac = /Mac|iPhone|iPad/.test(navigator.platform); + const parts = key.split("-"); + const mapped = parts.map((p) => { + switch (p) { + case "Mod": + return isMac ? "⌘" : "Ctrl"; + case "Shift": + return isMac ? "⇧" : "Shift"; + case "Alt": + return isMac ? "⌥" : "Alt"; + case "minus": + return "-"; + default: + return p.length === 1 ? p.toUpperCase() : p; + } + }); + return isMac ? mapped.join("") : mapped.join("+"); +} + +/** 编辑器默认配置 */ +const defaultConfig: MilkupConfig = { + content: "", + readonly: false, + sourceView: false, +}; + +/** + * Milkup 编辑器类 + */ +export class MilkupEditor implements IMilkupEditor { + view: EditorView; + private config: MilkupConfig; + private schema: Schema; + private parser: MarkdownParser; + private serializer: MarkdownSerializer; + private plugins: MilkupPlugin[] = []; + private eventHandlers: Map> = new Map(); + private contextMenu: HTMLElement | null = null; + private linkTooltip: HTMLElement | null = null; + private linkTooltipCurrentLink: HTMLAnchorElement | null = null; + private linkTooltipHideTimer: ReturnType | null = null; + private searchPanel: HTMLElement | null = null; + private searchWrapper: HTMLElement | null = null; + private searchInput: HTMLInputElement | null = null; + private replaceRow: HTMLElement | null = null; + private matchCountSpan: HTMLElement | null = null; + private searchCaseSensitive = false; + private searchWholeWord = false; + private searchUseRegex = false; + private searchInSelection = false; + private searchSelectionRange: { from: number; to: number } | null = null; + private _destroyed = false; + + constructor(container: HTMLElement, config: MilkupConfig = {}) { + this.config = { ...defaultConfig, ...config }; + this.schema = milkupSchema; + this.parser = new MarkdownParser(this.schema); + this.serializer = new MarkdownSerializer(); + + // 解析初始内容 + const { doc } = this.parser.parse(this.config.content || ""); + + // 创建编辑器状态 + const state = EditorState.create({ + doc, + plugins: this.createPlugins(), + }); + + // 创建编辑器视图 + this.view = new EditorView(container, { + state, + editable: () => !this.config.readonly, + clipboardTextSerializer: (slice) => this.serializeSliceToMarkdown(slice), + clipboardTextParser: (text, $context, plain, view) => { + // 将粘贴的纯文本作为 Markdown 解析 + const { doc } = this.parser.parse(text); + return new Slice(doc.content, 1, 1); + }, + nodeViews: { + code_block: createCodeBlockNodeView, + math_block: createMathBlockNodeView, + html_block: createHtmlBlockNodeView, + image: createImageNodeView, + bullet_list: createBulletListNodeView, + ordered_list: createOrderedListNodeView, + list_item: createListItemNodeView, + task_list: createTaskListNodeView, + task_item: createTaskItemNodeView, + }, + dispatchTransaction: (tr) => this.dispatchTransaction(tr), + attributes: { + class: "milkup-editor", + }, + handleClick: (view, pos, event) => this.handleEditorClick(view, pos, event), + handleDOMEvents: { + contextmenu: (view, event) => this.handleContextMenu(view, event), + }, + }); + + // 初始化自定义插件 + this.initPlugins(); + + // 初始化链接 tooltip 和点击拦截 + this.initLinkHandler(); + + // 创建搜索面板(挂载到 container,不在 contenteditable 内) + this.createSearchPanel(container); + + // 设置初始源码视图状态 + if (this.config.sourceView) { + setSourceView(this.view.state, true, this.view.dispatch.bind(this.view)); + } + } + + /** + * 创建 ProseMirror 插件 + */ + private createPlugins(): Plugin[] { + const plugins: Plugin[] = [ + // 历史记录 + history(), + // 拖拽光标 + dropCursor(), + // 间隙光标 + gapCursor(), + // 搜索替换快捷键(最高优先级) + keymap({ + "Mod-f": () => { + this.openSearch(false); + return true; + }, + "Mod-h": () => { + this.openSearch(true); + return true; + }, + }), + // 动态快捷键插件(可自定义的快捷键,优先级最高) + createDynamicKeymapPlugin(this.schema, () => this.getCustomKeyMap()), + // 不可自定义的快捷键(块级 Enter、列表操作等) + ...createKeymapPlugin(this.schema), + // 基础快捷键 + keymap(baseKeymap), + // 即时渲染插件 + ...createInstantRenderPlugin(), + // 输入规则 + createInputRulesPlugin(this.schema), + // 语法修复插件 + createSyntaxFixerPlugin(), + // 语法检测插件 + createSyntaxDetectorPlugin(), + // 标题同步插件 + createHeadingSyncPlugin(), + // 粘贴处理插件 + createPastePlugin(this.config.pasteConfig), + // 数学块状态同步插件 + createMathBlockSyncPlugin(), + // HTML 块状态同步插件 + createHtmlBlockSyncPlugin(), + // 图片状态同步插件 + createImageSyncPlugin(), + // 源码模式文档转换插件 + createSourceViewTransformPlugin(), + // 行号插件 + createLineNumbersPlugin(), + // 搜索插件 + createSearchPlugin(), + ]; + + // AI 续写插件(如果配置了) + if (this.config.aiConfig) { + const aiConfig = this.config.aiConfig; + plugins.push(createAICompletionPlugin(() => aiConfig)); + } + + // Placeholder 插件(如果配置了) + if (this.config.placeholder) { + plugins.push(createPlaceholderPlugin(this.config.placeholder)); + } + + return plugins; + } + + /** + * 初始化自定义插件 + */ + private initPlugins(): void { + if (this.config.plugins) { + for (const plugin of this.config.plugins) { + this.plugins.push(plugin); + plugin.init?.(this); + } + } + } + + /** + * 处理事务分发 + */ + private dispatchTransaction(tr: Transaction): void { + const newState = this.view.state.apply(tr); + this.view.updateState(newState); + + // 触发变更事件 + if (tr.docChanged) { + this.emit("change", { + markdown: this.getMarkdown(), + transaction: tr, + }); + } + + // 触发选区变更事件 + if (tr.selectionSet) { + this.emit("selectionChange", { + from: newState.selection.from, + to: newState.selection.to, + sourceFrom: newState.selection.from, + sourceTo: newState.selection.to, + }); + } + } + + /** + * 获取 Markdown 内容 + */ + getMarkdown(): string { + return serializeMarkdown(this.view.state.doc); + } + + /** + * 设置 Markdown 内容 + */ + setMarkdown(content: string): void { + const { doc } = this.parser.parse(content); + const tr = this.view.state.tr.replaceWith(0, this.view.state.doc.content.size, doc.content); + this.view.dispatch(tr); + } + + /** + * 获取当前配置 + */ + getConfig(): MilkupConfig { + return { ...this.config }; + } + + /** + * 更新配置 + */ + updateConfig(config: Partial): void { + this.config = { ...this.config, ...config }; + + // 处理只读状态变更 + if (config.readonly !== undefined) { + this.view.setProps({ editable: () => !config.readonly }); + } + + // 处理源码视图变更 + if (config.sourceView !== undefined) { + setSourceView(this.view.state, config.sourceView, this.view.dispatch.bind(this.view)); + } + } + + /** + * 销毁编辑器 + */ + destroy(): void { + this._destroyed = true; + + // 销毁自定义插件 + for (const plugin of this.plugins) { + plugin.destroy?.(); + } + this.plugins = []; + + // 清理事件处理器 + this.eventHandlers.clear(); + + // 清理右键菜单 + this.hideContextMenu(); + + // 清理链接 tooltip + this.hideLinkTooltipImmediate(); + this.linkTooltip?.remove(); + this.linkTooltip = null; + + // 清理搜索面板 + this.searchWrapper?.remove(); + this.searchWrapper = null; + this.searchPanel = null; + + // 销毁视图 + this.view.destroy(); + } + + /** + * 聚焦编辑器 + */ + focus(): void { + this.view.focus(); + } + + /** + * 处理编辑器点击事件 + * 用于处理点击空白区域时的聚焦 + */ + private handleEditorClick(view: EditorView, pos: number, event: MouseEvent): boolean { + const { state } = view; + const { doc } = state; + const editorRect = view.dom.getBoundingClientRect(); + const clickY = event.clientY; + + // 获取第一个和最后一个块节点的位置 + const firstChild = doc.firstChild; + const lastChild = doc.lastChild; + + if (!firstChild || !lastChild) return false; + + // 检查是否点击在第一个节点上方 + const firstNodePos = 0; + const firstNodeCoords = view.coordsAtPos(firstNodePos + 1); + if (clickY < firstNodeCoords.top) { + // 点击在第一个节点上方,聚焦到第一个字符 + const tr = state.tr.setSelection(TextSelection.create(state.doc, 1)); + view.dispatch(tr); + view.focus(); + return true; + } + + // 检查是否点击在最后一个节点下方 + const lastNodePos = doc.content.size - lastChild.nodeSize; + const lastNodeEndPos = doc.content.size; + const lastNodeCoords = view.coordsAtPos(lastNodeEndPos); + if (clickY > lastNodeCoords.bottom) { + // 点击在最后一个节点下方 + // 如果最后一个节点不是段落,在后面插入一个段落 + if (lastChild.type.name !== "paragraph") { + const paragraph = state.schema.nodes.paragraph.create(); + const tr = state.tr.insert(doc.content.size, paragraph); + tr.setSelection(TextSelection.create(tr.doc, doc.content.size + 1)); + view.dispatch(tr); + view.focus(); + return true; + } + } + + return false; + } + + // ============ 链接 Tooltip 和点击拦截 ============ + + private static readonly IS_MAC = + typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform); + private static readonly MOD_KEY = MilkupEditor.IS_MAC ? "⌘" : "Ctrl"; + + /** 从 DOM 元素向上查找最近的 标签 */ + private findLinkElement(target: HTMLElement): HTMLAnchorElement | null { + let el: HTMLElement | null = target; + const root = this.view.dom; + while (el && el !== root) { + if (el.tagName === "A") { + return el as HTMLAnchorElement; + } + el = el.parentElement; + } + return null; + } + + /** 从 元素获取链接 URL(优先从 ProseMirror 文档模型读取) */ + private getLinkHref(linkEl: HTMLAnchorElement): string { + // 1. 先尝试从 href 属性获取 + const href = linkEl.getAttribute("href"); + if (href) return href; + + // 2. href 为空时,从 ProseMirror 文档模型中获取 link mark 的 attrs + try { + const pos = this.view.posAtDOM(linkEl, 0); + if (pos >= 0) { + const $pos = this.view.state.doc.resolve(pos); + // 检查当前位置的 marks + const marks = $pos.marks(); + for (const mark of marks) { + if (mark.type.name === "link" && mark.attrs.href) { + return mark.attrs.href; + } + } + // 也检查该位置的节点 marks + const node = this.view.state.doc.nodeAt(pos); + if (node) { + for (const mark of node.marks) { + if (mark.type.name === "link" && mark.attrs.href) { + return mark.attrs.href; + } + } + } + } + } catch { + // posAtDOM 可能抛出异常 + } + + // 3. 最后从 DOM 中与此链接相邻的语法标记文本提取 URL + // 从 linkEl 向后查找紧邻的 ](url) 语法标记 + let sibling: Element | null = linkEl.nextElementSibling; + // 跳过同属一个链接的中间 元素 + while (sibling && sibling.tagName === "A") { + sibling = sibling.nextElementSibling; + } + if (sibling && sibling.matches('span.milkup-syntax[data-syntax-type="link"]')) { + const text = sibling.textContent || ""; + const m = text.match(/\]\((.+?)(?:\s+"[^"]*")?\)$/); + if (m && m[1]) return m[1]; + } + + return ""; + } + + /** 初始化链接处理 */ + private initLinkHandler(): void { + const dom = this.view.dom; + const container = dom.parentElement || dom; + + // 确保容器有定位上下文(tooltip 用 absolute 定位) + if (container !== dom && getComputedStyle(container).position === "static") { + container.style.position = "relative"; + } + + // 在 mousedown capture 阶段拦截链接上的 Ctrl+click, + // 阻止 ProseMirror 将其解释为节点选中,并直接打开链接 + dom.addEventListener( + "mousedown", + (e: Event) => { + const me = e as MouseEvent; + const modPressed = MilkupEditor.IS_MAC ? me.metaKey : me.ctrlKey; + if (!modPressed) return; + const linkEl = this.findLinkElement(me.target as HTMLElement); + if (!linkEl) return; + + me.preventDefault(); + me.stopPropagation(); + + let href = this.getLinkHref(linkEl); + if (href) { + // 补全协议前缀,避免被当作本地文件路径 + if (!/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(href)) { + href = "https://" + href; + } + const electronAPI = (window as any).electronAPI; + if (electronAPI?.openExternal) { + electronAPI.openExternal(href); + } else { + window.open(href, "_blank", "noopener,noreferrer"); + } + } + }, + true // capture 阶段 + ); + + // 用 capture 阶段拦截所有链接点击,阻止 Electron 内部导航 + dom.addEventListener( + "click", + (e: Event) => { + const me = e as MouseEvent; + const linkEl = this.findLinkElement(me.target as HTMLElement); + if (!linkEl) return; + + // 始终阻止 标签的默认跳转 + me.preventDefault(); + }, + true // capture 阶段 + ); + + // mousemove 检测链接 hover + dom.addEventListener("mousemove", (e: Event) => { + const me = e as MouseEvent; + const linkEl = this.findLinkElement(me.target as HTMLElement); + if (linkEl) { + const href = this.getLinkHref(linkEl); + if (href) { + this.showLinkTooltip(linkEl, href); + return; + } + } + if (this.linkTooltipCurrentLink) { + this.hideLinkTooltipDelayed(); + } + }); + + // 鼠标离开编辑器时隐藏 + dom.addEventListener("mouseleave", () => { + this.hideLinkTooltipDelayed(); + }); + + // 滚动时隐藏 + const scrollParent = dom.closest(".scrollView") || container; + if (scrollParent) { + scrollParent.addEventListener("scroll", () => this.hideLinkTooltipImmediate(), { + passive: true, + }); + } + } + + private showLinkTooltip(linkEl: HTMLAnchorElement, href: string): void { + if (this.linkTooltipHideTimer) { + clearTimeout(this.linkTooltipHideTimer); + this.linkTooltipHideTimer = null; + } + // 同一个链接不重复更新 + if (this.linkTooltipCurrentLink === linkEl && this.linkTooltip?.style.display === "block") { + return; + } + + const container = this.view.dom.parentElement || this.view.dom; + + if (!this.linkTooltip) { + this.linkTooltip = document.createElement("div"); + this.linkTooltip.className = "milkup-link-tooltip"; + container.appendChild(this.linkTooltip); + } + + const tip = this.linkTooltip; + const displayHref = href.length > 60 ? href.slice(0, 57) + "..." : href; + tip.textContent = `${displayHref} ${MilkupEditor.MOD_KEY}+左击访问`; + tip.style.display = "block"; + + const linkRect = linkEl.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + + let left = linkRect.left - containerRect.left; + const top = linkRect.bottom - containerRect.top + 4; + tip.style.top = `${top}px`; + tip.style.left = `${left}px`; + + // 下一帧修正右侧溢出 + requestAnimationFrame(() => { + if (!this.linkTooltip) return; + const tipRect = this.linkTooltip.getBoundingClientRect(); + const cr = container.getBoundingClientRect(); + if (tipRect.right > cr.right - 8) { + left = cr.right - cr.left - tipRect.width - 8; + this.linkTooltip.style.left = `${Math.max(0, left)}px`; + } + }); + + this.linkTooltipCurrentLink = linkEl; + } + + private hideLinkTooltipDelayed(): void { + if (this.linkTooltipHideTimer) clearTimeout(this.linkTooltipHideTimer); + this.linkTooltipHideTimer = setTimeout(() => { + if (this.linkTooltip) this.linkTooltip.style.display = "none"; + this.linkTooltipCurrentLink = null; + this.linkTooltipHideTimer = null; + }, 150); + } + + private hideLinkTooltipImmediate(): void { + if (this.linkTooltipHideTimer) { + clearTimeout(this.linkTooltipHideTimer); + this.linkTooltipHideTimer = null; + } + if (this.linkTooltip) this.linkTooltip.style.display = "none"; + this.linkTooltipCurrentLink = null; + } + + /** + * 处理右键菜单 + */ + private handleContextMenu(view: EditorView, event: MouseEvent): boolean { + // 检查是否在代码块内(代码块有自己的右键菜单) + const target = event.target as HTMLElement; + if ( + target.closest(".milkup-code-block-editor") || + target.closest(".milkup-code-block-header") + ) { + return false; // 让代码块处理 + } + + event.preventDefault(); + this.showContextMenu(event); + return true; + } + + /** + * 检测坐标位置是否在表格内 + */ + private isInsideTable(e: MouseEvent): boolean { + const coords = { left: e.clientX, top: e.clientY }; + const pos = this.view.posAtCoords(coords); + if (!pos) return false; + const $pos = this.view.state.doc.resolve(pos.pos); + for (let depth = $pos.depth; depth > 0; depth--) { + if ($pos.node(depth).type.name === "table") return true; + } + return false; + } + + /** + * 创建右键菜单分隔线 + */ + private createContextMenuSeparator(): HTMLElement { + const sep = document.createElement("div"); + sep.className = "milkup-context-menu-separator"; + return sep; + } + + /** + * 创建带子菜单的菜单项 + */ + private createContextMenuItemWithSubmenu( + label: string, + submenuBuilder: (container: HTMLElement) => void + ): HTMLElement { + const item = document.createElement("div"); + item.className = "milkup-context-menu-item has-submenu"; + item.textContent = label; + + const submenu = document.createElement("div"); + submenu.className = "milkup-context-menu-submenu"; + submenuBuilder(submenu); + item.appendChild(submenu); + + let hideTimer: ReturnType | null = null; + + const showSubmenu = () => { + if (hideTimer) { + clearTimeout(hideTimer); + hideTimer = null; + } + submenu.classList.add("visible"); + // 动态定位:先显示以获取尺寸,再调整 + requestAnimationFrame(() => { + const itemRect = item.getBoundingClientRect(); + const subRect = submenu.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + + // 水平:优先右侧,溢出则左侧 + let left = itemRect.right; + if (left + subRect.width > vw - 8) { + left = itemRect.left - subRect.width; + } + if (left < 8) left = 8; + + // 垂直:与菜单项顶部对齐,溢出则上移 + let top = itemRect.top - 4; + if (top + subRect.height > vh - 8) { + top = vh - subRect.height - 8; + } + if (top < 8) top = 8; + + submenu.style.left = `${left}px`; + submenu.style.top = `${top}px`; + }); + }; + + const hideSubmenu = () => { + hideTimer = setTimeout(() => { + submenu.classList.remove("visible"); + }, 150); + }; + + item.addEventListener("mouseenter", showSubmenu); + item.addEventListener("mouseleave", hideSubmenu); + submenu.addEventListener("mouseenter", () => { + if (hideTimer) { + clearTimeout(hideTimer); + hideTimer = null; + } + }); + submenu.addEventListener("mouseleave", hideSubmenu); + + return item; + } + + /** + * 创建表格网格选择器 + */ + private buildTableGridPicker(container: HTMLElement): void { + const picker = document.createElement("div"); + picker.className = "milkup-table-grid-picker"; + + const gridContainer = document.createElement("div"); + gridContainer.className = "grid-container"; + + const label = document.createElement("div"); + label.className = "grid-label"; + label.textContent = ""; + + const maxRows = 8; + const maxCols = 8; + const cells: HTMLElement[][] = []; + + for (let r = 0; r < maxRows; r++) { + cells[r] = []; + for (let c = 0; c < maxCols; c++) { + const cell = document.createElement("div"); + cell.className = "grid-cell"; + cell.dataset.row = String(r); + cell.dataset.col = String(c); + gridContainer.appendChild(cell); + cells[r][c] = cell; + } + } + + const updateHighlight = (hoverRow: number, hoverCol: number) => { + for (let r = 0; r < maxRows; r++) { + for (let c = 0; c < maxCols; c++) { + cells[r][c].classList.toggle("active", r <= hoverRow && c <= hoverCol); + } + } + label.textContent = `${hoverRow + 1} × ${hoverCol + 1} 表格`; + }; + + gridContainer.addEventListener("mouseover", (e) => { + const target = e.target as HTMLElement; + if (target.classList.contains("grid-cell")) { + const r = parseInt(target.dataset.row!, 10); + const c = parseInt(target.dataset.col!, 10); + updateHighlight(r, c); + } + }); + + gridContainer.addEventListener("click", (e) => { + const target = e.target as HTMLElement; + if (target.classList.contains("grid-cell")) { + const rows = parseInt(target.dataset.row!, 10) + 1; + const cols = parseInt(target.dataset.col!, 10) + 1; + this.view.focus(); + insertTable(rows, cols)(this.view.state, this.view.dispatch.bind(this.view)); + this.hideContextMenu(); + } + }); + + picker.appendChild(gridContainer); + picker.appendChild(label); + container.appendChild(picker); + } + + /** + * 显示右键菜单 + */ + private async showContextMenu(e: MouseEvent): Promise { + // 移除已存在的右键菜单 + this.hideContextMenu(); + + const menu = document.createElement("div"); + menu.className = "milkup-context-menu"; + + // 检查是否有选区 + const { selection } = this.view.state; + const hasSelection = !selection.empty; + + // 检查剪贴板是否有内容(文本或图片) + let hasClipboardContent = true; // 默认启用粘贴 + try { + const items = await navigator.clipboard.read(); + hasClipboardContent = items.length > 0; + } catch { + // 如果 read() 不支持,尝试 readText() + try { + const text = await navigator.clipboard.readText(); + hasClipboardContent = text.length > 0; + } catch { + hasClipboardContent = true; // 默认启用粘贴 + } + } + + // 异步操作后编辑器可能已被销毁(如 HMR),直接返回 + if (this._destroyed) return; + + // 复制 + const copyItem = this.createContextMenuItem("复制", !hasSelection, () => { + const slice = this.view.state.selection.content(); + const text = this.serializeSliceToMarkdown(slice); + navigator.clipboard.writeText(text); + this.hideContextMenu(); + }); + menu.appendChild(copyItem); + + // 剪切 + const cutItem = this.createContextMenuItem("剪切", !hasSelection, () => { + const slice = this.view.state.selection.content(); + const text = this.serializeSliceToMarkdown(slice); + navigator.clipboard.writeText(text); + const tr = this.view.state.tr.deleteSelection(); + this.view.dispatch(tr); + this.hideContextMenu(); + }); + menu.appendChild(cutItem); + + // 粘贴 - 使用 Clipboard API 读取内容并手动处理 + const pasteItem = this.createContextMenuItem("粘贴", !hasClipboardContent, async () => { + this.hideContextMenu(); + this.view.focus(); + await this.handlePasteFromClipboard(); + }); + menu.appendChild(pasteItem); + + // 检测是否在表格内 + const inTable = this.isInsideTable(e); + + if (inTable) { + // 表格内右键 — 追加表格操作项 + menu.appendChild(this.createContextMenuSeparator()); + + menu.appendChild( + this.createContextMenuItem("向上插入行", false, () => { + this.view.focus(); + addRowBefore(this.view.state, this.view.dispatch.bind(this.view)); + this.hideContextMenu(); + }) + ); + menu.appendChild( + this.createContextMenuItem("向下插入行", false, () => { + this.view.focus(); + addRowAfter(this.view.state, this.view.dispatch.bind(this.view)); + this.hideContextMenu(); + }) + ); + menu.appendChild( + this.createContextMenuItem("在末尾添加行", false, () => { + this.view.focus(); + addRowAtEnd(this.view.state, this.view.dispatch.bind(this.view)); + this.hideContextMenu(); + }) + ); + + menu.appendChild(this.createContextMenuSeparator()); + + menu.appendChild( + this.createContextMenuItem("向左插入列", false, () => { + this.view.focus(); + addColumnBefore(this.view.state, this.view.dispatch.bind(this.view)); + this.hideContextMenu(); + }) + ); + menu.appendChild( + this.createContextMenuItem("向右插入列", false, () => { + this.view.focus(); + addColumnAfter(this.view.state, this.view.dispatch.bind(this.view)); + this.hideContextMenu(); + }) + ); + menu.appendChild( + this.createContextMenuItem("在末尾添加列", false, () => { + this.view.focus(); + addColumnAtEnd(this.view.state, this.view.dispatch.bind(this.view)); + this.hideContextMenu(); + }) + ); + + menu.appendChild(this.createContextMenuSeparator()); + + menu.appendChild( + this.createContextMenuItem("删除当前行", false, () => { + this.view.focus(); + deleteRow(this.view.state, this.view.dispatch.bind(this.view)); + this.hideContextMenu(); + }) + ); + menu.appendChild( + this.createContextMenuItem("删除当前列", false, () => { + this.view.focus(); + deleteColumn(this.view.state, this.view.dispatch.bind(this.view)); + this.hideContextMenu(); + }) + ); + } else { + // 非表格区域 — 追加"插入"子菜单和"插入表格" + menu.appendChild(this.createContextMenuSeparator()); + menu.appendChild( + this.createContextMenuItemWithSubmenu("插入", (submenu) => { + this.buildInsertSubmenu(submenu); + }) + ); + menu.appendChild( + this.createContextMenuItemWithSubmenu("插入表格", (submenu) => { + this.buildTableGridPicker(submenu); + }) + ); + } + + // 定位菜单 + menu.style.left = `${e.clientX}px`; + menu.style.top = `${e.clientY}px`; + + document.body.appendChild(menu); + this.contextMenu = menu; + + // 调整位置,确保菜单在视口内 + const menuRect = menu.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + if (menuRect.right > viewportWidth) { + menu.style.left = `${viewportWidth - menuRect.width - 8}px`; + } + if (menuRect.bottom > viewportHeight) { + menu.style.top = `${viewportHeight - menuRect.height - 8}px`; + } + + // 点击外部关闭 + const closeHandler = (event: MouseEvent) => { + if (!menu.contains(event.target as Node)) { + this.hideContextMenu(); + document.removeEventListener("click", closeHandler); + } + }; + setTimeout(() => { + document.addEventListener("click", closeHandler); + }, 0); + } + + /** + * 创建右键菜单项 + */ + private createContextMenuItem( + label: string, + disabled: boolean, + onClick: () => void + ): HTMLElement { + const item = document.createElement("div"); + item.className = "milkup-context-menu-item"; + if (disabled) { + item.classList.add("disabled"); + } + item.textContent = label; + + if (!disabled) { + item.addEventListener("click", (e) => { + e.stopPropagation(); + onClick(); + }); + } + + return item; + } + + /** + * 获取某个动作当前绑定的快捷键(已格式化为显示文本) + */ + private getShortcutDisplay(actionId: ShortcutActionId): string { + const customMap = this.getCustomKeyMap(); + const def = DEFAULT_SHORTCUTS.find((s) => s.id === actionId); + if (!def) return ""; + const key = customMap[actionId] || def.defaultKey; + return formatShortcutDisplay(key); + } + + /** + * 创建带快捷键提示的菜单项 + */ + private createContextMenuItemWithShortcut( + label: string, + shortcutActionId: ShortcutActionId | null, + disabled: boolean, + onClick: () => void + ): HTMLElement { + const item = document.createElement("div"); + item.className = "milkup-context-menu-item"; + if (disabled) item.classList.add("disabled"); + + const labelSpan = document.createElement("span"); + labelSpan.textContent = label; + item.appendChild(labelSpan); + + if (shortcutActionId) { + const shortcut = this.getShortcutDisplay(shortcutActionId); + if (shortcut) { + const shortcutSpan = document.createElement("span"); + shortcutSpan.className = "milkup-context-menu-shortcut"; + shortcutSpan.textContent = shortcut; + item.appendChild(shortcutSpan); + } + } + + if (!disabled) { + item.addEventListener("click", (e) => { + e.stopPropagation(); + onClick(); + }); + } + + return item; + } + + /** + * 构建"插入"子菜单内容 + * 使用与快捷键相同的增强命令(buildActionCommandMap) + */ + private buildInsertSubmenu(container: HTMLElement): void { + const commandMap = buildActionCommandMap(this.schema); + const dispatch = this.view.dispatch.bind(this.view); + const execAndClose = (actionId: ShortcutActionId) => { + this.view.focus(); + const cmd = commandMap[actionId]; + if (cmd) cmd(this.view.state, dispatch); + this.hideContextMenu(); + }; + + type MenuItem = { label: string; id: ShortcutActionId }; + + const inlineItems: MenuItem[] = [ + { label: "粗体", id: "toggleStrong" }, + { label: "斜体", id: "toggleEmphasis" }, + { label: "行内代码", id: "toggleCodeInline" }, + { label: "删除线", id: "toggleStrikethrough" }, + { label: "高亮", id: "toggleHighlight" }, + ]; + + for (const item of inlineItems) { + container.appendChild( + this.createContextMenuItemWithShortcut(item.label, item.id, false, () => + execAndClose(item.id) + ) + ); + } + + container.appendChild(this.createContextMenuSeparator()); + + const blockItems: MenuItem[] = [ + { label: "一级标题", id: "setHeading1" }, + { label: "二级标题", id: "setHeading2" }, + { label: "三级标题", id: "setHeading3" }, + { label: "段落", id: "setParagraph" }, + { label: "代码块", id: "setCodeBlock" }, + { label: "引用", id: "wrapInBlockquote" }, + { label: "无序列表", id: "wrapInBulletList" }, + { label: "有序列表", id: "wrapInOrderedList" }, + ]; + + for (const item of blockItems) { + container.appendChild( + this.createContextMenuItemWithShortcut(item.label, item.id, false, () => + execAndClose(item.id) + ) + ); + } + + container.appendChild(this.createContextMenuSeparator()); + + const insertItems: MenuItem[] = [ + { label: "分割线", id: "insertHorizontalRule" }, + { label: "数学公式", id: "insertMathBlock" }, + ]; + + for (const item of insertItems) { + container.appendChild( + this.createContextMenuItemWithShortcut(item.label, item.id, false, () => + execAndClose(item.id) + ) + ); + } + } + + /** 创建搜索面板 DOM */ + private createSearchPanel(container: HTMLElement): void { + // SVG 图标工厂 + const svgIcon = (path: string, vb = "0 0 16 16") => { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("viewBox", vb); + svg.innerHTML = path; + return svg; + }; + + // 创建按钮的辅助函数(阻止 mousedown 防止编辑器失焦) + const makeBtn = (cls: string, title: string, icon: SVGSVGElement, onClick: () => void) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = cls; + btn.title = title; + btn.appendChild(icon); + btn.addEventListener("mousedown", (e) => e.preventDefault()); + btn.addEventListener("click", onClick); + return btn; + }; + + // 外层 wrapper(sticky 定位) + const wrapper = document.createElement("div"); + wrapper.className = "milkup-search-wrapper"; + + const panel = document.createElement("div"); + panel.className = "milkup-search-panel"; + + // ---- 搜索行 ---- + const searchRow = document.createElement("div"); + searchRow.className = "milkup-search-row"; + + // 展开/收起替换 ▶/▼ + const toggleIcon = svgIcon(''); + const toggleBtn = makeBtn("toggle-replace", "切换替换", toggleIcon, () => + this.toggleReplaceRow() + ); + + const searchInput = document.createElement("input"); + searchInput.type = "text"; + searchInput.className = "search-input"; + searchInput.placeholder = "搜索"; + searchInput.addEventListener("input", () => this.onSearchInput()); + searchInput.addEventListener("keydown", (e) => { + if (e.key === "Enter" && e.shiftKey) { + e.preventDefault(); + findPrev(this.view); + this.updateMatchCount(); + } else if (e.key === "Enter") { + e.preventDefault(); + findNext(this.view); + this.updateMatchCount(); + } else if (e.key === "Escape") { + e.preventDefault(); + this.closeSearch(); + } + }); + + // Aa 区分大小写 + const caseIcon = svgIcon( + 'Aa' + ); + const caseBtn = makeBtn("case-sensitive", "区分大小写", caseIcon, () => { + this.searchCaseSensitive = !this.searchCaseSensitive; + caseBtn.classList.toggle("active", this.searchCaseSensitive); + this.onSearchInput(); + }); + + // Ab| 全字匹配 + const wordIcon = svgIcon( + 'Ab' + ); + const wordBtn = makeBtn("whole-word", "全字匹配", wordIcon, () => { + this.searchWholeWord = !this.searchWholeWord; + wordBtn.classList.toggle("active", this.searchWholeWord); + this.onSearchInput(); + }); + + // .* 正则 + const regexIcon = svgIcon( + '.*' + ); + const regexBtn = makeBtn("use-regex", "正则表达式", regexIcon, () => { + this.searchUseRegex = !this.searchUseRegex; + regexBtn.classList.toggle("active", this.searchUseRegex); + this.onSearchInput(); + }); + + const matchCount = document.createElement("span"); + matchCount.className = "match-count"; + + // ↑ 上一个 + const prevIcon = svgIcon(''); + const prevBtn = makeBtn("prev-match", "上一个匹配 (Shift+Enter)", prevIcon, () => { + findPrev(this.view); + this.updateMatchCount(); + }); + + // ↓ 下一个 + const nextIcon = svgIcon(''); + const nextBtn = makeBtn("next-match", "下一个匹配 (Enter)", nextIcon, () => { + findNext(this.view); + this.updateMatchCount(); + }); + + // 选区内搜索 + const selIcon = svgIcon(''); + const selBtn = makeBtn("search-in-selection", "在选区内搜索", selIcon, () => { + this.searchInSelection = !this.searchInSelection; + selBtn.classList.toggle("active", this.searchInSelection); + if (this.searchInSelection) { + const { from, to } = this.view.state.selection; + this.searchSelectionRange = from !== to ? { from, to } : null; + } else { + this.searchSelectionRange = null; + } + this.onSearchInput(); + }); + + // × 关闭 + const closeIcon = svgIcon( + '' + ); + const closeBtn = makeBtn("close-search", "关闭 (Escape)", closeIcon, () => this.closeSearch()); + + searchRow.append( + toggleBtn, + searchInput, + caseBtn, + wordBtn, + regexBtn, + matchCount, + prevBtn, + nextBtn, + selBtn, + closeBtn + ); + + // ---- 替换行 ---- + const replaceRow = document.createElement("div"); + replaceRow.className = "milkup-replace-row hidden"; + + const replaceInput = document.createElement("input"); + replaceInput.type = "text"; + replaceInput.className = "replace-input"; + replaceInput.placeholder = "替换"; + replaceInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + replaceMatch(this.view, replaceInput.value); + this.onSearchInput(); + } else if (e.key === "Escape") { + e.preventDefault(); + this.closeSearch(); + } + }); + + const replaceOneBtn = document.createElement("button"); + replaceOneBtn.type = "button"; + replaceOneBtn.className = "replace-btn"; + replaceOneBtn.textContent = "替换"; + replaceOneBtn.addEventListener("mousedown", (e) => e.preventDefault()); + replaceOneBtn.addEventListener("click", () => { + replaceMatch(this.view, replaceInput.value); + this.onSearchInput(); + }); + + const replaceAllBtn = document.createElement("button"); + replaceAllBtn.type = "button"; + replaceAllBtn.className = "replace-btn"; + replaceAllBtn.textContent = "全部替换"; + replaceAllBtn.addEventListener("mousedown", (e) => e.preventDefault()); + replaceAllBtn.addEventListener("click", () => { + replaceAll(this.view, replaceInput.value); + this.onSearchInput(); + }); + + replaceRow.append(replaceInput, replaceOneBtn, replaceAllBtn); + + panel.append(searchRow, replaceRow); + wrapper.appendChild(panel); + + // 挂载到最近的滚动容器(overflow-y: auto/scroll 的祖先) + // 这样 sticky 定位才能正确工作 + const scrollParent = this.findScrollParent(container) || container; + scrollParent.insertBefore(wrapper, scrollParent.firstChild); + + this.searchWrapper = wrapper; + this.searchPanel = panel; + this.searchInput = searchInput; + this.replaceRow = replaceRow; + this.matchCountSpan = matchCount; + } + + /** 打开搜索面板 */ + openSearch(showReplace: boolean): void { + if (!this.searchWrapper || !this.searchInput) return; + + this.searchWrapper.classList.add("visible"); + + if (showReplace) { + this.replaceRow?.classList.remove("hidden"); + const toggleBtn = this.searchPanel?.querySelector(".toggle-replace svg"); + if (toggleBtn) toggleBtn.innerHTML = ''; + } + + // 如有选中文本,填入搜索框 + const { from, to } = this.view.state.selection; + if (from !== to) { + const selectedText = this.view.state.doc.textBetween(from, to, "\n"); + if (selectedText && !selectedText.includes("\n")) { + this.searchInput.value = selectedText; + } + } + + this.searchInput.focus(); + this.searchInput.select(); + this.onSearchInput(); + } + + /** 关闭搜索面板 */ + closeSearch(): void { + if (!this.searchWrapper) return; + this.searchWrapper.classList.remove("visible"); + clearSearch(this.view); + this.view.focus(); + } + + /** 切换替换行 */ + private toggleReplaceRow(): void { + if (!this.replaceRow || !this.searchPanel) return; + const hidden = this.replaceRow.classList.toggle("hidden"); + const toggleSvg = this.searchPanel.querySelector(".toggle-replace svg"); + if (toggleSvg) { + toggleSvg.innerHTML = hidden ? '' : ''; + } + } + + /** 搜索输入变化 */ + private onSearchInput(): void { + if (!this.searchInput) return; + const query = this.searchInput.value; + const options: SearchOptions = { + caseSensitive: this.searchCaseSensitive, + wholeWord: this.searchWholeWord, + useRegex: this.searchUseRegex, + searchInSelection: this.searchInSelection, + selectionRange: this.searchSelectionRange, + }; + updateSearch(this.view, query, options); + this.updateMatchCount(); + } + + /** 更新匹配计数显示 */ + private updateMatchCount(): void { + if (!this.matchCountSpan) return; + const state = searchPluginKey.getState(this.view.state); + if (!state || state.matches.length === 0) { + this.matchCountSpan.textContent = this.searchInput?.value ? "无结果" : ""; + } else { + this.matchCountSpan.textContent = `${state.currentIndex + 1}/${state.matches.length}`; + } + } + + /** 查找最近的可滚动祖先元素 */ + private findScrollParent(el: HTMLElement): HTMLElement | null { + let parent = el.parentElement; + while (parent) { + const style = getComputedStyle(parent); + if (style.overflowY === "auto" || style.overflowY === "scroll") { + return parent; + } + parent = parent.parentElement; + } + return null; + } + + private hideContextMenu(): void { + if (this.contextMenu) { + this.contextMenu.remove(); + this.contextMenu = null; + } + // 移除其他可能存在的右键菜单 + document.querySelectorAll(".milkup-context-menu").forEach((el) => el.remove()); + } + + /** + * 从剪贴板粘贴内容 + */ + private async handlePasteFromClipboard(): Promise { + try { + // 尝试读取剪贴板内容 + const items = await navigator.clipboard.read(); + + for (const item of items) { + // 检查是否有图片 + const imageType = item.types.find((type) => type.startsWith("image/")); + if (imageType) { + const blob = await item.getType(imageType); + const file = new File([blob], "pasted-image.png", { type: imageType }); + await this.insertImageFromFile(file); + return; + } + + // 检查是否有文本 + if (item.types.includes("text/plain")) { + const blob = await item.getType("text/plain"); + const text = await blob.text(); + if (text) { + const tr = this.view.state.tr.insertText(text); + this.view.dispatch(tr); + } + return; + } + } + } catch { + // 如果 read() 失败,尝试 readText() + try { + const text = await navigator.clipboard.readText(); + if (text) { + const tr = this.view.state.tr.insertText(text); + this.view.dispatch(tr); + } + } catch { + console.warn("无法访问剪贴板"); + } + } + } + + /** + * 从文件插入图片 + */ + private async insertImageFromFile(file: File): Promise { + // 获取图片粘贴方式 + const method: ImagePasteMethod = + this.config.pasteConfig?.getImagePasteMethod?.() || + (localStorage.getItem("pasteMethod") as ImagePasteMethod) || + "base64"; + + let src: string; + + try { + switch (method) { + case "base64": + src = await fileToBase64(file); + break; + + case "remote": + if (this.config.pasteConfig?.imageUploader) { + src = await this.config.pasteConfig.imageUploader(file); + } else { + console.warn("Image uploader not configured, falling back to base64"); + src = await fileToBase64(file); + } + break; + + case "local": + if (this.config.pasteConfig?.localImageSaver) { + src = await this.config.pasteConfig.localImageSaver(file); + } else { + // 尝试使用 Electron API + src = await saveImageLocally(file); + } + break; + + default: + src = await fileToBase64(file); + } + } catch (error) { + console.error("Failed to process image:", error); + // 出错时回退到 base64 + src = await fileToBase64(file); + } + + // 源码模式下:创建包含 Markdown 文本的段落 + if (this.isSourceViewEnabled()) { + const alt = file.name; + const markdownText = `![${alt}](${src})`; + const paragraph = this.schema.nodes.paragraph.create( + { imageAttrs: { src, alt, title: "" } }, + this.schema.text(markdownText) + ); + const { $from } = this.view.state.selection; + const tr = this.view.state.tr.insert($from.pos, paragraph); + this.view.dispatch(tr); + return; + } + + // 创建图片节点 + const imageNode = this.schema.nodes.image?.createAndFill({ + src, + alt: file.name, + title: "", + }); + + if (imageNode) { + const { $from } = this.view.state.selection; + const tr = this.view.state.tr.insert($from.pos, imageNode); + this.view.dispatch(tr); + } + } + + /** + * 将 Slice 序列化为 Markdown 文本 + */ + private serializeSliceToMarkdown(slice: Slice): string { + const fragment = slice.content; + if (fragment.childCount === 0) return ""; + + // 检查是否全部为行内节点(段落内部分选区) + let allInline = true; + fragment.forEach((node) => { + if (!node.isInline) allInline = false; + }); + + if (allInline) { + const para = this.schema.nodes.paragraph.create(null, fragment); + const doc = this.schema.topNodeType.create(null, para); + return serializeMarkdown(doc).trim(); + } + + const doc = this.schema.topNodeType.create(null, fragment); + return serializeMarkdown(doc).trimEnd(); + } + + /** + * 获取光标位置 + */ + getCursorOffset(): number { + return this.view.state.selection.head; + } + + /** + * 设置光标位置 + */ + setCursorOffset(offset: number): void { + const { doc } = this.view.state; + const pos = Math.min(Math.max(0, offset), doc.content.size); + const $pos = doc.resolve(pos); + const selection = Selection.near($pos); + const tr = this.view.state.tr.setSelection(selection); + this.view.dispatch(tr); + } + + /** + * 切换源码视图 + */ + toggleSourceView(): void { + toggleSourceView(this.view.state, this.view.dispatch.bind(this.view)); + this.config.sourceView = !this.config.sourceView; + } + + /** + * 获取源码视图状态 + */ + isSourceViewEnabled(): boolean { + const state = decorationPluginKey.getState(this.view.state); + return state?.sourceView ?? false; + } + + /** + * 注册事件处理器 + */ + on(event: string, handler: Function): void { + if (!this.eventHandlers.has(event)) { + this.eventHandlers.set(event, new Set()); + } + this.eventHandlers.get(event)!.add(handler); + } + + /** + * 移除事件处理器 + */ + off(event: string, handler: Function): void { + this.eventHandlers.get(event)?.delete(handler); + } + + /** + * 触发事件 + */ + private emit(event: string, data: any): void { + this.eventHandlers.get(event)?.forEach((handler) => handler(data)); + } + + /** + * 执行命令 + */ + command(name: string, ...args: any[]): boolean { + // 可以在这里添加自定义命令 + return false; + } + + /** + * 获取 ProseMirror 状态 + */ + getState(): EditorState { + return this.view.state; + } + + /** + * 获取文档 + */ + getDoc(): Node { + return this.view.state.doc; + } + + /** + * 获取 Schema + */ + getSchema(): Schema { + return this.schema; + } + + /** + * 获取用户自定义快捷键映射 + * 从 localStorage 读取 + */ + private getCustomKeyMap(): ShortcutKeyMap { + try { + const raw = localStorage.getItem("milkup-config"); + if (raw) { + const config = JSON.parse(raw); + return config.shortcuts || {}; + } + } catch { + // ignore + } + return {}; + } +} + +/** + * 创建 Milkup 编辑器 + */ +export function createMilkupEditor(container: HTMLElement, config?: MilkupConfig): MilkupEditor { + return new MilkupEditor(container, config); +} diff --git a/src/core/index.ts b/src/core/index.ts new file mode 100644 index 0000000..4a1bd97 --- /dev/null +++ b/src/core/index.ts @@ -0,0 +1,166 @@ +/** + * Milkup - Markdown 编辑器内核 + * + * 基于 ProseMirror 的即时渲染 Markdown 编辑器 + * 实现类似 Typora 的编辑体验 + */ + +// 编辑器主类 +export { MilkupEditor, createMilkupEditor } from "./editor"; + +// Schema +export { milkupSchema, type MilkupSchema } from "./schema"; + +// 解析器 +export { MarkdownParser, parseMarkdown, defaultParser, type ParseResult } from "./parser"; + +// 序列化器 +export { + MarkdownSerializer, + serializeMarkdown, + defaultSerializer, + type SerializeOptions, +} from "./serializer"; + +// 插件 +export { + createInstantRenderPlugin, + instantRenderPluginKey, + enableInstantRender, + disableInstantRender, + toggleInstantRender, + getInstantRenderState, + getActiveRegionsFromState, + type InstantRenderState, + type InstantRenderConfig, +} from "./plugins/instant-render"; + +export { createInputRulesPlugin } from "./plugins/input-rules"; + +export { createSyntaxFixerPlugin, syntaxFixerPluginKey } from "./plugins/syntax-fixer"; + +export { createHeadingSyncPlugin, headingSyncPluginKey } from "./plugins/heading-sync"; + +export { createSyntaxDetectorPlugin, syntaxDetectorPluginKey } from "./plugins/syntax-detector"; + +export { + createPastePlugin, + pastePluginKey, + type ImagePasteMethod, + type ImageUploader, + type LocalImageSaver, + type PastePluginConfig, +} from "./plugins/paste"; + +export { + createAICompletionPlugin, + aiCompletionPluginKey, + type AICompletionConfig, + type AICompletionContext, + type AICompletionState, +} from "./plugins/ai-completion"; + +export { createPlaceholderPlugin, placeholderPluginKey } from "./plugins/placeholder"; + +export { createLineNumbersPlugin, lineNumbersPluginKey } from "./plugins/line-numbers"; + +// 快捷键 +export { + createKeymapPlugin, + createListKeymap, + createDynamicKeymapPlugin, + buildActionCommandMap, + createEnhancedToggleMark, + DEFAULT_SHORTCUTS, + CATEGORY_LABELS, + type KeymapConfig, + type ShortcutActionId, + type ShortcutCategory, + type ShortcutDefinition, + type ShortcutKeyMap, +} from "./keymap"; + +// 装饰系统 +export { + createDecorationPlugin, + decorationPluginKey, + toggleSourceView, + setSourceView, + sourceViewManager, + findSyntaxMarkerRegions, + findMathInlineRegions, + findSyntaxRegions, + findMarkRegions, + getActiveRegions, + computeDecorations, + SYNTAX_CLASSES, + type DecorationPluginState, + type SyntaxRegion, + type MarkRegion, + type SyntaxMarkerRegion, + type MathInlineRegion, + type SourceViewListener, +} from "./decorations"; + +// NodeView +export { + CodeBlockView, + createCodeBlockNodeView, + setGlobalMermaidDefaultMode, + MathBlockView, + createMathBlockNodeView, + renderInlineMath, + isKaTeXAvailable, + preloadKaTeX, + updateAllMathBlocks, + ImageView, + createImageNodeView, + updateAllImages, + BulletListView, + OrderedListView, + ListItemView, + createBulletListNodeView, + createOrderedListNodeView, + createListItemNodeView, + updateAllLists, +} from "./nodeviews"; + +// 命令 +export { + commands, + toggleStrong, + toggleEmphasis, + toggleCodeInline, + toggleStrikethrough, + toggleHighlight, + setHeading, + setParagraph, + setCodeBlock, + wrapInBlockquote, + wrapInBulletList, + wrapInOrderedList, + liftBlock, + insertHorizontalRule, + insertImage, + insertLink, + removeLink, + insertTable, + insertMathBlock, + insertContainer, +} from "./commands"; + +// 类型 +export type { + MilkupConfig, + MilkupEditor as IMilkupEditor, + MilkupPlugin, + ImagePathProcessor, + SyntaxType, + SyntaxMarker, + PositionMap, + DecorationState, + MilkupEventType, + MilkupEventHandler, + ChangeEventData, + SelectionChangeEventData, +} from "./types"; diff --git a/src/core/keymap/action-commands.ts b/src/core/keymap/action-commands.ts new file mode 100644 index 0000000..9347a58 --- /dev/null +++ b/src/core/keymap/action-commands.ts @@ -0,0 +1,88 @@ +/** + * ShortcutActionId → ProseMirror Command 映射 + */ + +import { Schema } from "prosemirror-model"; +import { EditorState, Transaction } from "prosemirror-state"; +import { setBlockType, wrapIn, lift } from "prosemirror-commands"; +import { undo, redo } from "prosemirror-history"; +import { toggleSourceView, decorationPluginKey } from "../decorations"; +import { + createEnhancedToggleMark, + createSetHeadingCommand, + createSetParagraphCommand, +} from "../commands/enhanced-commands"; +import { + insertHorizontalRule, + insertTable, + insertMathBlock, + wrapInBulletList, + wrapInOrderedList, +} from "../commands"; +import type { ShortcutActionId } from "./types"; + +type Command = (state: EditorState, dispatch?: (tr: Transaction) => void) => boolean; + +/** + * 构建 ActionId → Command 映射 + */ +export function buildActionCommandMap(schema: Schema): Record { + const map = {} as Record; + + // 内联格式 - 使用增强命令(插入 Markdown 语法文本) + if (schema.marks.strong) { + map.toggleStrong = createEnhancedToggleMark(schema.marks.strong); + } + if (schema.marks.emphasis) { + map.toggleEmphasis = createEnhancedToggleMark(schema.marks.emphasis); + } + if (schema.marks.code_inline) { + map.toggleCodeInline = createEnhancedToggleMark(schema.marks.code_inline); + } + if (schema.marks.strikethrough) { + map.toggleStrikethrough = createEnhancedToggleMark(schema.marks.strikethrough); + } + if (schema.marks.highlight) { + map.toggleHighlight = createEnhancedToggleMark(schema.marks.highlight); + } + + // 块级格式 - 标题使用增强命令(插入 # 语法标记) + for (let level = 1; level <= 6; level++) { + map[`setHeading${level}` as ShortcutActionId] = createSetHeadingCommand(level); + } + map.setParagraph = createSetParagraphCommand(); + + if (schema.nodes.code_block) { + map.setCodeBlock = setBlockType(schema.nodes.code_block); + } + if (schema.nodes.blockquote) { + map.wrapInBlockquote = wrapIn(schema.nodes.blockquote); + } + map.wrapInBulletList = wrapInBulletList; + map.wrapInOrderedList = wrapInOrderedList; + map.liftBlock = lift; + + // 插入 + map.insertHorizontalRule = (state: EditorState, dispatch?: (tr: Transaction) => void) => { + const decoState = decorationPluginKey.getState(state); + if (decoState?.sourceView) { + // 源码模式:插入 --- 段落(与 source-view-transform 的 transformHrToParagraph 一致) + if (dispatch) { + const para = schema.nodes.paragraph.create({ hrSource: true }, schema.text("---")); + const tr = state.tr.replaceSelectionWith(para); + dispatch(tr.scrollIntoView()); + } + return true; + } + return insertHorizontalRule(state, dispatch); + }; + map.insertTable = insertTable(); + map.insertMathBlock = insertMathBlock(); + + // 编辑器操作 + map.toggleSourceView = toggleSourceView; + map.undo = undo; + map.redo = redo; + + return map; +} diff --git a/src/core/keymap/dynamic-keymap.ts b/src/core/keymap/dynamic-keymap.ts new file mode 100644 index 0000000..8357c4d --- /dev/null +++ b/src/core/keymap/dynamic-keymap.ts @@ -0,0 +1,62 @@ +/** + * 动态 Keymap 插件 + * + * 使用 ProseMirror 的 keydownHandler 处理按键事件, + * 支持动态更新快捷键绑定。通过缓存机制避免重复构建。 + */ + +import { Plugin } from "prosemirror-state"; +import { Schema } from "prosemirror-model"; +import { keydownHandler } from "prosemirror-keymap"; +import { buildActionCommandMap } from "./action-commands"; +import { DEFAULT_SHORTCUTS } from "./shortcut-registry"; +import type { ShortcutKeyMap } from "./types"; + +/** + * 创建动态 Keymap 插件 + * + * @param schema - ProseMirror Schema + * @param getCustomKeyMap - 获取用户自定义快捷键映射的回调 + */ +export function createDynamicKeymapPlugin( + schema: Schema, + getCustomKeyMap: () => ShortcutKeyMap +): Plugin { + const commandMap = buildActionCommandMap(schema); + + // 缓存:配置未变化时复用 handler + let lastConfigStr = ""; + let cachedHandler: ((view: any, event: KeyboardEvent) => boolean) | null = null; + + function getHandler() { + const customMap = getCustomKeyMap(); + const configStr = JSON.stringify(customMap); + + if (configStr !== lastConfigStr || !cachedHandler) { + lastConfigStr = configStr; + + // 构建 ProseMirror 格式的 key → command 绑定 + const bindings: Record = {}; + for (const shortcut of DEFAULT_SHORTCUTS) { + const boundKey = customMap[shortcut.id] || shortcut.defaultKey; + const command = commandMap[shortcut.id]; + if (command) { + bindings[boundKey] = command; + } + } + + // 使用 ProseMirror 的 keydownHandler,确保与原生 keymap 完全一致的按键匹配 + cachedHandler = keydownHandler(bindings); + } + + return cachedHandler!; + } + + return new Plugin({ + props: { + handleKeyDown(view, event) { + return getHandler()(view, event); + }, + }, + }); +} diff --git a/src/core/keymap/index.ts b/src/core/keymap/index.ts new file mode 100644 index 0000000..599934c --- /dev/null +++ b/src/core/keymap/index.ts @@ -0,0 +1,302 @@ +/** + * Milkup 快捷键插件 + * + * 定义编辑器快捷键 + * 注意:基础快捷键和 Markdown 快捷键已由动态 keymap 插件接管, + * 此模块只保留块级 Enter 处理和列表快捷键(不可自定义)。 + */ + +import { keymap } from "prosemirror-keymap"; +import { Plugin, TextSelection } from "prosemirror-state"; +import { Schema } from "prosemirror-model"; +import { lift, selectParentNode } from "prosemirror-commands"; +import { splitListItem, liftListItem, sinkListItem } from "prosemirror-schema-list"; +import { milkupSchema } from "../schema"; +import { decorationPluginKey } from "../decorations"; + +/** 快捷键配置 */ +export interface KeymapConfig { + /** 是否启用列表快捷键 */ + list?: boolean; +} + +const defaultConfig: KeymapConfig = { + list: true, +}; + +/** + * 创建块级元素 Enter 键处理 + * - 当输入 ``` 或 ```lang 后按回车,创建代码块 + * - 当输入 --- 或 *** 或 ___ 后按回车,创建分割线 + */ +function createBlockEnterKeymap(schema: Schema): Record { + return { + Enter: (state: any, dispatch: any) => { + const { $from, empty } = state.selection; + + // 只处理光标选区 + if (!empty) { + return false; + } + + const parent = $from.parent; + + // 只处理段落 + if (parent.type.name !== "paragraph") { + return false; + } + + const text = parent.textContent; + const depth = $from.depth; + const paragraphStart = $from.before(depth); + const paragraphEnd = $from.after(depth); + + // 分割线:--- 或 *** 或 ___(3个或更多相同字符) + if (schema.nodes.horizontal_rule && /^([-*_])\1{2,}$/.test(text)) { + // 源码视图模式下不自动创建分割线 + const decorationState = decorationPluginKey.getState(state); + if (decorationState?.sourceView) { + return false; + } + + const hr = schema.nodes.horizontal_rule.create(); + const paragraph = schema.nodes.paragraph.create(); + const tr = state.tr.replaceWith(paragraphStart, paragraphEnd, [hr, paragraph]); + tr.setSelection(TextSelection.create(tr.doc, paragraphStart + hr.nodeSize + 1)); + + if (dispatch) { + dispatch(tr); + } + return true; + } + + // 代码块:``` 或 ```lang + if (schema.nodes.code_block) { + // 源码视图模式下不自动创建代码块 + const decorationState = decorationPluginKey.getState(state); + if (decorationState?.sourceView) { + return false; + } + + const codeBlockMatch = text.match(/^```(\w*)$/); + if (!codeBlockMatch) { + return false; + } + + const language = codeBlockMatch[1] || ""; + const codeBlock = schema.nodes.code_block.create({ language }); + const tr = state.tr.replaceWith(paragraphStart, paragraphEnd, codeBlock); + tr.setSelection(TextSelection.create(tr.doc, paragraphStart + 1)); + + if (dispatch) { + dispatch(tr); + } + return true; + } + + return false; + }, + }; +} + +/** + * 创建列表快捷键 + */ +function createListKeymap(schema: Schema): Record { + const keys: Record = {}; + + // 创建统一的 Enter 处理器,同时支持 list_item 和 task_item + const listItemSplit = schema.nodes.list_item ? splitListItem(schema.nodes.list_item) : null; + const taskItemSplit = schema.nodes.task_item ? splitListItem(schema.nodes.task_item) : null; + + if (listItemSplit || taskItemSplit) { + keys["Enter"] = (state: any, dispatch: any) => { + const { $from, empty } = state.selection; + + // 只处理光标选区 + if (!empty) { + return false; + } + + const parent = $from.parent; + + // 检查是否在源码模式下的代码块段落中 + const decorationState = decorationPluginKey.getState(state); + if ( + decorationState?.sourceView && + parent.type.name === "paragraph" && + parent.attrs.codeBlockId + ) { + const text = parent.textContent; + const isClosingFence = text.trim() === "```"; + const lineIndex = parent.attrs.lineIndex; + const totalLines = parent.attrs.totalLines; + const isLastLine = lineIndex === totalLines - 1; + const depth = $from.depth; + + // 如果在结束围栏位置按回车 + if (isClosingFence && isLastLine) { + // 检查是否在列表中 + let inList = false; + for (let d = depth; d > 0; d--) { + const node = $from.node(d); + if (node.type.name === "list_item" || node.type.name === "task_item") { + inList = true; + break; + } + } + + if (inList) { + // 在列表中,尝试分割列表项 + if (taskItemSplit && taskItemSplit(state, dispatch)) { + return true; + } + if (listItemSplit && listItemSplit(state, dispatch)) { + return true; + } + return false; + } else { + // 不在列表中,创建新段落 + if (dispatch) { + const paragraphEnd = $from.after(depth); + const newParagraph = schema.nodes.paragraph.create(); + const tr = state.tr.insert(paragraphEnd, newParagraph); + tr.setSelection(TextSelection.create(tr.doc, paragraphEnd + 1)); + dispatch(tr); + } + return true; + } + } + + // 在代码块内容中按回车,分割当前段落 + if (dispatch) { + const codeBlockId = parent.attrs.codeBlockId; + const tr = state.tr.split($from.pos); + + // split 后两个段落都继承了原始属性(相同的 lineIndex) + // 需要更新:第二个段落 lineIndex+1,后续段落 lineIndex+1,所有段落 totalLines+1 + let foundSplit = false; + tr.doc.descendants((node: any, pos: number) => { + if (node.type.name === "paragraph" && node.attrs.codeBlockId === codeBlockId) { + if (node.attrs.lineIndex === lineIndex && !foundSplit) { + // 第一个(原始段落的前半部分) + foundSplit = true; + tr.setNodeMarkup(pos, null, { + ...node.attrs, + totalLines: totalLines + 1, + }); + } else if (node.attrs.lineIndex === lineIndex && foundSplit) { + // 第二个(分割出的新段落) + tr.setNodeMarkup(pos, null, { + ...node.attrs, + lineIndex: lineIndex + 1, + totalLines: totalLines + 1, + }); + } else if (node.attrs.lineIndex > lineIndex) { + tr.setNodeMarkup(pos, null, { + ...node.attrs, + lineIndex: node.attrs.lineIndex + 1, + totalLines: totalLines + 1, + }); + } else { + tr.setNodeMarkup(pos, null, { + ...node.attrs, + totalLines: totalLines + 1, + }); + } + } + }); + + dispatch(tr); + } + return true; + } + + // 先尝试分割任务列表项 + if (taskItemSplit && taskItemSplit(state, dispatch)) { + return true; + } + // 再尝试分割普通列表项 + if (listItemSplit && listItemSplit(state, dispatch)) { + return true; + } + return false; + }; + } + + // Backspace 处理:确保可以正常删除所有字符 + keys["Backspace"] = (state: any, dispatch: any) => { + const { $from, $to, empty } = state.selection; + + // 只处理光标选区 + if (!empty) { + return false; + } + + // 如果光标在段落开头,使用默认行为(可能会合并段落或列表项) + if ($from.parentOffset === 0) { + return false; + } + + // 直接删除光标前面的一个字符 + if (dispatch) { + const tr = state.tr.delete($from.pos - 1, $from.pos); + dispatch(tr); + } + return true; + }; + + // Tab 和 Shift-Tab 操作 + if (schema.nodes.list_item) { + keys["Tab"] = sinkListItem(schema.nodes.list_item); + keys["Shift-Tab"] = liftListItem(schema.nodes.list_item); + } + + // 取消列表 + keys["Mod-Shift-l"] = lift; + + return keys; +} + +/** + * 创建快捷键插件 + * 仅包含块级 Enter 处理和列表快捷键(不可自定义部分) + * 基础快捷键和 Markdown 快捷键由动态 keymap 插件处理 + */ +export function createKeymapPlugin( + schema: Schema = milkupSchema, + config: KeymapConfig = {} +): Plugin[] { + const mergedConfig = { ...defaultConfig, ...config }; + const plugins: Plugin[] = []; + + // 块级元素 Enter 键处理(优先级最高) + plugins.push(keymap(createBlockEnterKeymap(schema))); + + // Escape 选择父节点 + plugins.push(keymap({ Escape: selectParentNode })); + + if (mergedConfig.list) { + plugins.push(keymap(createListKeymap(schema))); + } + + return plugins; +} + +export { createListKeymap, createBlockEnterKeymap }; + +// 导出新模块 +export type { + ShortcutActionId, + ShortcutCategory, + ShortcutDefinition, + ShortcutKeyMap, +} from "./types"; +export { DEFAULT_SHORTCUTS, CATEGORY_LABELS } from "./shortcut-registry"; +export { buildActionCommandMap } from "./action-commands"; +export { createDynamicKeymapPlugin } from "./dynamic-keymap"; +export { + createEnhancedToggleMark, + createSetHeadingCommand, + createSetParagraphCommand, +} from "../commands/enhanced-commands"; diff --git a/src/core/keymap/shortcut-registry.ts b/src/core/keymap/shortcut-registry.ts new file mode 100644 index 0000000..4b28d7a --- /dev/null +++ b/src/core/keymap/shortcut-registry.ts @@ -0,0 +1,119 @@ +/** + * 快捷键默认注册表 + */ + +import type { ShortcutDefinition } from "./types"; + +/** 所有可自定义快捷键的默认注册表 */ +export const DEFAULT_SHORTCUTS: ShortcutDefinition[] = [ + // 内联格式 + { id: "toggleStrong", label: "粗体", category: "inline", key: "Mod-b", defaultKey: "Mod-b" }, + { id: "toggleEmphasis", label: "斜体", category: "inline", key: "Mod-i", defaultKey: "Mod-i" }, + { + id: "toggleCodeInline", + label: "行内代码", + category: "inline", + key: "Mod-`", + defaultKey: "Mod-`", + }, + { + id: "toggleStrikethrough", + label: "删除线", + category: "inline", + key: "Mod-Shift-s", + defaultKey: "Mod-Shift-s", + }, + { + id: "toggleHighlight", + label: "高亮", + category: "inline", + key: "Mod-Shift-h", + defaultKey: "Mod-Shift-h", + }, + + // 块级格式 + { id: "setHeading1", label: "一级标题", category: "block", key: "Mod-1", defaultKey: "Mod-1" }, + { id: "setHeading2", label: "二级标题", category: "block", key: "Mod-2", defaultKey: "Mod-2" }, + { id: "setHeading3", label: "三级标题", category: "block", key: "Mod-3", defaultKey: "Mod-3" }, + { id: "setHeading4", label: "四级标题", category: "block", key: "Mod-4", defaultKey: "Mod-4" }, + { id: "setHeading5", label: "五级标题", category: "block", key: "Mod-5", defaultKey: "Mod-5" }, + { id: "setHeading6", label: "六级标题", category: "block", key: "Mod-6", defaultKey: "Mod-6" }, + { id: "setParagraph", label: "段落", category: "block", key: "Mod-0", defaultKey: "Mod-0" }, + { + id: "setCodeBlock", + label: "代码块", + category: "block", + key: "Mod-Shift-c", + defaultKey: "Mod-Shift-c", + }, + { + id: "wrapInBlockquote", + label: "引用", + category: "block", + key: "Mod-Shift-q", + defaultKey: "Mod-Shift-q", + }, + { + id: "wrapInBulletList", + label: "无序列表", + category: "block", + key: "Mod-Shift-u", + defaultKey: "Mod-Shift-u", + }, + { + id: "wrapInOrderedList", + label: "有序列表", + category: "block", + key: "Mod-Shift-o", + defaultKey: "Mod-Shift-o", + }, + { + id: "liftBlock", + label: "取消嵌套", + category: "block", + key: "Mod-Shift-l", + defaultKey: "Mod-Shift-l", + }, + + // 插入 + { + id: "insertHorizontalRule", + label: "分割线", + category: "insert", + key: "Mod-Shift-minus", + defaultKey: "Mod-Shift-minus", + }, + { + id: "insertTable", + label: "表格", + category: "insert", + key: "Mod-Shift-t", + defaultKey: "Mod-Shift-t", + }, + { + id: "insertMathBlock", + label: "数学公式", + category: "insert", + key: "Mod-Shift-m", + defaultKey: "Mod-Shift-m", + }, + + // 编辑器操作 + { + id: "toggleSourceView", + label: "源码视图", + category: "editor", + key: "Mod-/", + defaultKey: "Mod-/", + }, + { id: "undo", label: "撤销", category: "editor", key: "Mod-z", defaultKey: "Mod-z" }, + { id: "redo", label: "重做", category: "editor", key: "Mod-y", defaultKey: "Mod-y" }, +]; + +/** 分类中文名映射 */ +export const CATEGORY_LABELS: Record = { + inline: "内联格式", + block: "块级格式", + insert: "插入", + editor: "编辑器操作", +}; diff --git a/src/core/keymap/types.ts b/src/core/keymap/types.ts new file mode 100644 index 0000000..a8e5bdf --- /dev/null +++ b/src/core/keymap/types.ts @@ -0,0 +1,48 @@ +/** + * 快捷键系统类型定义 + */ + +/** 快捷键动作 ID */ +export type ShortcutActionId = + | "toggleStrong" + | "toggleEmphasis" + | "toggleCodeInline" + | "toggleStrikethrough" + | "toggleHighlight" + | "setHeading1" + | "setHeading2" + | "setHeading3" + | "setHeading4" + | "setHeading5" + | "setHeading6" + | "setParagraph" + | "setCodeBlock" + | "wrapInBlockquote" + | "wrapInBulletList" + | "wrapInOrderedList" + | "liftBlock" + | "insertHorizontalRule" + | "insertTable" + | "insertMathBlock" + | "toggleSourceView" + | "undo" + | "redo"; + +/** 快捷键分类 */ +export type ShortcutCategory = "inline" | "block" | "insert" | "editor"; + +/** 快捷键定义 */ +export interface ShortcutDefinition { + id: ShortcutActionId; + /** 中文显示名 */ + label: string; + /** 分类 */ + category: ShortcutCategory; + /** 当前绑定键(ProseMirror 格式) */ + key: string; + /** 默认键 */ + defaultKey: string; +} + +/** 用户自定义快捷键映射(仅存储与默认值不同的部分) */ +export type ShortcutKeyMap = Partial>; diff --git a/src/core/nodeviews/code-block.ts b/src/core/nodeviews/code-block.ts new file mode 100644 index 0000000..4086f6a --- /dev/null +++ b/src/core/nodeviews/code-block.ts @@ -0,0 +1,1529 @@ +/** + * Milkup 代码块 NodeView + * + * 使用 CodeMirror 6 实现代码块编辑 + * 支持语法高亮和 Mermaid 图表预览 + * 支持源码模式显示完整 Markdown 语法 + */ + +import { Node as ProseMirrorNode } from "prosemirror-model"; +import { EditorView as ProseMirrorView, NodeView } from "prosemirror-view"; +import { Selection, TextSelection } from "prosemirror-state"; +import { + EditorView, + keymap as cmKeymap, + ViewUpdate, + lineNumbers, + Decoration as CMDecoration, +} from "@codemirror/view"; +import { EditorState as CMEditorState, Compartment, Extension } from "@codemirror/state"; +import { defaultKeymap, history, historyKeymap } from "@codemirror/commands"; +import { syntaxHighlighting, defaultHighlightStyle, HighlightStyle } from "@codemirror/language"; +import { tags } from "@lezer/highlight"; +import { javascript } from "@codemirror/lang-javascript"; +import { python } from "@codemirror/lang-python"; +import { html } from "@codemirror/lang-html"; +import { css } from "@codemirror/lang-css"; +import { json } from "@codemirror/lang-json"; +import { markdown } from "@codemirror/lang-markdown"; +import { sourceViewManager } from "../decorations"; +import { searchPluginKey } from "../plugins/search"; + +/** Mermaid 显示模式 */ +type MermaidDisplayMode = "code" | "mixed" | "diagram"; + +/** 暗色主题高亮样式 */ +export const darkHighlightStyle = HighlightStyle.define([ + { tag: tags.keyword, color: "#ff7b72" }, + { tag: tags.operator, color: "#79c0ff" }, + { tag: tags.special(tags.variableName), color: "#ffa657" }, + { tag: tags.typeName, color: "#ffa657" }, + { tag: tags.atom, color: "#79c0ff" }, + { tag: tags.number, color: "#79c0ff" }, + { tag: tags.definition(tags.variableName), color: "#d2a8ff" }, + { tag: tags.string, color: "#a5d6ff" }, + { tag: tags.special(tags.string), color: "#a5d6ff" }, + { tag: tags.comment, color: "#8b949e", fontStyle: "italic" }, + { tag: tags.variableName, color: "#c9d1d9" }, + { tag: tags.tagName, color: "#7ee787" }, + { tag: tags.propertyName, color: "#79c0ff" }, + { tag: tags.attributeName, color: "#79c0ff" }, + { tag: tags.className, color: "#ffa657" }, + { tag: tags.labelName, color: "#d2a8ff" }, + { tag: tags.namespace, color: "#ff7b72" }, + { tag: tags.macroName, color: "#d2a8ff" }, + { tag: tags.literal, color: "#79c0ff" }, + { tag: tags.bool, color: "#79c0ff" }, + { tag: tags.null, color: "#79c0ff" }, + { tag: tags.punctuation, color: "#c9d1d9" }, + { tag: tags.bracket, color: "#c9d1d9" }, + { tag: tags.meta, color: "#8b949e" }, + { tag: tags.link, color: "#58a6ff", textDecoration: "underline" }, + { tag: tags.heading, color: "#79c0ff", fontWeight: "bold" }, + { tag: tags.emphasis, fontStyle: "italic" }, + { tag: tags.strong, fontWeight: "bold" }, + { tag: tags.strikethrough, textDecoration: "line-through" }, +]); + +/** + * 检测当前主题是否为暗色模式 + */ +export function detectDarkTheme(): boolean { + const htmlElement = document.documentElement; + const themeClass = Array.from(htmlElement.classList).find((c) => c.startsWith("theme-")); + if (!themeClass) return false; + return themeClass.includes("dark"); +} + +/** + * 获取当前主题名称 + */ +function getCurrentThemeName(): string { + const htmlElement = document.documentElement; + const themeClass = Array.from(htmlElement.classList).find((c) => c.startsWith("theme-")); + return themeClass ? themeClass.replace("theme-", "") : "normal"; +} + +/** + * 创建 CodeMirror 主题扩展(使用 CSS 变量) + */ +export function createThemeExtension(isDark: boolean): Extension[] { + const baseTheme = EditorView.theme( + { + "&": { + backgroundColor: "transparent", + color: "var(--text-color)", + }, + ".cm-content": { + caretColor: "var(--text-color)", + }, + ".cm-cursor, .cm-dropCursor": { + borderLeftColor: "var(--text-color)", + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": { + backgroundColor: "var(--selected-background-color)", + }, + ".cm-activeLine": { + backgroundColor: "transparent", + }, + ".cm-gutters": { + backgroundColor: "transparent", + color: "var(--text-color-3)", + border: "none", + }, + ".cm-activeLineGutter": { + backgroundColor: "transparent", + }, + ".cm-lineNumbers .cm-gutterElement": { + color: "var(--text-color-3)", + }, + }, + { dark: isDark } + ); + + const highlightStyle = isDark ? darkHighlightStyle : defaultHighlightStyle; + return [baseTheme, syntaxHighlighting(highlightStyle)]; +} + +/** 语言扩展映射 */ +const languageExtensions: Record any> = { + javascript: javascript, + js: javascript, + typescript: () => javascript({ typescript: true }), + ts: () => javascript({ typescript: true }), + jsx: () => javascript({ jsx: true }), + tsx: () => javascript({ jsx: true, typescript: true }), + python: python, + py: python, + html: html, + css: css, + json: json, + markdown: markdown, + md: markdown, +}; + +/** 语言别名映射(用于显示) */ +const languageAliases: Record = { + js: "javascript", + ts: "typescript", + py: "python", + md: "markdown", +}; + +/** 支持的语言列表 */ +const supportedLanguages = [ + { value: "", label: "plain text" }, + { value: "javascript", label: "JavaScript" }, + { value: "typescript", label: "TypeScript" }, + { value: "python", label: "Python" }, + { value: "html", label: "HTML" }, + { value: "css", label: "CSS" }, + { value: "json", label: "JSON" }, + { value: "markdown", label: "Markdown" }, + { value: "mermaid", label: "Mermaid" }, + { value: "sql", label: "SQL" }, + { value: "bash", label: "Bash" }, + { value: "yaml", label: "YAML" }, + { value: "xml", label: "XML" }, +]; + +/** Mermaid 显示模式选项 */ +const mermaidDisplayModes = [ + { value: "code", label: "代码" }, + { value: "mixed", label: "混合" }, + { value: "diagram", label: "图表" }, +]; + +/** 全局 Mermaid 默认显示模式 */ +let globalMermaidDefaultMode: MermaidDisplayMode = "diagram"; + +/** 设置全局 Mermaid 默认显示模式 */ +export function setGlobalMermaidDefaultMode(mode: MermaidDisplayMode): void { + globalMermaidDefaultMode = mode; +} + +/** + * 规范化语言名称 + */ +function normalizeLanguage(language: string): string { + const lower = language.toLowerCase(); + return languageAliases[lower] || lower; +} + +/** + * 获取语言扩展 + */ +function getLanguageExtension(language: string): any { + const ext = languageExtensions[language.toLowerCase()]; + return ext ? ext() : []; +} + +/** + * 代码块 NodeView 类 + */ +export class CodeBlockView implements NodeView { + dom: HTMLElement; + cm: EditorView; + node: ProseMirrorNode; + view: ProseMirrorView; + getPos: () => number | undefined; + updating = false; + languageCompartment: Compartment; + themeCompartment: Compartment; + lineNumbersCompartment: Compartment; + searchHighlightCompartment: Compartment; + mermaidPreview: HTMLElement | null = null; + mermaidDisplayMode: MermaidDisplayMode = globalMermaidDefaultMode; + themeObserver: MutationObserver | null = null; + headerElement: HTMLElement | null = null; + editorContainer: HTMLElement | null = null; + contextMenu: HTMLElement | null = null; + sourceTextElement: HTMLElement | null = null; // 源码模式下的纯文本显示元素 + // 源码模式相关 + private sourceViewMode: boolean = false; + private sourceViewUnsubscribe: (() => void) | null = null; + + constructor(node: ProseMirrorNode, view: ProseMirrorView, getPos: () => number | undefined) { + this.node = node; + this.view = view; + this.getPos = getPos; + this.languageCompartment = new Compartment(); + this.themeCompartment = new Compartment(); + this.lineNumbersCompartment = new Compartment(); + this.searchHighlightCompartment = new Compartment(); + + // 检测当前主题 + const isDark = detectDarkTheme(); + + // 规范化语言名称 + const normalizedLang = normalizeLanguage(node.attrs.language); + if (normalizedLang !== node.attrs.language) { + requestAnimationFrame(() => { + const pos = this.getPos(); + if (pos !== undefined) { + this.view.dispatch( + this.view.state.tr.setNodeMarkup(pos, null, { + ...this.node.attrs, + language: normalizedLang, + }) + ); + } + }); + } + + // 创建容器 + this.dom = document.createElement("div"); + this.dom.className = "milkup-code-block"; + + // 创建头部(语言选择器) + this.headerElement = this.createHeader(normalizedLang); + this.dom.appendChild(this.headerElement); + + // 头部右键菜单 + this.headerElement.addEventListener("contextmenu", (e) => { + e.preventDefault(); + e.stopPropagation(); + this.showContextMenu(e); + }); + + // 创建 CodeMirror 编辑器容器 + this.editorContainer = document.createElement("div"); + this.editorContainer.className = "milkup-code-block-editor"; + this.dom.appendChild(this.editorContainer); + + this.cm = new EditorView({ + state: CMEditorState.create({ + doc: node.textContent, + extensions: [ + history(), + cmKeymap.of([ + { + key: "Ctrl-Enter", + run: () => { + // 检查是否在列表中 + const pos = this.getPos(); + if (pos !== undefined) { + const $pos = this.view.state.doc.resolve(pos); + // 检查祖先节点中是否有 list_item + let inList = false; + for (let d = $pos.depth; d > 0; d--) { + const node = $pos.node(d); + if (node.type.name === "list_item" || node.type.name === "task_item") { + inList = true; + break; + } + } + if (inList) { + // 在列表中,创建新的列表项 + this.exitCodeBlockAndCreateListItem(); + return true; + } + } + // 不在列表中,使用默认行为 + this.exitCodeBlock(1); + return true; + }, + }, + { + key: "ArrowDown", + run: (cmView) => { + const { state } = cmView; + const { main } = state.selection; + const line = state.doc.lineAt(main.head); + if (line.number === state.doc.lines) { + this.exitCodeBlock(1); + return true; + } + return false; + }, + }, + { + key: "ArrowUp", + run: (cmView) => { + const { state } = cmView; + const { main } = state.selection; + const line = state.doc.lineAt(main.head); + if (line.number === 1) { + this.exitCodeBlock(-1); + return true; + } + return false; + }, + }, + { + key: "ArrowLeft", + run: (cmView) => { + const { state } = cmView; + const { main } = state.selection; + if (main.head === 0 && main.empty) { + this.exitCodeBlock(-1); + return true; + } + return false; + }, + }, + { + key: "ArrowRight", + run: (cmView) => { + const { state } = cmView; + const { main } = state.selection; + + // 在第一位按右箭头,跳出代码块到开始围栏之前 + if (main.head === 0 && main.empty) { + this.exitCodeBlock(-1); + return true; + } + + // 在最后一位按右箭头,跳出代码块 + if (main.head === state.doc.length && main.empty) { + this.exitCodeBlock(1); + return true; + } + + return false; + }, + }, + { + key: "Backspace", + run: (cmView) => { + if (cmView.state.doc.length === 0) { + this.deleteCodeBlock(); + return true; + } + return false; + }, + }, + ...defaultKeymap, + ...historyKeymap, + ]), + this.themeCompartment.of(createThemeExtension(isDark)), + this.languageCompartment.of(getLanguageExtension(normalizedLang)), + this.lineNumbersCompartment.of(lineNumbers()), + this.searchHighlightCompartment.of([]), + EditorView.updateListener.of((update) => this.onCMUpdate(update)), + EditorView.domEventHandlers({ + focus: () => this.forwardSelection(), + blur: () => {}, + contextmenu: (e) => { + e.preventDefault(); + this.showContextMenu(e); + return true; + }, + }), + ], + }), + parent: this.editorContainer, + }); + + // 监听主题变化 + this.setupThemeObserver(); + + // Mermaid 预览 + if (normalizedLang === "mermaid") { + this.createMermaidPreview(node.textContent); + } + + // 如果代码块是空的,自动聚焦 + if (!node.textContent) { + requestAnimationFrame(() => { + this.cm.focus(); + }); + } + + // 源码模式初始化 + this.initSourceViewMode(normalizedLang); + } + + /** + * 初始化源码模式 + */ + private initSourceViewMode(language: string): void { + // 订阅源码模式状态变化 + this.sourceViewUnsubscribe = sourceViewManager.subscribe((sourceView) => { + this.setSourceViewMode(sourceView); + }); + } + + /** + * 设置源码模式 + */ + private setSourceViewMode(enabled: boolean): void { + if (this.sourceViewMode === enabled) return; + this.sourceViewMode = enabled; + + if (enabled) { + // 进入源码模式 + this.dom.classList.add("source-view"); + + // 隐藏头部 + if (this.headerElement) { + this.headerElement.style.display = "none"; + } + + // 隐藏 Mermaid 预览 + if (this.mermaidPreview) { + this.mermaidPreview.style.display = "none"; + } + + // 隐藏 CodeMirror + if (this.editorContainer) { + this.editorContainer.style.display = "none"; + } + + // 创建源码容器(拆分成多行以参与行号计数) + if (!this.sourceTextElement) { + this.sourceTextElement = document.createElement("div"); + this.sourceTextElement.className = "milkup-code-block-source-container"; + this.sourceTextElement.contentEditable = "true"; + this.sourceTextElement.spellcheck = false; + this.sourceTextElement.style.cssText = ` + position: relative; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 14px; + line-height: 1.6; + padding: 0; + margin: 0; + color: inherit; + background: transparent; + border: none; + outline: none; + `; + + // 监听输入事件 + this.sourceTextElement.addEventListener("input", () => { + this.handleSourceTextInput(); + }); + + // 监听键盘事件 + this.sourceTextElement.addEventListener("keydown", (e) => { + this.handleSourceTextKeydown(e); + }); + + // 阻止默认的粘贴行为,使用纯文本粘贴 + this.sourceTextElement.addEventListener("paste", (e) => { + e.preventDefault(); + const text = e.clipboardData?.getData("text/plain") || ""; + document.execCommand("insertText", false, text); + }); + + this.dom.insertBefore(this.sourceTextElement, this.editorContainer); + } + + // 更新源码内容(拆分成多行) + const language = this.node.attrs.language || ""; + const content = this.node.textContent; + const fullMarkdown = `\`\`\`${language}\n${content}\n\`\`\``; + const lines = fullMarkdown.split("\n"); + + // 清空容器 + this.sourceTextElement.innerHTML = ""; + + // 为每一行创建一个 div 元素以参与行号计数 + lines.forEach((line) => { + const lineDiv = document.createElement("div"); + lineDiv.className = "milkup-with-line-number"; + lineDiv.style.cssText = ` + white-space: pre; + min-height: 1.6em; + `; + lineDiv.textContent = line; + this.sourceTextElement!.appendChild(lineDiv); + }); + + this.sourceTextElement.style.display = "block"; + } else { + // 退出源码模式 + this.dom.classList.remove("source-view"); + + // 显示 CodeMirror + if (this.editorContainer) { + this.editorContainer.style.display = ""; + } + + // 显示头部 + if (this.headerElement) { + this.headerElement.style.display = ""; + } + + // 隐藏源码文本元素 + if (this.sourceTextElement) { + this.sourceTextElement.style.display = "none"; + } + + // 恢复 Mermaid 预览 + if (this.mermaidPreview && this.node.attrs.language === "mermaid") { + this.updateMermaidDisplay(); + } + } + } + + /** + * 处理源码文本输入 + */ + private handleSourceTextInput(): void { + if (!this.sourceTextElement || this.updating) return; + + // 从所有子 div 中收集文本内容 + const lines: string[] = []; + const childDivs = this.sourceTextElement.querySelectorAll("div.milkup-with-line-number"); + childDivs.forEach((div) => { + lines.push(div.textContent || ""); + }); + const text = lines.join("\n"); + + const pos = this.getPos(); + if (pos === undefined) return; + + // 保存光标位置 + const selection = window.getSelection(); + let cursorOffset = 0; + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + if (range.startContainer.nodeType === Node.TEXT_NODE) { + cursorOffset = range.startOffset; + // 计算相对于整个文本的偏移量 + let node: Node | null = range.startContainer; + while (node && node !== this.sourceTextElement && node.previousSibling) { + node = node.previousSibling; + cursorOffset += node.textContent?.length || 0; + } + } + } + + this.updating = true; + + // 检查是否是完整的代码块格式(必须有开头和结尾的 ```) + const fenceMatch = text.match(/^```([^\n]*?)\n([\s\S]*?)\n```$/); + + if (fenceMatch) { + // 仍然是完整的代码块格式,更新内容 + const [, language, content] = fenceMatch; + const tr = this.view.state.tr; + let needsUpdate = false; + + // 更新语言属性 + const normalizedLang = language || ""; + if (normalizedLang !== this.node.attrs.language) { + tr.setNodeMarkup(pos, null, { + ...this.node.attrs, + language: normalizedLang, + }); + needsUpdate = true; + } + + // 更新内容 + if (content !== this.node.textContent) { + const start = pos + 1; + const end = pos + 1 + this.node.content.size; + tr.replaceWith(start, end, content ? this.view.state.schema.text(content) : []); + needsUpdate = true; + } + + if (needsUpdate) { + this.view.dispatch(tr); + } + + // 更新行显示(重新拆分成多行) + const newLines = text.split("\n"); + this.sourceTextElement.innerHTML = ""; + newLines.forEach((line) => { + const lineDiv = document.createElement("div"); + lineDiv.className = "milkup-with-line-number"; + lineDiv.style.cssText = ` + white-space: pre; + min-height: 1.6em; + `; + lineDiv.textContent = line; + this.sourceTextElement!.appendChild(lineDiv); + }); + } else { + // 不是完整的代码块格式,转换为段落 + const tr = this.view.state.tr; + const nodeEnd = pos + this.node.nodeSize; + + tr.delete(pos, nodeEnd); + + if (text.trim()) { + const textLines = text.split("\n").filter((line) => line.trim()); + const paragraphs = textLines.map((line) => + this.view.state.schema.nodes.paragraph.create( + null, + line ? this.view.state.schema.text(line) : null + ) + ); + + if (paragraphs.length === 0) { + paragraphs.push(this.view.state.schema.nodes.paragraph.create()); + } + + tr.insert(pos, paragraphs); + const newPos = pos + 1; + tr.setSelection(TextSelection.create(tr.doc, newPos)); + } else { + const paragraph = this.view.state.schema.nodes.paragraph.create(); + tr.insert(pos, paragraph); + const newPos = pos + 1; + tr.setSelection(TextSelection.create(tr.doc, newPos)); + } + + this.view.dispatch(tr); + this.view.focus(); + } + + this.updating = false; + + // 如果仍然是代码块,恢复光标位置 + if (fenceMatch) { + requestAnimationFrame(() => { + if (this.sourceTextElement && selection) { + try { + // 找到光标应该在的位置 + let remainingOffset = cursorOffset; + let targetDiv: HTMLElement | null = null; + let targetOffset = 0; + + const divs = this.sourceTextElement.querySelectorAll("div.milkup-with-line-number"); + for (let i = 0; i < divs.length; i++) { + const div = divs[i] as HTMLElement; + const textNode = div.firstChild; + if (!textNode || textNode.nodeType !== Node.TEXT_NODE) continue; + + const textLength = textNode.textContent?.length || 0; + if (remainingOffset <= textLength) { + targetDiv = div; + targetOffset = remainingOffset; + break; + } + remainingOffset -= textLength + 1; // +1 for newline + } + + if (targetDiv && targetDiv.firstChild) { + const range = document.createRange(); + const textNode = targetDiv.firstChild; + const offset = Math.min(targetOffset, textNode.textContent?.length || 0); + range.setStart(textNode, offset); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + } + } catch (e) { + // 忽略光标恢复错误 + } + } + }); + } + } + + /** + * 处理源码文本键盘事件 + */ + private handleSourceTextKeydown(e: KeyboardEvent): void { + // 允许基本的编辑操作 + if (e.key === "Tab") { + e.preventDefault(); + // 插入两个空格 + document.execCommand("insertText", false, " "); + } + } + + /** + * 创建头部(语言选择器和 Mermaid 模式选择器) + */ + private createHeader(language: string): HTMLElement { + const header = document.createElement("div"); + header.className = "milkup-code-block-header"; + + // 语言选择器 + const langSelector = this.createCustomSelect(supportedLanguages, language, (value) => + this.setLanguage(value) + ); + langSelector.classList.add("milkup-code-block-lang-select"); + header.appendChild(langSelector); + + // Mermaid 模式选择器(仅在 mermaid 语言时显示) + if (language === "mermaid") { + const modeSelector = this.createCustomSelect( + mermaidDisplayModes, + this.mermaidDisplayMode, + (value) => this.setMermaidDisplayMode(value as MermaidDisplayMode) + ); + modeSelector.classList.add("milkup-code-block-mode-select"); + header.appendChild(modeSelector); + } + + // 复制按钮(hover 时显示) + const copyBtn = document.createElement("button"); + copyBtn.className = "milkup-code-block-copy-btn"; + copyBtn.type = "button"; + copyBtn.title = "复制代码块"; + copyBtn.innerHTML = ``; + copyBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.copyCodeBlock(); + // 短暂显示已复制反馈 + copyBtn.classList.add("copied"); + copyBtn.innerHTML = ``; + setTimeout(() => { + copyBtn.classList.remove("copied"); + copyBtn.innerHTML = ``; + }, 1500); + }); + header.appendChild(copyBtn); + + return header; + } + + /** + * 创建自定义下拉选择器 + */ + private createCustomSelect( + options: { value: string; label: string }[], + currentValue: string, + onChange: (value: string) => void + ): HTMLElement { + const container = document.createElement("div"); + container.className = "milkup-custom-select"; + + const button = document.createElement("button"); + button.className = "milkup-custom-select-button"; + button.type = "button"; + const currentOption = options.find((o) => o.value === currentValue); + button.textContent = currentOption?.label || options[0].label; + + const dropdown = document.createElement("div"); + dropdown.className = "milkup-custom-select-dropdown"; + + for (const option of options) { + const item = document.createElement("div"); + item.className = "milkup-custom-select-item"; + if (option.value === currentValue) { + item.classList.add("selected"); + } + item.textContent = option.label; + item.dataset.value = option.value; + item.addEventListener("click", (e) => { + e.stopPropagation(); + button.textContent = option.label; + // 更新选中状态 + dropdown.querySelectorAll(".milkup-custom-select-item").forEach((el) => { + el.classList.remove("selected"); + }); + item.classList.add("selected"); + container.classList.remove("open"); + onChange(option.value); + }); + dropdown.appendChild(item); + } + + button.addEventListener("click", (e) => { + e.stopPropagation(); + // 关闭所有其他下拉框 + document.querySelectorAll(".milkup-custom-select.open").forEach((el) => { + if (el !== container) { + el.classList.remove("open"); + } + }); + + // 检测是否需要向上弹出 + const buttonRect = button.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + const spaceBelow = viewportHeight - buttonRect.bottom; + const dropdownHeight = 240; // 最大高度 + + if (spaceBelow < dropdownHeight && buttonRect.top > spaceBelow) { + container.classList.add("dropup"); + } else { + container.classList.remove("dropup"); + } + + container.classList.toggle("open"); + }); + + // 点击外部关闭 + document.addEventListener("click", () => { + container.classList.remove("open"); + }); + + container.appendChild(button); + container.appendChild(dropdown); + return container; + } + + /** + * 获取代码块的完整 Markdown 文本(含围栏) + */ + private getCodeBlockMarkdown(): string { + const language = this.node.attrs.language || ""; + const content = this.cm.state.doc.toString(); + return `\`\`\`${language}\n${content}\n\`\`\``; + } + + /** + * 复制代码块到剪贴板 + */ + private copyCodeBlock(): void { + navigator.clipboard.writeText(this.getCodeBlockMarkdown()); + } + + /** + * 显示右键菜单 + */ + private async showContextMenu(e: MouseEvent): Promise { + // 移除已存在的右键菜单 + this.hideContextMenu(); + + const menu = document.createElement("div"); + menu.className = "milkup-context-menu"; + + // 检查是否有选区 + const { main } = this.cm.state.selection; + const hasSelection = !main.empty; + + // 检查剪贴板是否有内容(文本或图片) + let hasClipboardContent = true; // 默认启用粘贴 + try { + const items = await navigator.clipboard.read(); + hasClipboardContent = items.length > 0; + } catch { + // 如果 read() 不支持,尝试 readText() + try { + const text = await navigator.clipboard.readText(); + hasClipboardContent = text.length > 0; + } catch { + hasClipboardContent = true; // 默认启用粘贴 + } + } + + // 复制 + const copyItem = this.createContextMenuItem("复制", !hasSelection, () => { + const selectedText = this.cm.state.sliceDoc(main.from, main.to); + navigator.clipboard.writeText(selectedText); + this.hideContextMenu(); + }); + menu.appendChild(copyItem); + + // 剪切 + const cutItem = this.createContextMenuItem("剪切", !hasSelection, () => { + const selectedText = this.cm.state.sliceDoc(main.from, main.to); + navigator.clipboard.writeText(selectedText); + this.cm.dispatch({ + changes: { from: main.from, to: main.to, insert: "" }, + }); + this.hideContextMenu(); + }); + menu.appendChild(cutItem); + + // 粘贴 - 使用 Clipboard API 读取文本 + const pasteItem = this.createContextMenuItem("粘贴", !hasClipboardContent, async () => { + this.hideContextMenu(); + this.cm.focus(); + try { + const text = await navigator.clipboard.readText(); + if (text) { + const { main } = this.cm.state.selection; + this.cm.dispatch({ + changes: { from: main.from, to: main.to, insert: text }, + }); + } + } catch { + console.warn("无法访问剪贴板"); + } + }); + menu.appendChild(pasteItem); + + // 分隔线 + const separator = document.createElement("div"); + separator.className = "milkup-context-menu-separator"; + menu.appendChild(separator); + + // 复制代码块 + const copyBlockItem = this.createContextMenuItem("复制代码块", false, () => { + this.copyCodeBlock(); + this.hideContextMenu(); + }); + menu.appendChild(copyBlockItem); + + // 定位菜单 + menu.style.left = `${e.clientX}px`; + menu.style.top = `${e.clientY}px`; + + document.body.appendChild(menu); + this.contextMenu = menu; + + // 调整位置,确保菜单在视口内 + const menuRect = menu.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + if (menuRect.right > viewportWidth) { + menu.style.left = `${viewportWidth - menuRect.width - 8}px`; + } + if (menuRect.bottom > viewportHeight) { + menu.style.top = `${viewportHeight - menuRect.height - 8}px`; + } + + // 点击外部关闭 + const closeHandler = (event: MouseEvent) => { + if (!menu.contains(event.target as Node)) { + this.hideContextMenu(); + document.removeEventListener("click", closeHandler); + } + }; + setTimeout(() => { + document.addEventListener("click", closeHandler); + }, 0); + } + + /** + * 创建右键菜单项 + */ + private createContextMenuItem( + label: string, + disabled: boolean, + onClick: () => void + ): HTMLElement { + const item = document.createElement("div"); + item.className = "milkup-context-menu-item"; + if (disabled) { + item.classList.add("disabled"); + } + item.textContent = label; + + if (!disabled) { + item.addEventListener("click", (e) => { + e.stopPropagation(); + onClick(); + }); + } + + return item; + } + + /** + * 隐藏右键菜单 + */ + private hideContextMenu(): void { + if (this.contextMenu) { + this.contextMenu.remove(); + this.contextMenu = null; + } + // 移除其他可能存在的右键菜单 + document.querySelectorAll(".milkup-context-menu").forEach((el) => el.remove()); + } + + /** + * 设置主题观察器 + */ + private setupThemeObserver(): void { + const htmlElement = document.documentElement; + + this.themeObserver = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type === "attributes" && mutation.attributeName === "class") { + const isDark = detectDarkTheme(); + this.updateTheme(isDark); + // 更新 Mermaid 预览 + if (this.node.attrs.language === "mermaid" && this.mermaidPreview) { + this.createMermaidPreview(this.cm.state.doc.toString()); + } + } + } + }); + + this.themeObserver.observe(htmlElement, { + attributes: true, + attributeFilter: ["class"], + }); + } + + /** + * 更新主题 + */ + private updateTheme(isDark: boolean): void { + this.cm.dispatch({ + effects: this.themeCompartment.reconfigure(createThemeExtension(isDark)), + }); + } + + /** + * 更新搜索高亮(将 ProseMirror 搜索状态映射到 CodeMirror 装饰) + */ + private updateSearchHighlights(): void { + const pos = this.getPos(); + if (pos === undefined) return; + + const searchState = searchPluginKey.getState(this.view.state); + if (!searchState || !searchState.query || searchState.matches.length === 0) { + this.cm.dispatch({ + effects: this.searchHighlightCompartment.reconfigure([]), + }); + return; + } + + const nodeStart = pos + 1; + const nodeEnd = pos + 1 + this.node.content.size; + + const cmRanges = searchState.matches + .map((m, i) => ({ ...m, index: i })) + .filter((m) => m.from >= nodeStart && m.to <= nodeEnd) + .map((m) => { + const cls = + m.index === searchState.currentIndex + ? "milkup-search-match milkup-search-match-current" + : "milkup-search-match"; + return CMDecoration.mark({ class: cls }).range(m.from - nodeStart, m.to - nodeStart); + }); + + const decoSet = cmRanges.length > 0 ? CMDecoration.set(cmRanges, true) : CMDecoration.none; + + this.cm.dispatch({ + effects: this.searchHighlightCompartment.reconfigure(EditorView.decorations.of(decoSet)), + }); + } + + /** + * 设置语言 + */ + private setLanguage(language: string): void { + const pos = this.getPos(); + if (pos === undefined) return; + + const prevLanguage = this.node.attrs.language; + + // 更新 ProseMirror 节点属性 + this.view.dispatch( + this.view.state.tr.setNodeMarkup(pos, null, { + ...this.node.attrs, + language, + }) + ); + + // 更新 CodeMirror 语言扩展 + this.cm.dispatch({ + effects: this.languageCompartment.reconfigure(getLanguageExtension(language)), + }); + + // 更新头部(添加或移除 Mermaid 模式选择器) + if ((prevLanguage === "mermaid") !== (language === "mermaid")) { + this.updateHeader(language); + } + + // 更新 Mermaid 预览 + if (language === "mermaid") { + this.createMermaidPreview(this.cm.state.doc.toString()); + } else if (this.mermaidPreview) { + this.mermaidPreview.remove(); + this.mermaidPreview = null; + } + } + + /** + * 更新头部 + */ + private updateHeader(language: string): void { + if (this.headerElement) { + const newHeader = this.createHeader(language); + this.dom.replaceChild(newHeader, this.headerElement); + this.headerElement = newHeader; + } + } + + /** + * 设置 Mermaid 显示模式 + */ + private setMermaidDisplayMode(mode: MermaidDisplayMode): void { + this.mermaidDisplayMode = mode; + this.updateMermaidDisplay(); + } + + /** + * 更新 Mermaid 显示 + */ + private updateMermaidDisplay(): void { + if (!this.editorContainer || !this.mermaidPreview) return; + + switch (this.mermaidDisplayMode) { + case "code": + this.editorContainer.style.display = "block"; + this.mermaidPreview.style.display = "none"; + break; + case "diagram": + this.editorContainer.style.display = "none"; + this.mermaidPreview.style.display = "block"; + break; + case "mixed": + default: + this.editorContainer.style.display = "block"; + this.mermaidPreview.style.display = "block"; + break; + } + } + + /** + * 创建 Mermaid 预览 + */ + private async createMermaidPreview(content: string): Promise { + if (!this.mermaidPreview) { + this.mermaidPreview = document.createElement("div"); + this.mermaidPreview.className = "milkup-mermaid-preview"; + this.dom.appendChild(this.mermaidPreview); + } + + try { + const mermaid = await import("mermaid"); + const isDark = detectDarkTheme(); + const themeName = getCurrentThemeName(); + + // 根据主题选择 Mermaid 主题 + let mermaidTheme = "default"; + if (isDark) { + mermaidTheme = "dark"; + } else if (themeName === "frame") { + mermaidTheme = "forest"; + } + + mermaid.default.initialize({ + startOnLoad: false, + theme: mermaidTheme, + }); + + const { svg } = await mermaid.default.render(`mermaid-${Date.now()}`, content); + this.mermaidPreview.innerHTML = svg; + } catch (error) { + this.mermaidPreview.innerHTML = `
Mermaid 渲染错误
`; + } + + this.updateMermaidDisplay(); + } + + /** + * CodeMirror 更新回调 + */ + private onCMUpdate(update: ViewUpdate): void { + if (this.updating) return; + + if (update.docChanged) { + const pos = this.getPos(); + if (pos === undefined) return; + + const newText = update.state.doc.toString(); + + // 更新 ProseMirror 文档 + const tr = this.view.state.tr; + const start = pos + 1; + const end = pos + 1 + this.node.content.size; + + tr.replaceWith(start, end, newText ? this.view.state.schema.text(newText) : []); + + this.view.dispatch(tr); + + // 更新 Mermaid 预览 + if (this.node.attrs.language === "mermaid") { + this.createMermaidPreview(newText); + } + } + } + + /** + * 转发选区到 ProseMirror + */ + private forwardSelection(): void { + const pos = this.getPos(); + if (pos === undefined) return; + + const { from, to } = this.cm.state.selection.main; + const start = pos + 1 + from; + const end = pos + 1 + to; + + const selection = TextSelection.create(this.view.state.doc, start, end); + + if (!this.view.state.selection.eq(selection)) { + this.view.dispatch(this.view.state.tr.setSelection(selection)); + } + } + + /** + * 跳出代码块 + */ + private exitCodeBlock(direction: 1 | -1): void { + const pos = this.getPos(); + if (pos === undefined) return; + + const { state } = this.view; + const nodeEnd = pos + this.node.nodeSize; + + if (direction === 1) { + const isLastNode = nodeEnd >= state.doc.content.size; + + if (isLastNode) { + const paragraph = state.schema.nodes.paragraph.create(); + const tr = state.tr.insert(nodeEnd, paragraph); + tr.setSelection(TextSelection.create(tr.doc, nodeEnd + 1)); + this.view.dispatch(tr); + this.view.focus(); + return; + } + + const selection = Selection.near(state.doc.resolve(nodeEnd), 1); + this.view.dispatch(state.tr.setSelection(selection)); + this.view.focus(); + } else { + const selection = Selection.near(state.doc.resolve(pos), -1); + // 如果找到的选区不在代码块之前,说明前方无可用位置,需创建段落 + if (selection.from >= pos) { + const paragraph = state.schema.nodes.paragraph.create(); + const tr = state.tr.insert(pos, paragraph); + tr.setSelection(TextSelection.create(tr.doc, pos + 1)); + this.view.dispatch(tr); + this.view.focus(); + return; + } + this.view.dispatch(state.tr.setSelection(selection)); + this.view.focus(); + } + } + + /** + * 跳出代码块并创建新的列表项 + */ + private exitCodeBlockAndCreateListItem(): void { + const pos = this.getPos(); + if (pos === undefined) return; + + const { state } = this.view; + const $pos = state.doc.resolve(pos); + + // 查找列表项节点 + let listItemDepth = -1; + for (let d = $pos.depth; d > 0; d--) { + const node = $pos.node(d); + if (node.type.name === "list_item" || node.type.name === "task_item") { + listItemDepth = d; + break; + } + } + + // 确认在列表项中 + if (listItemDepth === -1) { + this.exitCodeBlock(1); + return; + } + + // 获取列表项之后的位置(而非内容末尾) + const listItemAfter = $pos.after(listItemDepth); + + // 创建新的列表项 + const newListItem = state.schema.nodes.list_item.create( + null, + state.schema.nodes.paragraph.create() + ); + + const tr = state.tr; + tr.insert(listItemAfter, newListItem); + tr.setSelection(TextSelection.create(tr.doc, listItemAfter + 2)); + this.view.dispatch(tr); + this.view.focus(); + } + + /** + * 删除代码块 + */ + private deleteCodeBlock(): void { + const pos = this.getPos(); + if (pos === undefined) return; + + const { state } = this.view; + const nodeEnd = pos + this.node.nodeSize; + + const tr = state.tr.delete(pos, nodeEnd); + + if (tr.doc.content.size === 0) { + const paragraph = state.schema.nodes.paragraph.create(); + tr.insert(0, paragraph); + tr.setSelection(TextSelection.create(tr.doc, 1)); + } else { + const $pos = tr.doc.resolve(Math.min(pos, tr.doc.content.size)); + tr.setSelection(Selection.near($pos, -1)); + } + + this.view.dispatch(tr); + this.view.focus(); + } + + /** + * 更新节点 + */ + update(node: ProseMirrorNode): boolean { + if (node.type !== this.node.type) return false; + + const prevLanguage = this.node.attrs.language; + this.node = node; + const newText = node.textContent; + + if (newText !== this.cm.state.doc.toString()) { + this.updating = true; + this.cm.dispatch({ + changes: { + from: 0, + to: this.cm.state.doc.length, + insert: newText, + }, + }); + this.updating = false; + } + + // 更新源码模式下的文本内容 + if (this.sourceViewMode && this.sourceTextElement) { + const language = node.attrs.language || ""; + const content = node.textContent; + const fullMarkdown = `\`\`\`${language}\n${content}\n\`\`\``; + const lines = fullMarkdown.split("\n"); + + // 收集当前的文本内容 + const currentLines: string[] = []; + const childDivs = this.sourceTextElement.querySelectorAll("div.milkup-with-line-number"); + childDivs.forEach((div) => { + currentLines.push(div.textContent || ""); + }); + const currentText = currentLines.join("\n"); + + // 只在内容真正变化时更新 + if (currentText !== fullMarkdown) { + // 保存光标位置 + const selection = window.getSelection(); + let cursorOffset = 0; + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + if (range.startContainer.nodeType === Node.TEXT_NODE) { + cursorOffset = range.startOffset; + // 计算相对于整个文本的偏移量 + let node: Node | null = range.startContainer; + while (node && node !== this.sourceTextElement && node.previousSibling) { + node = node.previousSibling; + cursorOffset += node.textContent?.length || 0; + } + } + } + + this.updating = true; + + // 清空容器并重新创建行 + this.sourceTextElement.innerHTML = ""; + lines.forEach((line) => { + const lineDiv = document.createElement("div"); + lineDiv.className = "milkup-with-line-number"; + lineDiv.style.cssText = ` + white-space: pre; + min-height: 1.6em; + `; + lineDiv.textContent = line; + this.sourceTextElement!.appendChild(lineDiv); + }); + + this.updating = false; + + // 恢复光标位置 + if (selection && cursorOffset > 0) { + requestAnimationFrame(() => { + if (!this.sourceTextElement) return; + try { + // 找到光标应该在的位置 + let remainingOffset = cursorOffset; + let targetDiv: HTMLElement | null = null; + let targetOffset = 0; + + const divs = this.sourceTextElement.querySelectorAll("div.milkup-with-line-number"); + for (let i = 0; i < divs.length; i++) { + const div = divs[i] as HTMLElement; + const textNode = div.firstChild; + if (!textNode || textNode.nodeType !== Node.TEXT_NODE) continue; + + const textLength = textNode.textContent?.length || 0; + if (remainingOffset <= textLength) { + targetDiv = div; + targetOffset = remainingOffset; + break; + } + remainingOffset -= textLength + 1; // +1 for newline + } + + if (targetDiv && targetDiv.firstChild) { + const range = document.createRange(); + const textNode = targetDiv.firstChild; + const offset = Math.min(targetOffset, textNode.textContent?.length || 0); + range.setStart(textNode, offset); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + } + } catch (e) { + // 忽略光标恢复错误 + } + }); + } + } + } + + // 更新语言 + if (node.attrs.language !== prevLanguage) { + this.cm.dispatch({ + effects: this.languageCompartment.reconfigure(getLanguageExtension(node.attrs.language)), + }); + } + + // 更新搜索高亮 + this.updateSearchHighlights(); + + return true; + } + + /** + * 设置选区 + */ + setSelection(anchor: number, head: number): void { + this.cm.focus(); + this.cm.dispatch({ + selection: { anchor, head }, + }); + } + + /** + * 选区是否在此节点内 + */ + selectNode(): void { + this.cm.focus(); + } + + /** + * 停止事件传播 + * 只阻止键盘事件,允许鼠标事件传播到自定义组件 + */ + stopEvent(event: Event): boolean { + // 允许头部区域的鼠标事件(下拉选择器) + if (event.target instanceof HTMLElement) { + const isInHeader = event.target.closest(".milkup-code-block-header"); + if (isInHeader) { + return false; + } + } + return true; + } + + /** + * 忽略变更 + */ + ignoreMutation(): boolean { + return true; + } + + /** + * 销毁 + */ + destroy(): void { + this.cm.destroy(); + if (this.mermaidPreview) { + this.mermaidPreview.remove(); + } + if (this.themeObserver) { + this.themeObserver.disconnect(); + this.themeObserver = null; + } + this.hideContextMenu(); + // 取消订阅源码模式状态 + if (this.sourceViewUnsubscribe) { + this.sourceViewUnsubscribe(); + this.sourceViewUnsubscribe = null; + } + } +} + +/** + * 创建代码块 NodeView 工厂函数 + */ +export function createCodeBlockNodeView( + node: ProseMirrorNode, + view: ProseMirrorView, + getPos: () => number | undefined +): CodeBlockView { + return new CodeBlockView(node, view, getPos); +} diff --git a/src/core/nodeviews/code-block.ts.backup b/src/core/nodeviews/code-block.ts.backup new file mode 100644 index 0000000..a96ff2d --- /dev/null +++ b/src/core/nodeviews/code-block.ts.backup @@ -0,0 +1,1541 @@ +/** + * Milkup 代码块 NodeView + * + * 使用 CodeMirror 6 实现代码块编辑 + * 支持语法高亮和 Mermaid 图表预览 + * 支持源码模式显示完整 Markdown 语法 + */ + +import { Node as ProseMirrorNode } from "prosemirror-model"; +import { EditorView as ProseMirrorView, NodeView } from "prosemirror-view"; +import { Selection, TextSelection } from "prosemirror-state"; +import { EditorView, keymap as cmKeymap, ViewUpdate, lineNumbers } from "@codemirror/view"; +import { EditorState as CMEditorState, Compartment, Extension } from "@codemirror/state"; +import { defaultKeymap, history, historyKeymap } from "@codemirror/commands"; +import { syntaxHighlighting, defaultHighlightStyle, HighlightStyle } from "@codemirror/language"; +import { tags } from "@lezer/highlight"; +import { javascript } from "@codemirror/lang-javascript"; +import { python } from "@codemirror/lang-python"; +import { html } from "@codemirror/lang-html"; +import { css } from "@codemirror/lang-css"; +import { json } from "@codemirror/lang-json"; +import { markdown } from "@codemirror/lang-markdown"; +import { sourceViewManager } from "../decorations"; + +/** Mermaid 显示模式 */ +type MermaidDisplayMode = "code" | "mixed" | "diagram"; + +/** 暗色主题高亮样式 */ +const darkHighlightStyle = HighlightStyle.define([ + { tag: tags.keyword, color: "#ff7b72" }, + { tag: tags.operator, color: "#79c0ff" }, + { tag: tags.special(tags.variableName), color: "#ffa657" }, + { tag: tags.typeName, color: "#ffa657" }, + { tag: tags.atom, color: "#79c0ff" }, + { tag: tags.number, color: "#79c0ff" }, + { tag: tags.definition(tags.variableName), color: "#d2a8ff" }, + { tag: tags.string, color: "#a5d6ff" }, + { tag: tags.special(tags.string), color: "#a5d6ff" }, + { tag: tags.comment, color: "#8b949e", fontStyle: "italic" }, + { tag: tags.variableName, color: "#c9d1d9" }, + { tag: tags.tagName, color: "#7ee787" }, + { tag: tags.propertyName, color: "#79c0ff" }, + { tag: tags.attributeName, color: "#79c0ff" }, + { tag: tags.className, color: "#ffa657" }, + { tag: tags.labelName, color: "#d2a8ff" }, + { tag: tags.namespace, color: "#ff7b72" }, + { tag: tags.macroName, color: "#d2a8ff" }, + { tag: tags.literal, color: "#79c0ff" }, + { tag: tags.bool, color: "#79c0ff" }, + { tag: tags.null, color: "#79c0ff" }, + { tag: tags.punctuation, color: "#c9d1d9" }, + { tag: tags.bracket, color: "#c9d1d9" }, + { tag: tags.meta, color: "#8b949e" }, + { tag: tags.link, color: "#58a6ff", textDecoration: "underline" }, + { tag: tags.heading, color: "#79c0ff", fontWeight: "bold" }, + { tag: tags.emphasis, fontStyle: "italic" }, + { tag: tags.strong, fontWeight: "bold" }, + { tag: tags.strikethrough, textDecoration: "line-through" }, +]); + +/** + * 检测当前主题是否为暗色模式 + */ +function detectDarkTheme(): boolean { + const htmlElement = document.documentElement; + const themeClass = Array.from(htmlElement.classList).find((c) => c.startsWith("theme-")); + if (!themeClass) return false; + return themeClass.includes("dark"); +} + +/** + * 获取当前主题名称 + */ +function getCurrentThemeName(): string { + const htmlElement = document.documentElement; + const themeClass = Array.from(htmlElement.classList).find((c) => c.startsWith("theme-")); + return themeClass ? themeClass.replace("theme-", "") : "normal"; +} + +/** + * 创建 CodeMirror 主题扩展(使用 CSS 变量) + */ +function createThemeExtension(isDark: boolean): Extension[] { + const baseTheme = EditorView.theme( + { + "&": { + backgroundColor: "transparent", + color: "var(--text-color)", + }, + ".cm-content": { + caretColor: "var(--text-color)", + }, + ".cm-cursor, .cm-dropCursor": { + borderLeftColor: "var(--text-color)", + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": { + backgroundColor: "var(--selected-background-color)", + }, + ".cm-activeLine": { + backgroundColor: "transparent", + }, + ".cm-gutters": { + backgroundColor: "transparent", + color: "var(--text-color-3)", + border: "none", + }, + ".cm-activeLineGutter": { + backgroundColor: "transparent", + }, + ".cm-lineNumbers .cm-gutterElement": { + color: "var(--text-color-3)", + }, + }, + { dark: isDark } + ); + + const highlightStyle = isDark ? darkHighlightStyle : defaultHighlightStyle; + return [baseTheme, syntaxHighlighting(highlightStyle)]; +} + +/** 语言扩展映射 */ +const languageExtensions: Record any> = { + javascript: javascript, + js: javascript, + typescript: () => javascript({ typescript: true }), + ts: () => javascript({ typescript: true }), + jsx: () => javascript({ jsx: true }), + tsx: () => javascript({ jsx: true, typescript: true }), + python: python, + py: python, + html: html, + css: css, + json: json, + markdown: markdown, + md: markdown, +}; + +/** 语言别名映射(用于显示) */ +const languageAliases: Record = { + js: "javascript", + ts: "typescript", + py: "python", + md: "markdown", +}; + +/** 支持的语言列表 */ +const supportedLanguages = [ + { value: "", label: "plain text" }, + { value: "javascript", label: "JavaScript" }, + { value: "typescript", label: "TypeScript" }, + { value: "python", label: "Python" }, + { value: "html", label: "HTML" }, + { value: "css", label: "CSS" }, + { value: "json", label: "JSON" }, + { value: "markdown", label: "Markdown" }, + { value: "mermaid", label: "Mermaid" }, + { value: "sql", label: "SQL" }, + { value: "bash", label: "Bash" }, + { value: "yaml", label: "YAML" }, + { value: "xml", label: "XML" }, +]; + +/** Mermaid 显示模式选项 */ +const mermaidDisplayModes = [ + { value: "code", label: "代码" }, + { value: "mixed", label: "混合" }, + { value: "diagram", label: "图表" }, +]; + +/** + * 规范化语言名称 + */ +function normalizeLanguage(language: string): string { + const lower = language.toLowerCase(); + return languageAliases[lower] || lower; +} + +/** + * 获取语言扩展 + */ +function getLanguageExtension(language: string): any { + const ext = languageExtensions[language.toLowerCase()]; + return ext ? ext() : []; +} + +/** + * 代码块 NodeView 类 + */ +export class CodeBlockView implements NodeView { + dom: HTMLElement; + cm: EditorView; + node: ProseMirrorNode; + view: ProseMirrorView; + getPos: () => number | undefined; + updating = false; + languageCompartment: Compartment; + themeCompartment: Compartment; + lineNumbersCompartment: Compartment; + mermaidPreview: HTMLElement | null = null; + mermaidDisplayMode: MermaidDisplayMode = "mixed"; + themeObserver: MutationObserver | null = null; + headerElement: HTMLElement | null = null; + editorContainer: HTMLElement | null = null; + contextMenu: HTMLElement | null = null; + sourceTextElement: HTMLElement | null = null; // 源码模式下的纯文本显示元素 + // 源码模式相关 + private sourceViewMode: boolean = false; + private sourceViewUnsubscribe: (() => void) | null = null; + + constructor(node: ProseMirrorNode, view: ProseMirrorView, getPos: () => number | undefined) { + this.node = node; + this.view = view; + this.getPos = getPos; + this.languageCompartment = new Compartment(); + this.themeCompartment = new Compartment(); + this.lineNumbersCompartment = new Compartment(); + + // 检测当前主题 + const isDark = detectDarkTheme(); + + // 规范化语言名称 + const normalizedLang = normalizeLanguage(node.attrs.language); + if (normalizedLang !== node.attrs.language) { + requestAnimationFrame(() => { + const pos = this.getPos(); + if (pos !== undefined) { + this.view.dispatch( + this.view.state.tr.setNodeMarkup(pos, null, { + ...this.node.attrs, + language: normalizedLang, + }) + ); + } + }); + } + + // 创建容器 + this.dom = document.createElement("div"); + this.dom.className = "milkup-code-block"; + + // 创建头部(语言选择器) + this.headerElement = this.createHeader(normalizedLang); + this.dom.appendChild(this.headerElement); + + // 创建 CodeMirror 编辑器容器 + this.editorContainer = document.createElement("div"); + this.editorContainer.className = "milkup-code-block-editor"; + this.dom.appendChild(this.editorContainer); + + this.cm = new EditorView({ + state: CMEditorState.create({ + doc: node.textContent, + extensions: [ + history(), + cmKeymap.of([ + ...defaultKeymap, + ...historyKeymap, + { + key: "Ctrl-Enter", + run: () => { + this.exitCodeBlock(1); + return true; + }, + }, + { + key: "ArrowDown", + run: (cmView) => { + const { state } = cmView; + const { main } = state.selection; + const line = state.doc.lineAt(main.head); + if (line.number === state.doc.lines) { + this.exitCodeBlock(1); + return true; + } + return false; + }, + }, + { + key: "ArrowUp", + run: (cmView) => { + const { state } = cmView; + const { main } = state.selection; + const line = state.doc.lineAt(main.head); + if (line.number === 1) { + this.exitCodeBlock(-1); + return true; + } + return false; + }, + }, + { + key: "ArrowLeft", + run: (cmView) => { + const { state } = cmView; + const { main } = state.selection; + if (main.head === 0 && main.empty) { + this.exitCodeBlock(-1); + return true; + } + return false; + }, + }, + { + key: "ArrowRight", + run: (cmView) => { + const { state } = cmView; + const { main } = state.selection; + if (main.head === state.doc.length && main.empty) { + this.exitCodeBlock(1); + return true; + } + return false; + }, + }, + { + key: "Backspace", + run: (cmView) => { + if (cmView.state.doc.length === 0) { + this.deleteCodeBlock(); + return true; + } + return false; + }, + }, + ]), + this.themeCompartment.of(createThemeExtension(isDark)), + this.languageCompartment.of(getLanguageExtension(normalizedLang)), + this.lineNumbersCompartment.of([]), // 初始不显示行号 + EditorView.updateListener.of((update) => this.onCMUpdate(update)), + EditorView.domEventHandlers({ + focus: () => this.forwardSelection(), + blur: () => {}, + contextmenu: (e) => { + e.preventDefault(); + this.showContextMenu(e); + return true; + }, + }), + ], + }), + parent: this.editorContainer, + }); + + // 监听主题变化 + this.setupThemeObserver(); + + // 创建底部点击区域 + const footer = document.createElement("div"); + footer.className = "milkup-code-block-footer"; + footer.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + this.exitCodeBlock(1); + }); + this.dom.appendChild(footer); + + // Mermaid 预览 + if (normalizedLang === "mermaid") { + this.createMermaidPreview(node.textContent); + } + + // 如果代码块是空的,自动聚焦 + if (!node.textContent) { + requestAnimationFrame(() => { + this.cm.focus(); + }); + } + + // 源码模式初始化 + this.initSourceViewMode(normalizedLang); + } + + /** + * 初始化源码模式 + */ + private initSourceViewMode(language: string): void { + // 订阅源码模式状态变化 + this.sourceViewUnsubscribe = sourceViewManager.subscribe((sourceView) => { + this.setSourceViewMode(sourceView); + }); + } + + /** + * 设置源码模式 + */ + private setSourceViewMode(enabled: boolean): void { + if (this.sourceViewMode === enabled) return; + this.sourceViewMode = enabled; + + if (enabled) { + // 进入源码模式 + this.dom.classList.add("source-view"); + + // 隐藏头部 + if (this.headerElement) { + this.headerElement.style.display = "none"; + } + + // 隐藏 Mermaid 预览 + if (this.mermaidPreview) { + this.mermaidPreview.style.display = "none"; + } + + // 隐藏 CodeMirror + if (this.editorContainer) { + this.editorContainer.style.display = "none"; + } + + // 创建源码容器(整体 contentEditable 元素) + if (!this.sourceTextElement) { + this.sourceTextElement = document.createElement("pre"); + this.sourceTextElement.className = "milkup-code-block-source"; + this.sourceTextElement.contentEditable = "true"; + this.sourceTextElement.spellcheck = false; + this.sourceTextElement.style.cssText = ` + position: relative; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 14px; + line-height: 1.6; + padding: 0; + margin: 0; + color: inherit; + background: transparent; + border: none; + outline: none; + white-space: pre; + overflow-x: auto; + tab-size: 2; + min-height: 1.6em; + `; + + // 监听输入事件 + this.sourceTextElement.addEventListener("input", () => { + this.handleSourceTextInput(); + }); + + // 监听键盘事件 + this.sourceTextElement.addEventListener("keydown", (e) => { + this.handleSourceTextKeydown(e); + }); + + // 阻止默认的粘贴行为,使用纯文本粘贴 + this.sourceTextElement.addEventListener("paste", (e) => { + e.preventDefault(); + const text = e.clipboardData?.getData("text/plain") || ""; + document.execCommand("insertText", false, text); + }); + + this.dom.insertBefore(this.sourceTextElement, this.editorContainer); + } + + // 更新源码内容(包括围栏标记) + const language = this.node.attrs.language || ""; + const content = this.node.textContent; + const fullMarkdown = `\`\`\`${language}\n${content}\n\`\`\``; + + this.sourceTextElement.textContent = fullMarkdown; + + // 计算行数并设置 data 属性,用于 CSS counter-increment + const lineCount = fullMarkdown.split("\n").length; + this.sourceTextElement.setAttribute("data-line-count", lineCount.toString()); + + this.sourceTextElement.style.display = "block"; + } else { + // 退出源码模式 + this.dom.classList.remove("source-view"); + + // 显示 CodeMirror + if (this.editorContainer) { + this.editorContainer.style.display = ""; + } + + // 显示头部 + if (this.headerElement) { + this.headerElement.style.display = ""; + } + + // 隐藏源码文本元素 + if (this.sourceTextElement) { + this.sourceTextElement.style.display = "none"; + } + + // 恢复 Mermaid 预览 + if (this.mermaidPreview && this.node.attrs.language === "mermaid") { + this.updateMermaidDisplay(); + } + } + } + + /** + * 更新源码内容 + */ + private updateSourceContent(): void { + if (!this.sourceTextElement) return; + + const language = this.node.attrs.language || ""; + const content = this.node.textContent; + const fullMarkdown = `\`\`\`${language}\n${content}\n\`\`\``; + const lines = fullMarkdown.split("\n"); + + // 保存当前焦点的行索引和光标位置 + let focusedLineIndex = -1; + let cursorOffset = 0; + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + const focusedElement = range.startContainer.parentElement; + if (focusedElement) { + const lineElements = this.sourceTextElement.querySelectorAll(".milkup-code-block-source-line"); + lineElements.forEach((el, index) => { + if (el === focusedElement || el.contains(focusedElement)) { + focusedLineIndex = index; + if (range.startContainer.nodeType === Node.TEXT_NODE) { + cursorOffset = range.startOffset; + } + } + }); + } + } + + // 清空容器 + this.sourceTextElement.innerHTML = ""; + + // 为每一行创建一个元素,使其能够参与全局行号计数 + lines.forEach((line, index) => { + const lineWrapper = document.createElement("div"); + lineWrapper.className = "milkup-with-line-number milkup-code-block-source-line"; + lineWrapper.style.cssText = ` + position: relative; + white-space: pre; + min-height: 1.6em; + `; + + const lineContent = document.createElement("span"); + lineContent.contentEditable = "true"; + lineContent.textContent = line; + lineContent.style.cssText = ` + outline: none; + display: inline-block; + width: 100%; + `; + + // 监听每一行的输入事件 + lineContent.addEventListener("input", () => { + this.handleSourceLineInput(); + }); + + // 监听键盘事件 + lineContent.addEventListener("keydown", (e) => { + this.handleSourceLineKeydown(e, index, lines.length); + }); + + // 监听鼠标事件以支持跨行选择 + lineContent.addEventListener("mousedown", (e) => { + this.handleSourceLineMouseDown(e); + }); + + lineWrapper.appendChild(lineContent); + this.sourceTextElement!.appendChild(lineWrapper); + }); + + // 恢复焦点和光标位置 + if (focusedLineIndex >= 0 && focusedLineIndex < lines.length) { + requestAnimationFrame(() => { + const lineElements = this.sourceTextElement!.querySelectorAll(".milkup-code-block-source-line span"); + const targetLine = lineElements[focusedLineIndex] as HTMLElement; + if (targetLine) { + targetLine.focus(); + const textNode = targetLine.firstChild; + if (textNode && textNode.nodeType === Node.TEXT_NODE && selection) { + try { + const range = document.createRange(); + const offset = Math.min(cursorOffset, textNode.textContent?.length || 0); + range.setStart(textNode, offset); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + } catch (e) { + // 忽略光标恢复错误 + } + } + } + }); + } + } + + /** + * 处理源码行输入 + */ + private handleSourceLineInput(): void { + if (!this.sourceTextElement || this.updating) return; + + // 收集所有行的内容 + const lines: string[] = []; + const lineElements = this.sourceTextElement.querySelectorAll(".milkup-code-block-source-line span"); + lineElements.forEach((el) => { + lines.push(el.textContent || ""); + }); + + const text = lines.join("\n"); + const pos = this.getPos(); + if (pos === undefined) return; + + this.updating = true; + + // 检查是否是完整的代码块格式(必须有开头和结尾的 ```) + const fenceMatch = text.match(/^```([^\n]*?)\n([\s\S]*?)\n```$/); + + if (fenceMatch) { + // 仍然是完整的代码块格式,更新内容 + const [, language, content] = fenceMatch; + const tr = this.view.state.tr; + let needsUpdate = false; + + // 更新语言属性 + const normalizedLang = language || ""; + if (normalizedLang !== this.node.attrs.language) { + tr.setNodeMarkup(pos, null, { + ...this.node.attrs, + language: normalizedLang, + }); + needsUpdate = true; + } + + // 更新内容 + if (content !== this.node.textContent) { + const start = pos + 1; + const end = pos + 1 + this.node.content.size; + tr.replaceWith( + start, + end, + content ? this.view.state.schema.text(content) : [] + ); + needsUpdate = true; + } + + if (needsUpdate) { + this.view.dispatch(tr); + } + } else { + // 不是完整的代码块格式,转换为段落 + const tr = this.view.state.tr; + const nodeEnd = pos + this.node.nodeSize; + + // 删除代码块节点 + tr.delete(pos, nodeEnd); + + // 将文本内容转换为段落 + if (text.trim()) { + const textLines = text.split("\n").filter((line) => line.trim()); + const paragraphs = textLines.map((line) => + this.view.state.schema.nodes.paragraph.create( + null, + line ? this.view.state.schema.text(line) : null + ) + ); + + if (paragraphs.length === 0) { + paragraphs.push(this.view.state.schema.nodes.paragraph.create()); + } + + tr.insert(pos, paragraphs); + const newPos = pos + 1; + tr.setSelection(TextSelection.create(tr.doc, newPos)); + } else { + const paragraph = this.view.state.schema.nodes.paragraph.create(); + tr.insert(pos, paragraph); + const newPos = pos + 1; + tr.setSelection(TextSelection.create(tr.doc, newPos)); + } + + this.view.dispatch(tr); + this.view.focus(); + } + + this.updating = false; + } + + /** + * 处理源码行键盘事件 + */ + private handleSourceLineKeydown(e: KeyboardEvent, lineIndex: number, totalLines: number): void { + if (e.key === "Enter") { + e.preventDefault(); + // 在当前行后插入新行 + const lineWrapper = document.createElement("div"); + lineWrapper.className = "milkup-with-line-number milkup-code-block-source-line"; + lineWrapper.style.cssText = ` + position: relative; + white-space: pre; + min-height: 1.6em; + `; + + const lineContent = document.createElement("span"); + lineContent.contentEditable = "true"; + lineContent.textContent = ""; + lineContent.style.cssText = ` + outline: none; + display: inline-block; + width: 100%; + `; + + lineContent.addEventListener("input", () => { + this.handleSourceLineInput(); + }); + + lineContent.addEventListener("keydown", (e) => { + const newIndex = Array.from(this.sourceTextElement!.querySelectorAll(".milkup-code-block-source-line")).indexOf(lineWrapper); + const newTotal = this.sourceTextElement!.querySelectorAll(".milkup-code-block-source-line").length; + this.handleSourceLineKeydown(e, newIndex, newTotal); + }); + + lineContent.addEventListener("mousedown", (e) => { + this.handleSourceLineMouseDown(e); + }); + + lineWrapper.appendChild(lineContent); + + const currentLine = (e.target as HTMLElement).parentElement; + if (currentLine && currentLine.nextSibling) { + this.sourceTextElement!.insertBefore(lineWrapper, currentLine.nextSibling); + } else { + this.sourceTextElement!.appendChild(lineWrapper); + } + + lineContent.focus(); + this.handleSourceLineInput(); + } else if (e.key === "Backspace") { + const target = e.target as HTMLElement; + if (target.textContent === "" && totalLines > 1) { + e.preventDefault(); + const currentLine = target.parentElement; + const prevLine = currentLine?.previousElementSibling; + if (currentLine) { + currentLine.remove(); + if (prevLine) { + const prevContent = prevLine.querySelector("span"); + if (prevContent) { + prevContent.focus(); + const range = document.createRange(); + const sel = window.getSelection(); + range.selectNodeContents(prevContent); + range.collapse(false); + sel?.removeAllRanges(); + sel?.addRange(range); + } + } + this.handleSourceLineInput(); + } + } + } else if (e.key === "Tab") { + e.preventDefault(); + document.execCommand("insertText", false, " "); + } + } + + /** + * 处理源码行鼠标按下事件(用于跨行选择) + */ + private handleSourceLineMouseDown(e: MouseEvent): void { + // 允许默认的选择行为 + // 浏览器会自动处理跨元素的文本选择 + } + + /** + * 处理源码文本输入 + */ + private handleSourceTextInput(): void { + if (!this.sourceTextElement || this.updating) return; + + const text = this.sourceTextElement.textContent || ""; + const pos = this.getPos(); + if (pos === undefined) return; + + // 保存光标位置 + const selection = window.getSelection(); + let cursorOffset = 0; + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + if (range.startContainer.nodeType === Node.TEXT_NODE) { + cursorOffset = range.startOffset; + // 计算相对于整个文本的偏移量 + let node: Node | null = range.startContainer; + while (node && node !== this.sourceTextElement && node.previousSibling) { + node = node.previousSibling; + cursorOffset += node.textContent?.length || 0; + } + } + } + + this.updating = true; + + // 检查是否是完整的代码块格式(必须有开头和结尾的 ```) + const fenceMatch = text.match(/^```([^\n]*?)\n([\s\S]*?)\n```$/); + + if (fenceMatch) { + // 仍然是完整的代码块格式,更新内容 + const [, language, content] = fenceMatch; + const tr = this.view.state.tr; + let needsUpdate = false; + + // 更新语言属性 + const normalizedLang = language || ""; + if (normalizedLang !== this.node.attrs.language) { + tr.setNodeMarkup(pos, null, { + ...this.node.attrs, + language: normalizedLang, + }); + needsUpdate = true; + } + + // 更新内容 + if (content !== this.node.textContent) { + const start = pos + 1; + const end = pos + 1 + this.node.content.size; + tr.replaceWith( + start, + end, + content ? this.view.state.schema.text(content) : [] + ); + needsUpdate = true; + } + + if (needsUpdate) { + this.view.dispatch(tr); + } + + // 更新行数 + const lineCount = text.split("\n").length; + this.sourceTextElement.setAttribute("data-line-count", lineCount.toString()); + } else { + // 不是完整的代码块格式,转换为段落 + const tr = this.view.state.tr; + const nodeEnd = pos + this.node.nodeSize; + + tr.delete(pos, nodeEnd); + + if (text.trim()) { + const textLines = text.split("\n").filter((line) => line.trim()); + const paragraphs = textLines.map((line) => + this.view.state.schema.nodes.paragraph.create( + null, + line ? this.view.state.schema.text(line) : null + ) + ); + + if (paragraphs.length === 0) { + paragraphs.push(this.view.state.schema.nodes.paragraph.create()); + } + + tr.insert(pos, paragraphs); + const newPos = pos + 1; + tr.setSelection(TextSelection.create(tr.doc, newPos)); + } else { + const paragraph = this.view.state.schema.nodes.paragraph.create(); + tr.insert(pos, paragraph); + const newPos = pos + 1; + tr.setSelection(TextSelection.create(tr.doc, newPos)); + } + + this.view.dispatch(tr); + this.view.focus(); + } + + this.updating = false; + + // 如果仍然是代码块,恢复光标位置 + if (fenceMatch) { + requestAnimationFrame(() => { + if (this.sourceTextElement && selection) { + try { + const textNode = this.sourceTextElement.firstChild; + if (textNode && textNode.nodeType === Node.TEXT_NODE) { + const range = document.createRange(); + const offset = Math.min(cursorOffset, textNode.textContent?.length || 0); + range.setStart(textNode, offset); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + } + } catch (e) { + // 忽略光标恢复错误 + } + } + }); + } + } + + /** + * 处理源码文本键盘事件 + */ + private handleSourceTextKeydown(e: KeyboardEvent): void { + // 允许基本的编辑操作 + if (e.key === "Tab") { + e.preventDefault(); + // 插入两个空格 + document.execCommand("insertText", false, " "); + } + } + + /** + * 创建头部(语言选择器和 Mermaid 模式选择器) + */ + private createHeader(language: string): HTMLElement { + const header = document.createElement("div"); + header.className = "milkup-code-block-header"; + + // 语言选择器 + const langSelector = this.createCustomSelect(supportedLanguages, language, (value) => + this.setLanguage(value) + ); + langSelector.classList.add("milkup-code-block-lang-select"); + header.appendChild(langSelector); + + // Mermaid 模式选择器(仅在 mermaid 语言时显示) + if (language === "mermaid") { + const modeSelector = this.createCustomSelect( + mermaidDisplayModes, + this.mermaidDisplayMode, + (value) => this.setMermaidDisplayMode(value as MermaidDisplayMode) + ); + modeSelector.classList.add("milkup-code-block-mode-select"); + header.appendChild(modeSelector); + } + + return header; + } + + /** + * 创建自定义下拉选择器 + */ + private createCustomSelect( + options: { value: string; label: string }[], + currentValue: string, + onChange: (value: string) => void + ): HTMLElement { + const container = document.createElement("div"); + container.className = "milkup-custom-select"; + + const button = document.createElement("button"); + button.className = "milkup-custom-select-button"; + button.type = "button"; + const currentOption = options.find((o) => o.value === currentValue); + button.textContent = currentOption?.label || options[0].label; + + const dropdown = document.createElement("div"); + dropdown.className = "milkup-custom-select-dropdown"; + + for (const option of options) { + const item = document.createElement("div"); + item.className = "milkup-custom-select-item"; + if (option.value === currentValue) { + item.classList.add("selected"); + } + item.textContent = option.label; + item.dataset.value = option.value; + item.addEventListener("click", (e) => { + e.stopPropagation(); + button.textContent = option.label; + // 更新选中状态 + dropdown.querySelectorAll(".milkup-custom-select-item").forEach((el) => { + el.classList.remove("selected"); + }); + item.classList.add("selected"); + container.classList.remove("open"); + onChange(option.value); + }); + dropdown.appendChild(item); + } + + button.addEventListener("click", (e) => { + e.stopPropagation(); + // 关闭所有其他下拉框 + document.querySelectorAll(".milkup-custom-select.open").forEach((el) => { + if (el !== container) { + el.classList.remove("open"); + } + }); + + // 检测是否需要向上弹出 + const buttonRect = button.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + const spaceBelow = viewportHeight - buttonRect.bottom; + const dropdownHeight = 240; // 最大高度 + + if (spaceBelow < dropdownHeight && buttonRect.top > spaceBelow) { + container.classList.add("dropup"); + } else { + container.classList.remove("dropup"); + } + + container.classList.toggle("open"); + }); + + // 点击外部关闭 + document.addEventListener("click", () => { + container.classList.remove("open"); + }); + + container.appendChild(button); + container.appendChild(dropdown); + return container; + } + + /** + * 显示右键菜单 + */ + private async showContextMenu(e: MouseEvent): Promise { + // 移除已存在的右键菜单 + this.hideContextMenu(); + + const menu = document.createElement("div"); + menu.className = "milkup-context-menu"; + + // 检查是否有选区 + const { main } = this.cm.state.selection; + const hasSelection = !main.empty; + + // 检查剪贴板是否有内容(文本或图片) + let hasClipboardContent = true; // 默认启用粘贴 + try { + const items = await navigator.clipboard.read(); + hasClipboardContent = items.length > 0; + } catch { + // 如果 read() 不支持,尝试 readText() + try { + const text = await navigator.clipboard.readText(); + hasClipboardContent = text.length > 0; + } catch { + hasClipboardContent = true; // 默认启用粘贴 + } + } + + // 复制 + const copyItem = this.createContextMenuItem("复制", !hasSelection, () => { + const selectedText = this.cm.state.sliceDoc(main.from, main.to); + navigator.clipboard.writeText(selectedText); + this.hideContextMenu(); + }); + menu.appendChild(copyItem); + + // 剪切 + const cutItem = this.createContextMenuItem("剪切", !hasSelection, () => { + const selectedText = this.cm.state.sliceDoc(main.from, main.to); + navigator.clipboard.writeText(selectedText); + this.cm.dispatch({ + changes: { from: main.from, to: main.to, insert: "" }, + }); + this.hideContextMenu(); + }); + menu.appendChild(cutItem); + + // 粘贴 - 使用 Clipboard API 读取文本 + const pasteItem = this.createContextMenuItem("粘贴", !hasClipboardContent, async () => { + this.hideContextMenu(); + this.cm.focus(); + try { + const text = await navigator.clipboard.readText(); + if (text) { + const { main } = this.cm.state.selection; + this.cm.dispatch({ + changes: { from: main.from, to: main.to, insert: text }, + }); + } + } catch { + console.warn("无法访问剪贴板"); + } + }); + menu.appendChild(pasteItem); + + // 定位菜单 + menu.style.left = `${e.clientX}px`; + menu.style.top = `${e.clientY}px`; + + document.body.appendChild(menu); + this.contextMenu = menu; + + // 调整位置,确保菜单在视口内 + const menuRect = menu.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + if (menuRect.right > viewportWidth) { + menu.style.left = `${viewportWidth - menuRect.width - 8}px`; + } + if (menuRect.bottom > viewportHeight) { + menu.style.top = `${viewportHeight - menuRect.height - 8}px`; + } + + // 点击外部关闭 + const closeHandler = (event: MouseEvent) => { + if (!menu.contains(event.target as Node)) { + this.hideContextMenu(); + document.removeEventListener("click", closeHandler); + } + }; + setTimeout(() => { + document.addEventListener("click", closeHandler); + }, 0); + } + + /** + * 创建右键菜单项 + */ + private createContextMenuItem( + label: string, + disabled: boolean, + onClick: () => void + ): HTMLElement { + const item = document.createElement("div"); + item.className = "milkup-context-menu-item"; + if (disabled) { + item.classList.add("disabled"); + } + item.textContent = label; + + if (!disabled) { + item.addEventListener("click", (e) => { + e.stopPropagation(); + onClick(); + }); + } + + return item; + } + + /** + * 隐藏右键菜单 + */ + private hideContextMenu(): void { + if (this.contextMenu) { + this.contextMenu.remove(); + this.contextMenu = null; + } + // 移除其他可能存在的右键菜单 + document.querySelectorAll(".milkup-context-menu").forEach((el) => el.remove()); + } + + /** + * 设置主题观察器 + */ + private setupThemeObserver(): void { + const htmlElement = document.documentElement; + + this.themeObserver = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type === "attributes" && mutation.attributeName === "class") { + const isDark = detectDarkTheme(); + this.updateTheme(isDark); + // 更新 Mermaid 预览 + if (this.node.attrs.language === "mermaid" && this.mermaidPreview) { + this.createMermaidPreview(this.cm.state.doc.toString()); + } + } + } + }); + + this.themeObserver.observe(htmlElement, { + attributes: true, + attributeFilter: ["class"], + }); + } + + /** + * 更新主题 + */ + private updateTheme(isDark: boolean): void { + this.cm.dispatch({ + effects: this.themeCompartment.reconfigure(createThemeExtension(isDark)), + }); + } + + /** + * 设置语言 + */ + private setLanguage(language: string): void { + const pos = this.getPos(); + if (pos === undefined) return; + + const prevLanguage = this.node.attrs.language; + + // 更新 ProseMirror 节点属性 + this.view.dispatch( + this.view.state.tr.setNodeMarkup(pos, null, { + ...this.node.attrs, + language, + }) + ); + + // 更新 CodeMirror 语言扩展 + this.cm.dispatch({ + effects: this.languageCompartment.reconfigure(getLanguageExtension(language)), + }); + + // 更新头部(添加或移除 Mermaid 模式选择器) + if ((prevLanguage === "mermaid") !== (language === "mermaid")) { + this.updateHeader(language); + } + + // 更新 Mermaid 预览 + if (language === "mermaid") { + this.createMermaidPreview(this.cm.state.doc.toString()); + } else if (this.mermaidPreview) { + this.mermaidPreview.remove(); + this.mermaidPreview = null; + } + } + + /** + * 更新头部 + */ + private updateHeader(language: string): void { + if (this.headerElement) { + const newHeader = this.createHeader(language); + this.dom.replaceChild(newHeader, this.headerElement); + this.headerElement = newHeader; + } + } + + /** + * 设置 Mermaid 显示模式 + */ + private setMermaidDisplayMode(mode: MermaidDisplayMode): void { + this.mermaidDisplayMode = mode; + this.updateMermaidDisplay(); + } + + /** + * 更新 Mermaid 显示 + */ + private updateMermaidDisplay(): void { + if (!this.editorContainer || !this.mermaidPreview) return; + + switch (this.mermaidDisplayMode) { + case "code": + this.editorContainer.style.display = "block"; + this.mermaidPreview.style.display = "none"; + break; + case "diagram": + this.editorContainer.style.display = "none"; + this.mermaidPreview.style.display = "block"; + break; + case "mixed": + default: + this.editorContainer.style.display = "block"; + this.mermaidPreview.style.display = "block"; + break; + } + } + + /** + * 创建 Mermaid 预览 + */ + private async createMermaidPreview(content: string): Promise { + if (!this.mermaidPreview) { + this.mermaidPreview = document.createElement("div"); + this.mermaidPreview.className = "milkup-mermaid-preview"; + this.dom.appendChild(this.mermaidPreview); + } + + try { + const mermaid = await import("mermaid"); + const isDark = detectDarkTheme(); + const themeName = getCurrentThemeName(); + + // 根据主题选择 Mermaid 主题 + let mermaidTheme = "default"; + if (isDark) { + mermaidTheme = "dark"; + } else if (themeName === "frame") { + mermaidTheme = "forest"; + } + + mermaid.default.initialize({ + startOnLoad: false, + theme: mermaidTheme, + }); + + const { svg } = await mermaid.default.render(`mermaid-${Date.now()}`, content); + this.mermaidPreview.innerHTML = svg; + } catch (error) { + this.mermaidPreview.innerHTML = `
Mermaid 渲染错误
`; + } + + this.updateMermaidDisplay(); + } + + /** + * CodeMirror 更新回调 + */ + private onCMUpdate(update: ViewUpdate): void { + if (this.updating) return; + + if (update.docChanged) { + const pos = this.getPos(); + if (pos === undefined) return; + + const newText = update.state.doc.toString(); + + // 更新 ProseMirror 文档 + const tr = this.view.state.tr; + const start = pos + 1; + const end = pos + 1 + this.node.content.size; + + tr.replaceWith(start, end, newText ? this.view.state.schema.text(newText) : []); + + this.view.dispatch(tr); + + // 更新 Mermaid 预览 + if (this.node.attrs.language === "mermaid") { + this.createMermaidPreview(newText); + } + } + } + + /** + * 转发选区到 ProseMirror + */ + private forwardSelection(): void { + const pos = this.getPos(); + if (pos === undefined) return; + + const { from, to } = this.cm.state.selection.main; + const start = pos + 1 + from; + const end = pos + 1 + to; + + const selection = TextSelection.create(this.view.state.doc, start, end); + + if (!this.view.state.selection.eq(selection)) { + this.view.dispatch(this.view.state.tr.setSelection(selection)); + } + } + + /** + * 跳出代码块 + */ + private exitCodeBlock(direction: 1 | -1): void { + const pos = this.getPos(); + if (pos === undefined) return; + + const { state } = this.view; + const nodeEnd = pos + this.node.nodeSize; + + if (direction === 1) { + const isLastNode = nodeEnd >= state.doc.content.size; + + if (isLastNode) { + const paragraph = state.schema.nodes.paragraph.create(); + const tr = state.tr.insert(nodeEnd, paragraph); + tr.setSelection(TextSelection.create(tr.doc, nodeEnd + 1)); + this.view.dispatch(tr); + this.view.focus(); + return; + } + + const selection = Selection.near(state.doc.resolve(nodeEnd), 1); + this.view.dispatch(state.tr.setSelection(selection)); + this.view.focus(); + } else { + const selection = Selection.near(state.doc.resolve(pos), -1); + this.view.dispatch(state.tr.setSelection(selection)); + this.view.focus(); + } + } + + /** + * 删除代码块 + */ + private deleteCodeBlock(): void { + const pos = this.getPos(); + if (pos === undefined) return; + + const { state } = this.view; + const nodeEnd = pos + this.node.nodeSize; + + const tr = state.tr.delete(pos, nodeEnd); + + if (tr.doc.content.size === 0) { + const paragraph = state.schema.nodes.paragraph.create(); + tr.insert(0, paragraph); + tr.setSelection(TextSelection.create(tr.doc, 1)); + } else { + const $pos = tr.doc.resolve(Math.min(pos, tr.doc.content.size)); + tr.setSelection(Selection.near($pos, -1)); + } + + this.view.dispatch(tr); + this.view.focus(); + } + + /** + * 更新节点 + */ + update(node: ProseMirrorNode): boolean { + if (node.type !== this.node.type) return false; + + const prevLanguage = this.node.attrs.language; + this.node = node; + const newText = node.textContent; + + if (newText !== this.cm.state.doc.toString()) { + this.updating = true; + this.cm.dispatch({ + changes: { + from: 0, + to: this.cm.state.doc.length, + insert: newText, + }, + }); + this.updating = false; + } + + // 更新源码模式下的文本内容 + if (this.sourceViewMode && this.sourceTextElement) { + const language = node.attrs.language || ""; + const content = node.textContent; + const fullMarkdown = `\`\`\`${language}\n${content}\n\`\`\``; + + // 只在内容真正变化时更新 + if (this.sourceTextElement.textContent !== fullMarkdown) { + // 保存光标位置 + const selection = window.getSelection(); + let cursorOffset = 0; + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + const textNode = this.sourceTextElement.firstChild; + if (textNode && textNode.nodeType === Node.TEXT_NODE && range.startContainer === textNode) { + cursorOffset = range.startOffset; + } + } + + this.updating = true; + this.sourceTextElement.textContent = fullMarkdown; + + // 更新行数 + const lineCount = fullMarkdown.split("\n").length; + this.sourceTextElement.setAttribute("data-line-count", lineCount.toString()); + + this.updating = false; + + // 恢复光标位置 + if (selection && cursorOffset > 0) { + requestAnimationFrame(() => { + const textNode = this.sourceTextElement!.firstChild; + if (textNode && textNode.nodeType === Node.TEXT_NODE) { + try { + const range = document.createRange(); + const offset = Math.min(cursorOffset, textNode.textContent?.length || 0); + range.setStart(textNode, offset); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + } catch (e) { + // 忽略光标恢复错误 + } + } + }); + } + } + } + + // 更新语言 + if (node.attrs.language !== prevLanguage) { + this.cm.dispatch({ + effects: this.languageCompartment.reconfigure(getLanguageExtension(node.attrs.language)), + }); + } + + return true; + } + + /** + * 设置选区 + */ + setSelection(anchor: number, head: number): void { + this.cm.focus(); + this.cm.dispatch({ + selection: { anchor, head }, + }); + } + + /** + * 选区是否在此节点内 + */ + selectNode(): void { + this.cm.focus(); + } + + /** + * 停止事件传播 + * 只阻止键盘事件,允许鼠标事件传播到自定义组件 + */ + stopEvent(event: Event): boolean { + // 允许头部区域的鼠标事件(下拉选择器) + if (event.target instanceof HTMLElement) { + const isInHeader = event.target.closest(".milkup-code-block-header"); + if (isInHeader) { + return false; + } + } + return true; + } + + /** + * 忽略变更 + */ + ignoreMutation(): boolean { + return true; + } + + /** + * 销毁 + */ + destroy(): void { + this.cm.destroy(); + if (this.mermaidPreview) { + this.mermaidPreview.remove(); + } + if (this.themeObserver) { + this.themeObserver.disconnect(); + this.themeObserver = null; + } + this.hideContextMenu(); + // 取消订阅源码模式状态 + if (this.sourceViewUnsubscribe) { + this.sourceViewUnsubscribe(); + this.sourceViewUnsubscribe = null; + } + } +} + +/** + * 创建代码块 NodeView 工厂函数 + */ +export function createCodeBlockNodeView( + node: ProseMirrorNode, + view: ProseMirrorView, + getPos: () => number | undefined +): CodeBlockView { + return new CodeBlockView(node, view, getPos); +} diff --git a/src/core/nodeviews/html-block.ts b/src/core/nodeviews/html-block.ts new file mode 100644 index 0000000..d97a13c --- /dev/null +++ b/src/core/nodeviews/html-block.ts @@ -0,0 +1,448 @@ +/** + * Milkup HTML 块 NodeView + * + * 渲染 HTML 内容,支持编辑模式和预览模式切换 + * 编辑模式使用 CodeMirror 6 + HTML 语法高亮 + */ + +import { Node as ProseMirrorNode } from "prosemirror-model"; +import { EditorView as ProseMirrorView, NodeView } from "prosemirror-view"; +import { Selection, TextSelection } from "prosemirror-state"; +import { EditorView, keymap as cmKeymap, ViewUpdate } from "@codemirror/view"; +import { EditorState as CMEditorState, Compartment } from "@codemirror/state"; +import { defaultKeymap, history, historyKeymap } from "@codemirror/commands"; +import { html } from "@codemirror/lang-html"; +import { createThemeExtension, detectDarkTheme } from "./code-block"; + +// 存储所有 HtmlBlockView 实例,用于全局更新 +const htmlBlockViews = new Set(); + +/** + * 更新所有 HTML 块的编辑状态 + */ +export function updateAllHtmlBlocks(view: ProseMirrorView): void { + const { from, to } = view.state.selection; + for (const htmlView of htmlBlockViews) { + htmlView.updateEditingState(from, to); + } +} + +/** + * 危险元素黑名单 + */ +const DANGEROUS_ELEMENTS = new Set([ + "script", + "style", + "iframe", + "object", + "embed", + "applet", + "form", + "base", + "link", + "meta", + "noscript", + "template", + "frame", + "frameset", +]); + +/** + * 危险 URL 协议 + */ +const DANGEROUS_URL_RE = /^\s*(javascript|vbscript|data)\s*:/i; + +/** + * 递归清理 DOM 节点 + */ +function sanitizeNode(node: Node): void { + const toRemove: Node[] = []; + + node.childNodes.forEach((child) => { + if (child.nodeType === Node.ELEMENT_NODE) { + const el = child as Element; + const tag = el.tagName.toLowerCase(); + + // 移除危险元素 + if (DANGEROUS_ELEMENTS.has(tag)) { + toRemove.push(child); + return; + } + + // 移除所有事件处理器属性 (on*) + const attrs = Array.from(el.attributes); + for (const attr of attrs) { + if (attr.name.toLowerCase().startsWith("on")) { + el.removeAttribute(attr.name); + } + } + + // 清理危险 URL 属性 + for (const urlAttr of ["href", "src", "action", "formaction", "xlink:href"]) { + const val = el.getAttribute(urlAttr); + if (val && DANGEROUS_URL_RE.test(val)) { + el.removeAttribute(urlAttr); + } + } + + // 递归处理子节点 + sanitizeNode(child); + } + }); + + for (const child of toRemove) { + node.removeChild(child); + } +} + +/** + * 对 HTML 内容进行安全处理(DOM 解析 + 黑名单过滤) + * 保留样式属性和安全的 HTML 结构,移除脚本和事件处理器 + */ +function sanitizeHtml(htmlContent: string): DocumentFragment { + const doc = new DOMParser().parseFromString(htmlContent, "text/html"); + sanitizeNode(doc.body); + const fragment = document.createDocumentFragment(); + while (doc.body.firstChild) { + fragment.appendChild(doc.body.firstChild); + } + return fragment; +} + +/** + * HTML 块 NodeView + */ +export class HtmlBlockView implements NodeView { + dom: HTMLElement; + private cm: EditorView; + private node: ProseMirrorNode; + private view: ProseMirrorView; + private getPos: () => number | undefined; + private updating = false; + private isEditing = false; + private preview: HTMLElement; + private header: HTMLElement; + private editorContainer: HTMLElement; + private themeCompartment: Compartment; + private themeObserver: MutationObserver | null = null; + + constructor(node: ProseMirrorNode, view: ProseMirrorView, getPos: () => number | undefined) { + this.node = node; + this.view = view; + this.getPos = getPos; + this.themeCompartment = new Compartment(); + htmlBlockViews.add(this); + + const isDark = detectDarkTheme(); + + // 创建容器 + this.dom = document.createElement("div"); + this.dom.className = "milkup-html-block"; + + // 创建 header(固定显示 "HTML") + this.header = document.createElement("div"); + this.header.className = "milkup-html-block-header"; + const label = document.createElement("span"); + label.className = "milkup-html-block-label"; + label.textContent = "HTML"; + this.header.appendChild(label); + this.dom.appendChild(this.header); + + // 创建预览区域 + this.preview = document.createElement("div"); + this.preview.className = "milkup-html-block-preview"; + this.dom.appendChild(this.preview); + + // 创建编辑器容器 + this.editorContainer = document.createElement("div"); + this.editorContainer.className = "milkup-html-block-editor"; + this.dom.appendChild(this.editorContainer); + + // 创建 CodeMirror 编辑器 + this.cm = new EditorView({ + state: CMEditorState.create({ + doc: node.textContent, + extensions: [ + history(), + cmKeymap.of([ + { + key: "Ctrl-Enter", + run: () => { + this.exitBlock(1); + return true; + }, + }, + { + key: "ArrowDown", + run: (cmView) => { + const { main } = cmView.state.selection; + const line = cmView.state.doc.lineAt(main.head); + if (line.number === cmView.state.doc.lines) { + this.exitBlock(1); + return true; + } + return false; + }, + }, + { + key: "ArrowUp", + run: (cmView) => { + const { main } = cmView.state.selection; + const line = cmView.state.doc.lineAt(main.head); + if (line.number === 1) { + this.exitBlock(-1); + return true; + } + return false; + }, + }, + { + key: "Backspace", + run: (cmView) => { + if (cmView.state.doc.length === 0) { + this.deleteBlock(); + return true; + } + return false; + }, + }, + ...defaultKeymap, + ...historyKeymap, + ]), + this.themeCompartment.of(createThemeExtension(isDark)), + html(), + EditorView.updateListener.of((update) => this.onCMUpdate(update)), + EditorView.domEventHandlers({ + focus: () => this.forwardSelection(), + }), + ], + }), + parent: this.editorContainer, + }); + + // 初始渲染 + this.updatePreview(node.textContent); + this.setEditing(false); + + // 点击预览区域进入编辑模式 + this.preview.addEventListener("click", () => this.enterEditMode()); + + // 初始检查光标位置 + const { from, to } = view.state.selection; + this.updateEditingState(from, to); + + // 监听主题变化 + this.setupThemeObserver(); + } + + private updatePreview(content: string): void { + this.preview.innerHTML = ""; + if (content.trim()) { + const fragment = sanitizeHtml(content); + this.preview.appendChild(fragment); + } else { + this.preview.innerHTML = '输入 HTML...'; + } + } + + updateEditingState(selFrom: number, selTo: number): void { + const pos = this.getPos(); + if (pos === undefined) return; + const node = this.view.state.doc.nodeAt(pos); + if (!node) return; + + const nodeEnd = pos + node.nodeSize; + const cursorInNode = selFrom >= pos && selTo <= nodeEnd; + + if (cursorInNode && !this.isEditing) { + this.setEditing(true); + } else if (!cursorInNode && this.isEditing) { + this.setEditing(false); + } + } + + private setEditing(editing: boolean): void { + this.isEditing = editing; + if (editing) { + this.dom.classList.add("editing"); + this.header.style.display = ""; + this.editorContainer.style.display = ""; + this.preview.style.display = "none"; + } else { + this.dom.classList.remove("editing"); + this.header.style.display = "none"; + this.editorContainer.style.display = "none"; + this.preview.style.display = ""; + // 更新预览 + this.updatePreview(this.node.textContent); + } + } + + private enterEditMode(): void { + if (this.isEditing) return; + this.setEditing(true); + const pos = this.getPos(); + if (pos !== undefined) { + const tr = this.view.state.tr.setSelection( + Selection.near(this.view.state.doc.resolve(pos + 1)) + ); + this.view.dispatch(tr); + this.view.focus(); + } + } + + private onCMUpdate(update: ViewUpdate): void { + if (this.updating) return; + if (update.docChanged) { + const pos = this.getPos(); + if (pos === undefined) return; + const newText = update.state.doc.toString(); + const tr = this.view.state.tr; + const start = pos + 1; + const end = pos + 1 + this.node.content.size; + tr.replaceWith(start, end, newText ? this.view.state.schema.text(newText) : []); + this.view.dispatch(tr); + } + } + + private forwardSelection(): void { + const pos = this.getPos(); + if (pos === undefined) return; + const { from, to } = this.cm.state.selection.main; + const start = pos + 1 + from; + const end = pos + 1 + to; + const selection = TextSelection.create(this.view.state.doc, start, end); + if (!this.view.state.selection.eq(selection)) { + this.view.dispatch(this.view.state.tr.setSelection(selection)); + } + } + + private exitBlock(direction: 1 | -1): void { + const pos = this.getPos(); + if (pos === undefined) return; + const { state } = this.view; + const nodeEnd = pos + this.node.nodeSize; + + if (direction === 1) { + if (nodeEnd >= state.doc.content.size) { + const paragraph = state.schema.nodes.paragraph.create(); + const tr = state.tr.insert(nodeEnd, paragraph); + tr.setSelection(TextSelection.create(tr.doc, nodeEnd + 1)); + this.view.dispatch(tr); + this.view.focus(); + return; + } + const selection = Selection.near(state.doc.resolve(nodeEnd), 1); + this.view.dispatch(state.tr.setSelection(selection)); + this.view.focus(); + } else { + const selection = Selection.near(state.doc.resolve(pos), -1); + if (selection.from >= pos) { + const paragraph = state.schema.nodes.paragraph.create(); + const tr = state.tr.insert(pos, paragraph); + tr.setSelection(TextSelection.create(tr.doc, pos + 1)); + this.view.dispatch(tr); + this.view.focus(); + return; + } + this.view.dispatch(state.tr.setSelection(selection)); + this.view.focus(); + } + } + + private deleteBlock(): void { + const pos = this.getPos(); + if (pos === undefined) return; + const { state } = this.view; + const nodeEnd = pos + this.node.nodeSize; + const tr = state.tr.delete(pos, nodeEnd); + if (tr.doc.content.size === 0) { + const paragraph = state.schema.nodes.paragraph.create(); + tr.insert(0, paragraph); + tr.setSelection(TextSelection.create(tr.doc, 1)); + } else { + const $pos = tr.doc.resolve(Math.min(pos, tr.doc.content.size)); + tr.setSelection(Selection.near($pos, -1)); + } + this.view.dispatch(tr); + this.view.focus(); + } + + private setupThemeObserver(): void { + this.themeObserver = new MutationObserver(() => { + const isDark = detectDarkTheme(); + this.cm.dispatch({ + effects: this.themeCompartment.reconfigure(createThemeExtension(isDark)), + }); + }); + this.themeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + } + + update(node: ProseMirrorNode): boolean { + if (node.type.name !== "html_block") return false; + this.node = node; + const newText = node.textContent; + + if (newText !== this.cm.state.doc.toString()) { + this.updating = true; + this.cm.dispatch({ + changes: { from: 0, to: this.cm.state.doc.length, insert: newText }, + }); + this.updating = false; + } + + // 更新预览(仅在非编辑模式下) + if (!this.isEditing) { + this.updatePreview(newText); + } + + return true; + } + + setSelection(anchor: number, head: number): void { + if (!this.isEditing) { + this.setEditing(true); + } + this.cm.focus(); + this.cm.dispatch({ selection: { anchor, head } }); + } + + selectNode(): void { + this.setEditing(true); + this.cm.focus(); + } + + deselectNode(): void { + // 由 updateEditingState 统一处理 + } + + stopEvent(): boolean { + return true; + } + + ignoreMutation(): boolean { + return true; + } + + destroy(): void { + htmlBlockViews.delete(this); + this.cm.destroy(); + if (this.themeObserver) { + this.themeObserver.disconnect(); + } + } +} + +/** + * 创建 HTML 块 NodeView + */ +export function createHtmlBlockNodeView( + node: ProseMirrorNode, + view: ProseMirrorView, + getPos: () => number | undefined +): NodeView { + return new HtmlBlockView(node, view, getPos); +} diff --git a/src/core/nodeviews/image.ts b/src/core/nodeviews/image.ts new file mode 100644 index 0000000..ae51f5b --- /dev/null +++ b/src/core/nodeviews/image.ts @@ -0,0 +1,697 @@ +/** + * Milkup 图片 NodeView + * + * 支持编辑模式和预览模式切换 + * 聚焦时同时显示图片和源码,离开时只显示图片 + * 源码可编辑,编辑后自动更新图片属性 + * 源码位置根据光标进入方向动态调整 + * 支持源码模式只显示原始 Markdown 文本 + */ + +import { Node } from "prosemirror-model"; +import { EditorView, NodeView } from "prosemirror-view"; +import { NodeSelection } from "prosemirror-state"; +import { sourceViewManager } from "../decorations"; + +// 存储所有 ImageView 实例,用于全局更新 +const imageViews = new Set(); + +/** + * 在浏览器端简单解析目录路径 + */ +function dirname(filePath: string): string { + const sep = filePath.includes("\\") ? "\\" : "/"; + const lastIndex = filePath.lastIndexOf(sep); + return lastIndex === -1 ? "." : filePath.substring(0, lastIndex); +} + +/** + * 在浏览器端简单拼接路径并规范化为正斜杠 + */ +function joinPath(dir: string, relative: string): string { + const sep = dir.includes("\\") ? "\\" : "/"; + let rel = relative; + while (rel.startsWith("./") || rel.startsWith(".\\")) { + rel = rel.substring(2); + } + return (dir + sep + rel).replace(/\\/g, "/"); +} + +/** + * 将相对路径转换为 file:// URL,仅用于 DOM 渲染 + * BrowserWindow 已设置 webSecurity: false,可直接加载 file:// URL + * 不修改 ProseMirror 模型的 attrs.src + */ +function resolveImageSrc(src: string): string { + if (!src) return src; + + // 跳过已知协议和绝对路径 + if ( + src.startsWith("http://") || + src.startsWith("https://") || + src.startsWith("file://") || + src.startsWith("data:") || + src.startsWith("milkup://") || + /^(?:[a-z]:[\\/]|\\\\|\/)/i.test(src) + ) { + return src; + } + + // 获取当前文件路径 + const currentFilePath = (window as any).__currentFilePath; + if (!currentFilePath) return src; + + // 解析为绝对路径并转为 file:// URL + const absolutePath = joinPath(dirname(currentFilePath), src); + return "file:///" + absolutePath; +} + +// 记录上一次光标位置,用于判断进入方向 +let lastCursorPos = 0; + +/** + * 更新所有图片的编辑状态 + */ +export function updateAllImages(view: EditorView): void { + const { from, to } = view.state.selection; + const selection = view.state.selection; + + for (const imageView of imageViews) { + imageView.updateEditingState(from, to, selection, lastCursorPos); + } + + // 更新上一次光标位置 + lastCursorPos = from; +} + +/** + * 解析图片 Markdown 语法 + * 格式: ![alt](src "title") + */ +function parseImageMarkdown(markdown: string): { src: string; alt: string; title: string } | null { + const match = markdown.match(/^!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)$/); + if (!match) return null; + return { + alt: match[1] || "", + src: match[2] || "", + title: match[3] || "", + }; +} + +/** + * 图片 NodeView + * + * 图片是原子节点,不使用 contentDOM + * 源码可编辑,编辑后自动更新图片属性 + * 支持源码模式只显示原始 Markdown 文本 + */ +export class ImageView implements NodeView { + dom: HTMLElement; + private imgElement: HTMLElement; + private sourceContainer: HTMLElement; + private sourceInput: HTMLInputElement; + private view: EditorView; + private getPos: () => number | undefined; + private isEditing: boolean = false; + private node: Node; + private sourcePosition: "before" | "after" = "after"; + // 源码模式相关 + private sourceViewMode: boolean = false; + private sourceViewUnsubscribe: (() => void) | null = null; + private sourceTextElement: HTMLElement | null = null; + + constructor(node: Node, view: EditorView, getPos: () => number | undefined) { + this.view = view; + this.getPos = getPos; + this.node = node; + + // 注册到全局集合 + imageViews.add(this); + + // 创建容器 + this.dom = document.createElement("div"); + this.dom.className = "milkup-image-block"; + + // 创建图片元素 + this.imgElement = document.createElement("div"); + this.imgElement.className = "milkup-image-preview"; + this.dom.appendChild(this.imgElement); + + // 创建源码容器(编辑模式下显示) + this.sourceContainer = document.createElement("div"); + this.sourceContainer.className = "milkup-image-source-container"; + this.dom.appendChild(this.sourceContainer); + + // 创建源码输入框 + this.sourceInput = document.createElement("input"); + this.sourceInput.type = "text"; + this.sourceInput.className = "milkup-image-source-input"; + this.sourceInput.draggable = false; // 禁止拖动 + this.sourceContainer.appendChild(this.sourceInput); + + // 禁止容器拖动 + this.dom.draggable = false; + this.sourceContainer.draggable = false; + + // 阻止源码容器的拖动事件 + this.sourceContainer.addEventListener("dragstart", (e) => { + e.preventDefault(); + e.stopPropagation(); + }); + this.sourceContainer.addEventListener("mousedown", (e) => { + // 阻止事件冒泡到 ProseMirror,防止触发节点拖动 + e.stopPropagation(); + }); + + // 初始渲染 + this.updateContent(node); + + // 点击图片进入编辑模式 + this.imgElement.addEventListener("click", (e) => { + e.preventDefault(); + this.selectThisNode(); + }); + + // 源码输入框事件 + this.sourceInput.addEventListener("blur", () => { + this.applySourceChange(); + }); + + // 实时响应源码变化 + this.sourceInput.addEventListener("input", () => { + this.previewSourceChange(); + }); + + this.sourceInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + this.applySourceChange(); + // 移动光标到下一行 + const pos = this.getPos(); + if (pos !== undefined) { + const { state } = this.view; + const $pos = state.doc.resolve(pos + this.node.nodeSize); + const tr = state.tr.setSelection(state.selection.constructor.near($pos)); + this.view.dispatch(tr); + this.view.focus(); + } + } else if (e.key === "Escape") { + e.preventDefault(); + // 恢复原始值 + this.updateSourceInput(); + this.view.focus(); + } else if (e.key === "Backspace") { + // 当输入框为空时,删除整个图片节点 + if ( + this.sourceInput.value === "" || + (this.sourceInput.selectionStart === 0 && this.sourceInput.selectionEnd === 0) + ) { + if (this.sourceInput.value === "") { + e.preventDefault(); + this.deleteImageNode(); + } + } + } else if (e.key === "ArrowUp") { + e.preventDefault(); + this.applySourceChange(); + // 移动光标到图片之前 + const pos = this.getPos(); + if (pos !== undefined) { + const { state } = this.view; + const $pos = state.doc.resolve(pos); + const tr = state.tr.setSelection(state.selection.constructor.near($pos, -1)); + this.view.dispatch(tr); + this.view.focus(); + } + } else if (e.key === "ArrowDown") { + e.preventDefault(); + this.applySourceChange(); + // 移动光标到图片之后 + const pos = this.getPos(); + if (pos !== undefined) { + const { state } = this.view; + const $pos = state.doc.resolve(pos + this.node.nodeSize); + const tr = state.tr.setSelection(state.selection.constructor.near($pos, 1)); + this.view.dispatch(tr); + this.view.focus(); + } + } + }); + + // 源码模式初始化 + this.initSourceViewMode(); + } + + /** + * 初始化源码模式 + */ + private initSourceViewMode(): void { + // 创建源码文本元素(源码模式下显示) + this.sourceTextElement = document.createElement("div"); + this.sourceTextElement.className = "milkup-image-source-text"; + this.sourceTextElement.contentEditable = "true"; + this.sourceTextElement.spellcheck = false; + this.updateSourceText(); + + // 源码文本编辑事件 + this.sourceTextElement.addEventListener("input", () => { + this.handleSourceTextInput(); + }); + + this.sourceTextElement.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + this.applySourceTextChange(); + // 移动光标到下一行 + const pos = this.getPos(); + if (pos !== undefined) { + const { state } = this.view; + const $pos = state.doc.resolve(pos + this.node.nodeSize); + const tr = state.tr.setSelection(state.selection.constructor.near($pos)); + this.view.dispatch(tr); + this.view.focus(); + } + } else if (e.key === "Backspace") { + const text = this.sourceTextElement?.textContent || ""; + const selection = window.getSelection(); + if ( + text === "" || + (selection && selection.anchorOffset === 0 && selection.focusOffset === 0) + ) { + if (text === "") { + e.preventDefault(); + this.deleteImageNode(); + } + } + } + }); + + this.sourceTextElement.addEventListener("blur", () => { + this.applySourceTextChange(); + }); + + // 订阅源码模式状态变化 + this.sourceViewUnsubscribe = sourceViewManager.subscribe((sourceView) => { + this.setSourceViewMode(sourceView); + }); + } + + /** + * 更新源码文本 + */ + private updateSourceText(): void { + if (!this.sourceTextElement) return; + const { src, alt, title } = this.node.attrs; + let markdown = `![${alt}](${src}`; + if (title) { + markdown += ` "${title}"`; + } + markdown += ")"; + this.sourceTextElement.textContent = markdown; + } + + /** + * 处理源码文本输入 + */ + private handleSourceTextInput(): void { + // 实时预览不需要做什么,因为源码模式下不显示图片 + } + + /** + * 应用源码文本变更 + */ + private applySourceTextChange(): void { + if (!this.sourceTextElement) return; + const newMarkdown = this.sourceTextElement.textContent?.trim() || ""; + const parsed = parseImageMarkdown(newMarkdown); + + if (!parsed) { + // 解析失败,恢复原始值 + this.updateSourceText(); + return; + } + + const pos = this.getPos(); + if (pos === undefined) return; + + const { state } = this.view; + const { src, alt, title } = this.node.attrs; + + // 检查是否有变化 + if (parsed.src === src && parsed.alt === alt && parsed.title === title) { + return; + } + + // 更新节点属性 + const tr = state.tr.setNodeMarkup(pos, undefined, { + src: parsed.src, + alt: parsed.alt, + title: parsed.title, + }); + this.view.dispatch(tr); + } + + /** + * 设置源码模式 + */ + private setSourceViewMode(enabled: boolean): void { + if (this.sourceViewMode === enabled) return; + this.sourceViewMode = enabled; + + if (enabled) { + // 进入源码模式 + this.dom.classList.add("source-view"); + // 隐藏图片预览 + this.imgElement.style.display = "none"; + // 隐藏编辑模式的源码容器 + this.sourceContainer.style.display = "none"; + // 显示源码文本 + if (this.sourceTextElement) { + this.updateSourceText(); + this.dom.appendChild(this.sourceTextElement); + } + } else { + // 退出源码模式 + this.dom.classList.remove("source-view"); + // 显示图片预览 + this.imgElement.style.display = ""; + // 恢复编辑模式的源码容器显示状态 + this.sourceContainer.style.display = ""; + // 移除源码文本 + if (this.sourceTextElement && this.sourceTextElement.parentNode) { + this.sourceTextElement.remove(); + } + } + } + + update(node: Node): boolean { + if (node.type.name !== "image") return false; + this.node = node; + this.updateContent(node); + // 源码模式下也更新源码文本 + if (this.sourceViewMode) { + this.updateSourceText(); + } + return true; + } + + private updateContent(node: Node): void { + const { src, alt, title } = node.attrs; + this.renderImage(src, alt, title); + + // 更新源码输入框(仅在非编辑状态下更新,避免覆盖用户输入) + if (!this.isEditing) { + this.updateSourceInput(); + } + } + + /** + * 渲染图片 + */ + private renderImage(src: string, alt: string, title?: string): void { + // 清空容器 + this.imgElement.innerHTML = ""; + + if (!src) { + this.showImagePlaceholder("请输入图片地址"); + return; + } + + const img = document.createElement("img"); + // 将相对路径转为 milkup:// 协议 URL 仅用于 DOM 渲染 + img.src = resolveImageSrc(src); + img.alt = alt; + if (title) img.title = title; + img.onerror = () => { + this.showImageError(src); + }; + this.imgElement.appendChild(img); + } + + /** + * 显示图片加载失败占位 + */ + private showImageError(src: string): void { + this.imgElement.innerHTML = ""; + const placeholder = document.createElement("div"); + placeholder.className = "milkup-image-placeholder milkup-image-error-placeholder"; + placeholder.innerHTML = ` + 🖼️ + 图片加载失败 + ${this.escapeHtml(src)} + `; + this.imgElement.appendChild(placeholder); + } + + /** + * 显示图片占位 + */ + private showImagePlaceholder(text: string): void { + this.imgElement.innerHTML = ""; + const placeholder = document.createElement("div"); + placeholder.className = "milkup-image-placeholder"; + placeholder.innerHTML = ` + 🖼️ + ${this.escapeHtml(text)} + `; + this.imgElement.appendChild(placeholder); + } + + /** + * 转义 HTML + */ + private escapeHtml(text: string): string { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + } + + private updateSourceInput(): void { + const { src, alt, title } = this.node.attrs; + let markdown = `![${alt}](${src}`; + if (title) { + markdown += ` "${title}"`; + } + markdown += ")"; + this.sourceInput.value = markdown; + } + + /** + * 实时预览源码变化(不更新 ProseMirror 状态) + */ + private previewSourceChange(): void { + const newMarkdown = this.sourceInput.value.trim(); + const parsed = parseImageMarkdown(newMarkdown); + + if (parsed) { + // 实时更新图片预览 + this.renderImage(parsed.src, parsed.alt, parsed.title); + } else { + // 语法不完整时显示占位 + this.showImagePlaceholder("请输入完整的图片语法"); + } + } + + /** + * 应用源码变更(更新 ProseMirror 状态) + */ + private applySourceChange(): void { + const newMarkdown = this.sourceInput.value.trim(); + const parsed = parseImageMarkdown(newMarkdown); + + if (!parsed) { + // 解析失败,恢复原始值 + this.updateSourceInput(); + return; + } + + const pos = this.getPos(); + if (pos === undefined) return; + + const { state } = this.view; + const { src, alt, title } = this.node.attrs; + + // 检查是否有变化 + if (parsed.src === src && parsed.alt === alt && parsed.title === title) { + return; + } + + // 更新节点属性 + const tr = state.tr.setNodeMarkup(pos, undefined, { + src: parsed.src, + alt: parsed.alt, + title: parsed.title, + }); + this.view.dispatch(tr); + } + + /** + * 删除图片节点 + */ + private deleteImageNode(): void { + const pos = this.getPos(); + if (pos === undefined) return; + + const { state } = this.view; + const tr = state.tr.delete(pos, pos + this.node.nodeSize); + + // 如果删除后文档为空,创建一个空段落 + if (tr.doc.content.size === 0) { + const paragraph = state.schema.nodes.paragraph.create(); + tr.insert(0, paragraph); + } + + this.view.dispatch(tr); + this.view.focus(); + } + + /** + * 选中此节点 + */ + private selectThisNode(): void { + const pos = this.getPos(); + if (pos === undefined) return; + + const { state } = this.view; + const selection = NodeSelection.create(state.doc, pos); + + const tr = state.tr.setSelection(selection); + this.view.dispatch(tr); + this.view.focus(); + } + + /** + * 根据光标位置更新编辑状态 + */ + updateEditingState(selFrom: number, selTo: number, selection: any, prevCursorPos: number): void { + const pos = this.getPos(); + if (pos === undefined) return; + + const node = this.view.state.doc.nodeAt(pos); + if (!node) return; + + const nodeStart = pos; + const nodeEnd = pos + node.nodeSize; + + // 只有 NodeSelection 选中此节点时才进入编辑模式 + const isSelected = selection instanceof NodeSelection && selection.from === pos; + + if (isSelected && !this.isEditing) { + // 判断进入方向:从前方还是后方进入 + const enterFromBefore = prevCursorPos <= nodeStart; + this.setSourcePosition(enterFromBefore ? "before" : "after"); + this.setEditing(true); + } else if (!isSelected && this.isEditing) { + this.setEditing(false); + } + } + + /** + * 设置源码位置 + */ + private setSourcePosition(position: "before" | "after"): void { + if (this.sourcePosition === position) return; + this.sourcePosition = position; + + // 调整 DOM 顺序 + if (position === "before") { + this.dom.insertBefore(this.sourceContainer, this.imgElement); + this.dom.classList.add("source-before"); + this.dom.classList.remove("source-after"); + } else { + this.dom.appendChild(this.sourceContainer); + this.dom.classList.remove("source-before"); + this.dom.classList.add("source-after"); + } + } + + private setEditing(editing: boolean): void { + this.isEditing = editing; + if (editing) { + this.dom.classList.add("editing"); + // 自动聚焦到输入框 + requestAnimationFrame(() => { + this.sourceInput.focus(); + // 根据进入方向设置光标位置 + if (this.sourcePosition === "before") { + // 从上方进入,光标在开头 + this.sourceInput.setSelectionRange(0, 0); + } else { + // 从下方进入,光标在末尾 + const len = this.sourceInput.value.length; + this.sourceInput.setSelectionRange(len, len); + } + // 确保输入框在可视范围内 + this.sourceInput.scrollIntoView({ behavior: "smooth", block: "nearest" }); + }); + } else { + this.dom.classList.remove("editing"); + } + } + + selectNode(): void { + // 点击选中时,默认源码在下方 + this.setSourcePosition("after"); + this.setEditing(true); + } + + deselectNode(): void { + this.setEditing(false); + } + + stopEvent(event: Event): boolean { + // 允许输入框接收所有事件 + if (event.target === this.sourceInput) { + // 阻止拖动事件 + if (event.type === "dragstart" || event.type === "drag") { + event.preventDefault(); + return true; + } + return true; + } + // 允许源码文本元素接收所有事件 + if (event.target === this.sourceTextElement) { + if (event.type === "dragstart" || event.type === "drag") { + event.preventDefault(); + return true; + } + return true; + } + // 阻止源码容器的拖动 + if ( + event.target === this.sourceContainer || + this.sourceContainer.contains(event.target as Node) + ) { + if (event.type === "dragstart" || event.type === "drag") { + event.preventDefault(); + return true; + } + } + return false; + } + + ignoreMutation(): boolean { + return true; + } + + destroy(): void { + // 从全局集合中移除 + imageViews.delete(this); + // 取消订阅源码模式状态 + if (this.sourceViewUnsubscribe) { + this.sourceViewUnsubscribe(); + this.sourceViewUnsubscribe = null; + } + } +} + +/** + * 创建图片 NodeView + */ +export function createImageNodeView( + node: Node, + view: EditorView, + getPos: () => number | undefined +): NodeView { + return new ImageView(node, view, getPos); +} diff --git a/src/core/nodeviews/index.ts b/src/core/nodeviews/index.ts new file mode 100644 index 0000000..e8e1c4d --- /dev/null +++ b/src/core/nodeviews/index.ts @@ -0,0 +1,27 @@ +/** + * Milkup NodeView 导出 + */ + +export { CodeBlockView, createCodeBlockNodeView, setGlobalMermaidDefaultMode } from "./code-block"; +export { + MathBlockView, + createMathBlockNodeView, + renderInlineMath, + isKaTeXAvailable, + preloadKaTeX, + updateAllMathBlocks, +} from "./math-block"; +export { ImageView, createImageNodeView, updateAllImages } from "./image"; +export { + BulletListView, + OrderedListView, + ListItemView, + TaskListView, + TaskItemView, + createBulletListNodeView, + createOrderedListNodeView, + createListItemNodeView, + createTaskListNodeView, + createTaskItemNodeView, + updateAllLists, +} from "./list"; diff --git a/src/core/nodeviews/list.ts b/src/core/nodeviews/list.ts new file mode 100644 index 0000000..4f5b459 --- /dev/null +++ b/src/core/nodeviews/list.ts @@ -0,0 +1,583 @@ +/** + * Milkup 列表 NodeView + * + * 支持源码模式显示原始 Markdown 标记 + * 保持缩进结构 + */ + +import { Node as ProseMirrorNode } from "prosemirror-model"; +import { EditorView as ProseMirrorView, NodeView } from "prosemirror-view"; +import { sourceViewManager } from "../decorations"; + +// 存储所有列表视图实例 +const listViews = new Set(); + +/** + * 更新所有列表的源码模式状态 + */ +export function updateAllLists(sourceView: boolean): void { + for (const view of listViews) { + view.setSourceViewMode(sourceView); + } +} + +/** + * 无序列表 NodeView + */ +export class BulletListView implements NodeView { + dom: HTMLElement; + contentDOM: HTMLElement; + private node: ProseMirrorNode; + private view: ProseMirrorView; + private getPos: () => number | undefined; + private sourceViewMode: boolean = false; + private sourceViewUnsubscribe: (() => void) | null = null; + + constructor(node: ProseMirrorNode, view: ProseMirrorView, getPos: () => number | undefined) { + this.node = node; + this.view = view; + this.getPos = getPos; + + // 注册到全局集合 + listViews.add(this); + + // 创建容器 + this.dom = document.createElement("ul"); + this.dom.className = "milkup-bullet-list"; + this.contentDOM = this.dom; + + // 订阅源码模式状态变化 + this.sourceViewUnsubscribe = sourceViewManager.subscribe((sourceView) => { + this.setSourceViewMode(sourceView); + }); + } + + setSourceViewMode(enabled: boolean): void { + if (this.sourceViewMode === enabled) return; + this.sourceViewMode = enabled; + + if (enabled) { + this.dom.classList.add("source-view"); + } else { + this.dom.classList.remove("source-view"); + } + } + + update(node: ProseMirrorNode): boolean { + if (node.type.name !== "bullet_list") return false; + this.node = node; + return true; + } + + ignoreMutation(mutation: MutationRecord): boolean { + // 忽略 class 属性变化 + if (mutation.type === "attributes" && mutation.attributeName === "class") { + return true; + } + return false; + } + + destroy(): void { + listViews.delete(this); + if (this.sourceViewUnsubscribe) { + this.sourceViewUnsubscribe(); + this.sourceViewUnsubscribe = null; + } + } +} + +/** + * 有序列表 NodeView + */ +export class OrderedListView implements NodeView { + dom: HTMLElement; + contentDOM: HTMLElement; + private node: ProseMirrorNode; + private view: ProseMirrorView; + private getPos: () => number | undefined; + private sourceViewMode: boolean = false; + private sourceViewUnsubscribe: (() => void) | null = null; + + constructor(node: ProseMirrorNode, view: ProseMirrorView, getPos: () => number | undefined) { + this.node = node; + this.view = view; + this.getPos = getPos; + + // 注册到全局集合 + listViews.add(this); + + // 创建容器 + this.dom = document.createElement("ol"); + this.dom.className = "milkup-ordered-list"; + if (node.attrs.start !== 1) { + this.dom.setAttribute("start", String(node.attrs.start)); + } + this.contentDOM = this.dom; + + // 订阅源码模式状态变化 + this.sourceViewUnsubscribe = sourceViewManager.subscribe((sourceView) => { + this.setSourceViewMode(sourceView); + }); + } + + setSourceViewMode(enabled: boolean): void { + if (this.sourceViewMode === enabled) return; + this.sourceViewMode = enabled; + + if (enabled) { + this.dom.classList.add("source-view"); + } else { + this.dom.classList.remove("source-view"); + } + } + + update(node: ProseMirrorNode): boolean { + if (node.type.name !== "ordered_list") return false; + this.node = node; + if (node.attrs.start !== 1) { + this.dom.setAttribute("start", String(node.attrs.start)); + } else { + this.dom.removeAttribute("start"); + } + return true; + } + + ignoreMutation(mutation: MutationRecord): boolean { + // 忽略 class 属性变化 + if (mutation.type === "attributes" && mutation.attributeName === "class") { + return true; + } + return false; + } + + destroy(): void { + listViews.delete(this); + if (this.sourceViewUnsubscribe) { + this.sourceViewUnsubscribe(); + this.sourceViewUnsubscribe = null; + } + } +} + +/** + * 列表项 NodeView + */ +export class ListItemView implements NodeView { + dom: HTMLElement; + contentDOM: HTMLElement; + private node: ProseMirrorNode; + private view: ProseMirrorView; + private getPos: () => number | undefined; + private sourceViewMode: boolean = false; + private sourceViewUnsubscribe: (() => void) | null = null; + private markerElement: HTMLElement | null = null; + + constructor(node: ProseMirrorNode, view: ProseMirrorView, getPos: () => number | undefined) { + this.node = node; + this.view = view; + this.getPos = getPos; + + // 创建容器 + this.dom = document.createElement("li"); + this.dom.className = "milkup-list-item"; + + // 创建标记元素(源码模式下显示) + this.markerElement = document.createElement("span"); + this.markerElement.className = "milkup-list-marker"; + this.updateMarker(); + + // 创建内容容器 + this.contentDOM = document.createElement("div"); + this.contentDOM.className = "milkup-list-item-content"; + this.dom.appendChild(this.contentDOM); + + // 订阅源码模式状态变化 + this.sourceViewUnsubscribe = sourceViewManager.subscribe((sourceView) => { + this.setSourceViewMode(sourceView); + }); + } + + /** + * 更新标记文本 + */ + private updateMarker(): void { + if (!this.markerElement) return; + + const pos = this.getPos(); + if (pos === undefined) return; + + // 获取父列表类型和索引 + const $pos = this.view.state.doc.resolve(pos); + const parent = $pos.parent; + const index = $pos.index(); + + let markerText = "- "; + if (parent.type.name === "bullet_list") { + markerText = "- "; + } else if (parent.type.name === "ordered_list") { + const start = parent.attrs.start || 1; + markerText = `${start + index}. `; + } + + this.markerElement.textContent = markerText; + // 如果标记已在 DOM 中,测量实际宽度 + this.updateMarkerWidth(); + } + + /** + * 测量标记元素的实际像素宽度,设置 CSS 自定义属性 + * 延迟到下一帧测量,避免在批量 DOM 变更时触发 layout thrashing + */ + private updateMarkerWidth(): void { + if (!this.markerElement) return; + requestAnimationFrame(() => { + if (!this.markerElement || !this.markerElement.parentNode) return; + const width = this.markerElement.getBoundingClientRect().width; + if (width > 0) { + this.dom.style.setProperty("--marker-width", `${width}px`); + } + }); + } + + setSourceViewMode(enabled: boolean): void { + if (this.sourceViewMode === enabled) return; + this.sourceViewMode = enabled; + + if (enabled) { + this.dom.classList.add("source-view"); + // 在内容前插入标记 + if (this.markerElement && this.contentDOM) { + this.updateMarker(); + this.dom.insertBefore(this.markerElement, this.contentDOM); + } + } else { + this.dom.classList.remove("source-view"); + // 移除标记 + if (this.markerElement && this.markerElement.parentNode) { + this.markerElement.remove(); + } + } + } + + update(node: ProseMirrorNode): boolean { + if (node.type.name !== "list_item") return false; + this.node = node; + // 更新标记 + if (this.sourceViewMode) { + this.updateMarker(); + } + return true; + } + + ignoreMutation(mutation: MutationRecord): boolean { + // 忽略 class 和 style 属性变化 + if ( + mutation.type === "attributes" && + (mutation.attributeName === "class" || mutation.attributeName === "style") + ) { + return true; + } + // 忽略标记元素上的变化 + if (mutation.target === this.markerElement) { + return true; + } + // 忽略 dom 上的子节点变化(添加/移除标记元素) + if (mutation.type === "childList" && mutation.target === this.dom) { + return true; + } + return false; + } + + destroy(): void { + if (this.sourceViewUnsubscribe) { + this.sourceViewUnsubscribe(); + this.sourceViewUnsubscribe = null; + } + } +} + +/** + * 创建无序列表 NodeView + */ +export function createBulletListNodeView( + node: ProseMirrorNode, + view: ProseMirrorView, + getPos: () => number | undefined +): BulletListView { + return new BulletListView(node, view, getPos); +} + +/** + * 创建有序列表 NodeView + */ +export function createOrderedListNodeView( + node: ProseMirrorNode, + view: ProseMirrorView, + getPos: () => number | undefined +): OrderedListView { + return new OrderedListView(node, view, getPos); +} + +/** + * 创建列表项 NodeView + */ +export function createListItemNodeView( + node: ProseMirrorNode, + view: ProseMirrorView, + getPos: () => number | undefined +): ListItemView { + return new ListItemView(node, view, getPos); +} + +/** + * 任务列表 NodeView + */ +export class TaskListView implements NodeView { + dom: HTMLElement; + contentDOM: HTMLElement; + private node: ProseMirrorNode; + private view: ProseMirrorView; + private getPos: () => number | undefined; + private sourceViewMode: boolean = false; + private sourceViewUnsubscribe: (() => void) | null = null; + + constructor(node: ProseMirrorNode, view: ProseMirrorView, getPos: () => number | undefined) { + this.node = node; + this.view = view; + this.getPos = getPos; + + // 注册到全局集合 + listViews.add(this); + + // 创建容器 + this.dom = document.createElement("ul"); + this.dom.className = "milkup-task-list"; + this.contentDOM = this.dom; + + // 订阅源码模式状态变化 + this.sourceViewUnsubscribe = sourceViewManager.subscribe((sourceView) => { + this.setSourceViewMode(sourceView); + }); + } + + setSourceViewMode(enabled: boolean): void { + if (this.sourceViewMode === enabled) return; + this.sourceViewMode = enabled; + + if (enabled) { + this.dom.classList.add("source-view"); + } else { + this.dom.classList.remove("source-view"); + } + } + + update(node: ProseMirrorNode): boolean { + if (node.type.name !== "task_list") return false; + this.node = node; + return true; + } + + ignoreMutation(mutation: MutationRecord): boolean { + if (mutation.type === "attributes" && mutation.attributeName === "class") { + return true; + } + return false; + } + + destroy(): void { + listViews.delete(this); + if (this.sourceViewUnsubscribe) { + this.sourceViewUnsubscribe(); + this.sourceViewUnsubscribe = null; + } + } +} + +/** + * 任务列表项 NodeView + */ +export class TaskItemView implements NodeView { + dom: HTMLElement; + contentDOM: HTMLElement; + private node: ProseMirrorNode; + private view: ProseMirrorView; + private getPos: () => number | undefined; + private sourceViewMode: boolean = false; + private sourceViewUnsubscribe: (() => void) | null = null; + private markerElement: HTMLElement | null = null; + private checkboxElement: HTMLElement | null = null; + + constructor(node: ProseMirrorNode, view: ProseMirrorView, getPos: () => number | undefined) { + this.node = node; + this.view = view; + this.getPos = getPos; + + // 创建容器 + this.dom = document.createElement("li"); + this.dom.className = "milkup-task-item"; + + // 创建自定义复选框(即时渲染模式下显示) + this.checkboxElement = document.createElement("span"); + this.checkboxElement.className = "milkup-task-checkbox"; + this.checkboxElement.setAttribute("role", "checkbox"); + this.checkboxElement.setAttribute("aria-checked", String(node.attrs.checked)); + if (node.attrs.checked) { + this.checkboxElement.classList.add("checked"); + } + this.checkboxElement.addEventListener("mousedown", (e) => { + e.preventDefault(); + const pos = this.getPos(); + if (pos === undefined) return; + const newChecked = !this.node.attrs.checked; + const tr = this.view.state.tr.setNodeMarkup(pos, undefined, { + ...this.node.attrs, + checked: newChecked, + }); + this.view.dispatch(tr); + }); + this.dom.appendChild(this.checkboxElement); + + // 创建标记元素(源码模式下显示) + this.markerElement = document.createElement("span"); + this.markerElement.className = "milkup-list-marker"; + this.updateMarker(); + + // 创建内容容器 + this.contentDOM = document.createElement("div"); + this.contentDOM.className = "milkup-list-item-content"; + this.dom.appendChild(this.contentDOM); + + // 订阅源码模式状态变化 + this.sourceViewUnsubscribe = sourceViewManager.subscribe((sourceView) => { + this.setSourceViewMode(sourceView); + }); + } + + /** + * 更新复选框视觉状态 + */ + private updateCheckbox(): void { + if (!this.checkboxElement) return; + const checked = this.node.attrs.checked; + this.checkboxElement.setAttribute("aria-checked", String(checked)); + if (checked) { + this.checkboxElement.classList.add("checked"); + } else { + this.checkboxElement.classList.remove("checked"); + } + } + + /** + * 更新标记文本 + */ + private updateMarker(): void { + if (!this.markerElement) return; + const checked = this.node.attrs.checked; + this.markerElement.textContent = checked ? "- [x] " : "- [] "; + this.updateMarkerWidth(); + } + + /** + * 测量标记元素的实际像素宽度,设置 CSS 自定义属性 + * 延迟到下一帧测量,避免在批量 DOM 变更时触发 layout thrashing + */ + private updateMarkerWidth(): void { + if (!this.markerElement) return; + requestAnimationFrame(() => { + if (!this.markerElement || !this.markerElement.parentNode) return; + const width = this.markerElement.getBoundingClientRect().width; + if (width > 0) { + this.dom.style.setProperty("--marker-width", `${width}px`); + } + }); + } + + setSourceViewMode(enabled: boolean): void { + if (this.sourceViewMode === enabled) return; + this.sourceViewMode = enabled; + + if (enabled) { + this.dom.classList.add("source-view"); + // 隐藏复选框,显示标记 + if (this.checkboxElement) { + this.checkboxElement.style.display = "none"; + } + if (this.markerElement && this.contentDOM) { + this.updateMarker(); + this.dom.insertBefore(this.markerElement, this.contentDOM); + } + } else { + this.dom.classList.remove("source-view"); + // 显示复选框,隐藏标记 + if (this.checkboxElement) { + this.checkboxElement.style.display = ""; + this.updateCheckbox(); + } + if (this.markerElement && this.markerElement.parentNode) { + this.markerElement.remove(); + } + } + } + + update(node: ProseMirrorNode): boolean { + if (node.type.name !== "task_item") return false; + this.node = node; + // 更新复选框状态 + if (!this.sourceViewMode) { + this.updateCheckbox(); + } + // 更新标记 + if (this.sourceViewMode) { + this.updateMarker(); + } + return true; + } + + ignoreMutation(mutation: MutationRecord): boolean { + // 忽略 class 和 style 属性变化 + if ( + mutation.type === "attributes" && + (mutation.attributeName === "class" || mutation.attributeName === "style") + ) { + return true; + } + // 忽略标记和复选框元素上的变化 + if (mutation.target === this.markerElement || mutation.target === this.checkboxElement) { + return true; + } + // 忽略 dom 上的子节点变化(添加/移除标记和复选框) + if (mutation.type === "childList" && mutation.target === this.dom) { + return true; + } + return false; + } + + destroy(): void { + if (this.sourceViewUnsubscribe) { + this.sourceViewUnsubscribe(); + this.sourceViewUnsubscribe = null; + } + } +} + +/** + * 创建任务列表 NodeView + */ +export function createTaskListNodeView( + node: ProseMirrorNode, + view: ProseMirrorView, + getPos: () => number | undefined +): TaskListView { + return new TaskListView(node, view, getPos); +} + +/** + * 创建任务列表项 NodeView + */ +export function createTaskItemNodeView( + node: ProseMirrorNode, + view: ProseMirrorView, + getPos: () => number | undefined +): TaskItemView { + return new TaskItemView(node, view, getPos); +} diff --git a/src/core/nodeviews/math-block.ts b/src/core/nodeviews/math-block.ts new file mode 100644 index 0000000..6709047 --- /dev/null +++ b/src/core/nodeviews/math-block.ts @@ -0,0 +1,215 @@ +/** + * Milkup 数学公式 NodeView + * + * 使用 KaTeX 渲染数学公式 + * 支持编辑模式和预览模式切换 + */ + +import { Node } from "prosemirror-model"; +import { EditorView, NodeView } from "prosemirror-view"; +import katex from "katex"; +import "katex/dist/katex.min.css"; + +/** + * 渲染数学公式 + */ +function renderMath(content: string, displayMode: boolean): string { + if (!content.trim()) { + return ""; + } + + try { + return katex.renderToString(content, { + displayMode, + throwOnError: false, + output: "html", + }); + } catch (e) { + return `${escapeHtml(content)}`; + } +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +// 存储所有 MathBlockView 实例,用于全局更新 +const mathBlockViews = new Set(); + +/** + * 更新所有数学块的编辑状态 + */ +export function updateAllMathBlocks(view: EditorView): void { + const { from, to } = view.state.selection; + + for (const mathView of mathBlockViews) { + mathView.updateEditingState(from, to); + } +} + +/** + * 数学块 NodeView + */ +export class MathBlockView implements NodeView { + dom: HTMLElement; + contentDOM: HTMLElement; + private preview: HTMLElement; + private sourceContainer: HTMLElement; + private view: EditorView; + private getPos: () => number | undefined; + private isEditing: boolean = false; + + constructor(node: Node, view: EditorView, getPos: () => number | undefined) { + this.view = view; + this.getPos = getPos; + + // 注册到全局集合 + mathBlockViews.add(this); + + // 创建容器 + this.dom = document.createElement("div"); + this.dom.className = "math-block"; + + // 创建预览区域 + this.preview = document.createElement("div"); + this.preview.className = "math-preview"; + this.dom.appendChild(this.preview); + + // 创建源码容器(用于居中) + this.sourceContainer = document.createElement("div"); + this.sourceContainer.className = "math-source-container"; + this.dom.appendChild(this.sourceContainer); + + // 创建编辑区域(contentDOM) + this.contentDOM = document.createElement("code"); + this.contentDOM.className = "math-source"; + this.sourceContainer.appendChild(this.contentDOM); + + // 初始渲染 + this.updatePreview(node.textContent); + + // 点击预览区域进入编辑模式 + this.preview.addEventListener("click", () => { + this.enterEditMode(); + }); + + // 初始检查光标位置 + const { from, to } = view.state.selection; + this.updateEditingState(from, to); + } + + update(node: Node): boolean { + if (node.type.name !== "math_block") return false; + this.updatePreview(node.textContent); + return true; + } + + private updatePreview(content: string): void { + const html = renderMath(content, true); + this.preview.innerHTML = html || '输入数学公式...'; + } + + /** + * 根据光标位置更新编辑状态 + */ + updateEditingState(selFrom: number, selTo: number): void { + const pos = this.getPos(); + if (pos === undefined) return; + + const node = this.view.state.doc.nodeAt(pos); + if (!node) return; + + const nodeStart = pos; + const nodeEnd = pos + node.nodeSize; + + // 检查光标是否在节点内部(包括节点边界) + const cursorInNode = selFrom >= nodeStart && selTo <= nodeEnd; + + if (cursorInNode && !this.isEditing) { + this.setEditing(true); + } else if (!cursorInNode && this.isEditing) { + this.setEditing(false); + } + } + + private setEditing(editing: boolean): void { + this.isEditing = editing; + if (editing) { + this.dom.classList.add("editing"); + } else { + this.dom.classList.remove("editing"); + } + } + + private enterEditMode(): void { + if (this.isEditing) return; + this.setEditing(true); + + // 聚焦到编辑区域 + const pos = this.getPos(); + if (pos !== undefined) { + const tr = this.view.state.tr.setSelection( + this.view.state.selection.constructor.near(this.view.state.doc.resolve(pos + 1)) + ); + this.view.dispatch(tr); + this.view.focus(); + } + } + + selectNode(): void { + this.setEditing(true); + } + + deselectNode(): void { + // 不在这里退出编辑模式,由 updateEditingState 统一处理 + } + + stopEvent(event: Event): boolean { + return false; + } + + ignoreMutation(): boolean { + return true; + } + + destroy(): void { + // 从全局集合中移除 + mathBlockViews.delete(this); + } +} + +/** + * 创建数学块 NodeView + */ +export function createMathBlockNodeView( + node: Node, + view: EditorView, + getPos: () => number | undefined +): NodeView { + return new MathBlockView(node, view, getPos); +} + +/** + * 渲染行内数学公式 + */ +export function renderInlineMath(content: string): string { + return renderMath(content, false); +} + +/** + * 检查 KaTeX 是否可用 + */ +export function isKaTeXAvailable(): boolean { + return true; +} + +/** + * 预加载 KaTeX(已通过静态导入加载) + */ +export function preloadKaTeX(): Promise { + return Promise.resolve(); +} diff --git a/src/core/parser/index.ts b/src/core/parser/index.ts new file mode 100644 index 0000000..4944936 --- /dev/null +++ b/src/core/parser/index.ts @@ -0,0 +1,1138 @@ +/** + * Milkup Markdown 解析器 v2 + * + * 核心改进:保留语法标记作为文本内容 + * 文档结构示例:对于 "这是**粗体**文本" + * + * paragraph + * └─ text "这是" + * └─ text "**" [syntax_open, strong_syntax] + * └─ text "粗体" [strong] + * └─ text "**" [syntax_close, strong_syntax] + * └─ text "文本" + * + * 这样光标可以在语法标记内自由移动 + */ + +import { Node, Schema, Mark } from "prosemirror-model"; +import { milkupSchema } from "../schema"; +import type { SyntaxMarker, SyntaxType } from "../types"; + +/** 解析结果 */ +export interface ParseResult { + doc: Node; + markers: SyntaxMarker[]; +} + +/** 行内语法定义 */ +interface InlineSyntax { + type: string; + pattern: RegExp; + prefix: string | ((match: RegExpExecArray) => string); + suffix: string | ((match: RegExpExecArray) => string); + contentIndex: number; + getAttrs?: (match: RegExpExecArray) => Record; +} + +/** 行内语法列表 - 按优先级排序 */ +const INLINE_SYNTAXES: InlineSyntax[] = [ + // 粗斜体 ***text*** 或 ___text___ - 必须在 strong 和 emphasis 之前 + { + type: "strong_emphasis", + pattern: /(\*\*\*|___)(.+?)\1/g, + prefix: (m) => m[1], + suffix: (m) => m[1], + contentIndex: 2, + }, + // 粗体 **text** 或 __text__ - 排除 *** 的情况 + { + type: "strong", + pattern: /(? m[1] || m[3], + suffix: (m) => m[1] || m[3], + contentIndex: 2, + }, + { + type: "emphasis", + pattern: + /(? m[1] || m[3], + suffix: (m) => m[1] || m[3], + contentIndex: 2, + }, + { + type: "code_inline", + pattern: /`([^`]+)`/g, + prefix: "`", + suffix: "`", + contentIndex: 1, + }, + { + type: "strikethrough", + pattern: /~~(.+?)~~/g, + prefix: "~~", + suffix: "~~", + contentIndex: 1, + }, + { + type: "highlight", + pattern: /==(.+?)==/g, + prefix: "==", + suffix: "==", + contentIndex: 1, + }, + { + type: "link", + pattern: /(? `](${m[2]}${m[3] ? ` "${m[3]}"` : ""})`, + contentIndex: 1, + getAttrs: (m) => ({ href: m[2], title: m[3] || "" }), + }, + { + type: "math_inline", + pattern: /(? ({ content: m[1] }), + }, +]; + +/** 块级语法模式 */ +const BLOCK_PATTERNS = { + heading: /^(#{1,6})\s+(.*)$/, + code_block_start: /^```([^\s`]*)(.*)$/, // 允许语言标识后跟任意属性(如 {linenos=1}) + code_block_end: /^\s*```\s*$/, // 允许前导空格和行尾空格 + blockquote: /^>\s?(.*)$/, + bullet_list: /^(\s*)([-*+])\s+(.*)$/, + ordered_list: /^(\s*)(\d+)\.\s+(.*)$/, + task_item: /^(\s*)[-*+]\s+\[([ xX]?)\]\s+(.*)$/, + horizontal_rule: /^([-*_]){3,}\s*$/, // 允许行尾有空格 + table_row: /^\|(.+)\|\s*$/, + table_separator: /^\|[-:\s|]+\|\s*$/, + math_block_start: /^\$\$\s*$/, // 多行数学块开始 + math_block_end: /^\$\$\s*$/, // 多行数学块结束 + math_block_inline: /^\$\$(.+)\$\$$/, // 单行数学块 $$content$$ + image: /^!\[([^\]]*)\]\((.+?)(?:\s+"([^"]*)")?\)\s*$/, // 图片 ![alt](src "title") - 允许 URL 中有空格 + container_start: /^:::(\w+)(?:\s+(.*))?$/, + container_end: /^:::\s*$/, // 允许行尾有空格 + html_block_start: /^<([a-zA-Z][a-zA-Z0-9]*)/, // 以 < 开头后跟标签名 +}; + +/** + * Markdown 解析器类 + */ +export class MarkdownParser { + private schema: Schema; + private markers: SyntaxMarker[] = []; + + constructor(schema: Schema = milkupSchema) { + this.schema = schema; + } + + /** + * 解析 Markdown 文本 + */ + parse(markdown: string): ParseResult { + this.markers = []; + + // 统一换行符,移除 \r + const lines = markdown.replace(/\r\n?/g, "\n").split("\n"); + const blocks = this.parseBlocks(lines); + + const content = blocks.length > 0 ? blocks : [this.schema.node("paragraph")]; + const doc = this.schema.node("doc", null, content); + + return { doc, markers: this.markers }; + } + + /** + * 解析块级元素 + */ + private parseBlocks(lines: string[]): Node[] { + const blocks: Node[] = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + if (line.trim() === "") { + i++; + continue; + } + + // 代码块(只有闭合的代码块才解析为 code_block 节点) + const codeMatch = line.match(BLOCK_PATTERNS.code_block_start); + if (codeMatch) { + const result = this.parseCodeBlock(lines, i); + if (result) { + blocks.push(result.node); + i = result.endIndex + 1; + continue; + } + // 未闭合的代码块,当作普通段落处理 + } + + // 单行数学块 $$content$$ + const mathInlineMatch = line.match(BLOCK_PATTERNS.math_block_inline); + if (mathInlineMatch) { + const content = mathInlineMatch[1]; + const textNode = content ? this.schema.text(content) : null; + blocks.push(this.schema.node("math_block", {}, textNode ? [textNode] : [])); + i++; + continue; + } + + // 多行数学块 + if (BLOCK_PATTERNS.math_block_start.test(line)) { + const result = this.parseMathBlock(lines, i); + blocks.push(result.node); + i = result.endIndex + 1; + continue; + } + + // 容器 + const containerMatch = line.match(BLOCK_PATTERNS.container_start); + if (containerMatch) { + const result = this.parseContainer(lines, i); + blocks.push(result.node); + i = result.endIndex + 1; + continue; + } + + // 标题 + const headingMatch = line.match(BLOCK_PATTERNS.heading); + if (headingMatch) { + blocks.push(this.parseHeading(headingMatch)); + i++; + continue; + } + + // 图片 + const imageMatch = line.match(BLOCK_PATTERNS.image); + if (imageMatch) { + blocks.push(this.parseImage(imageMatch)); + i++; + continue; + } + + // 分隔线 + if (BLOCK_PATTERNS.horizontal_rule.test(line)) { + blocks.push(this.schema.node("horizontal_rule")); + i++; + continue; + } + + // 引用 + if (BLOCK_PATTERNS.blockquote.test(line)) { + const result = this.parseBlockquote(lines, i); + blocks.push(result.node); + i = result.endIndex + 1; + continue; + } + + // 任务列表 + if (BLOCK_PATTERNS.task_item.test(line)) { + const result = this.parseTaskList(lines, i); + blocks.push(result.node); + i = result.endIndex + 1; + continue; + } + + // 无序列表 + if (BLOCK_PATTERNS.bullet_list.test(line)) { + const result = this.parseBulletList(lines, i); + blocks.push(result.node); + i = result.endIndex + 1; + continue; + } + + // 有序列表 + if (BLOCK_PATTERNS.ordered_list.test(line)) { + const result = this.parseOrderedList(lines, i); + blocks.push(result.node); + i = result.endIndex + 1; + continue; + } + + // 表格 + if (BLOCK_PATTERNS.table_row.test(line)) { + const result = this.parseTable(lines, i); + if (result) { + blocks.push(result.node); + i = result.endIndex + 1; + continue; + } + } + + // HTML 块 + const htmlMatch = line.match(BLOCK_PATTERNS.html_block_start); + if (htmlMatch) { + const result = this.parseHtmlBlock(lines, i); + blocks.push(result.node); + i = result.endIndex + 1; + continue; + } + + // 段落 + blocks.push(this.parseParagraph(line)); + i++; + } + + return blocks; + } + + /** + * 解析标题 - 保留 # 标记 + */ + private parseHeading(match: RegExpMatchArray): Node { + const hashes = match[1]; + const content = match[2]; + const level = hashes.length; + + const nodes: Node[] = []; + + // 添加 # 标记作为文本(带 syntax mark),空格单独作为普通文本 + const syntaxMark = this.schema.marks.syntax_marker?.create({ syntaxType: "heading" }); + if (syntaxMark) { + nodes.push(this.schema.text(hashes, [syntaxMark])); + nodes.push(this.schema.text(" ")); + } + + // 添加内容 + const inlineNodes = this.parseInlineWithSyntax(content); + nodes.push(...inlineNodes); + + return this.schema.node("heading", { level }, nodes); + } + + /** + * 解析图片 - ![alt](src "title") + */ + private parseImage(match: RegExpMatchArray): Node { + const alt = match[1] || ""; + const src = match[2] || ""; + const title = match[3] || ""; + + return this.schema.node("image", { src, alt, title }); + } + + /** + * 解析段落 + */ + private parseParagraph(line: string): Node { + const nodes = this.parseInlineWithSyntax(line); + return this.schema.node("paragraph", null, nodes.length > 0 ? nodes : undefined); + } + + /** + * 转义正则:匹配 \ 后跟特殊字符 + */ + private static ESCAPE_RE = /\\([\\`*_{}[\]()#+\-.!|~=$>])/g; + + /** + * 解析行内内容 - 保留语法标记,支持嵌套语法 + */ + private parseInlineWithSyntax(text: string, inheritedMarks: Mark[] = []): Node[] { + if (!text) return []; + + // 转义预处理:检测 \X 转义序列 + const escapePositions: Array<{ index: number; char: string }> = []; + const escapeRe = new RegExp(MarkdownParser.ESCAPE_RE.source, "g"); + let escMatch: RegExpExecArray | null; + while ((escMatch = escapeRe.exec(text)) !== null) { + escapePositions.push({ index: escMatch.index, char: escMatch[1] }); + } + + if (escapePositions.length > 0) { + return this.parseInlineWithEscapes(text, inheritedMarks, escapePositions); + } + + // 收集所有匹配 + interface MatchInfo { + syntax: InlineSyntax; + match: RegExpExecArray; + start: number; + end: number; + prefix: string; + suffix: string; + content: string; + attrs?: Record; + } + + const matches: MatchInfo[] = []; + + for (const syntax of INLINE_SYNTAXES) { + const re = new RegExp(syntax.pattern.source, "g"); + let match: RegExpExecArray | null; + + while ((match = re.exec(text)) !== null) { + const prefix = typeof syntax.prefix === "function" ? syntax.prefix(match) : syntax.prefix; + const suffix = typeof syntax.suffix === "function" ? syntax.suffix(match) : syntax.suffix; + // 支持多捕获组的情况(如 strong 的正则有两种模式) + const content = match[syntax.contentIndex] || match[syntax.contentIndex + 2] || ""; + + // 跳过无效匹配 + if (!prefix || !content) continue; + + matches.push({ + syntax, + match, + start: match.index, + end: match.index + match[0].length, + prefix, + suffix, + content, + attrs: syntax.getAttrs?.(match), + }); + } + } + + // 按位置排序,优先选择更长的匹配(外层语法) + matches.sort((a, b) => { + if (a.start !== b.start) return a.start - b.start; + return b.end - a.end; // 相同起点时,更长的优先 + }); + + // 过滤完全重叠的匹配(保留外层) + const filtered: MatchInfo[] = []; + let lastEnd = 0; + for (const m of matches) { + if (m.start >= lastEnd) { + filtered.push(m); + lastEnd = m.end; + } + } + + // 构建节点 + const nodes: Node[] = []; + let pos = 0; + + for (const m of filtered) { + // 前面的纯文本 + if (m.start > pos) { + const plainText = text.slice(pos, m.start); + if (inheritedMarks.length > 0) { + nodes.push(this.schema.text(plainText, inheritedMarks)); + } else { + nodes.push(this.schema.text(plainText)); + } + } + + // 语法标记和内容 + const syntaxMark = this.schema.marks.syntax_marker?.create({ + syntaxType: m.syntax.type, + }); + + // 处理 strong_emphasis 特殊类型 + let contentMarks: Mark[] = []; + if (m.syntax.type === "strong_emphasis") { + const strongMark = this.schema.marks.strong?.create(); + const emphasisMark = this.schema.marks.emphasis?.create(); + if (strongMark) contentMarks.push(strongMark); + if (emphasisMark) contentMarks.push(emphasisMark); + } else { + const contentMark = this.schema.marks[m.syntax.type]?.create(m.attrs); + if (contentMark) contentMarks.push(contentMark); + } + + // 合并继承的 marks + const allContentMarks = [...inheritedMarks, ...contentMarks]; + + // 前缀(带 syntax_marker) + if (syntaxMark) { + const prefixMarks = [...inheritedMarks, syntaxMark, ...contentMarks]; + nodes.push(this.schema.text(m.prefix, prefixMarks)); + } + + // 递归解析内容(可能包含嵌套语法) + const innerNodes = this.parseInlineWithSyntax(m.content, allContentMarks); + if (innerNodes.length > 0) { + nodes.push(...innerNodes); + } else if (m.content) { + // 如果没有嵌套语法,直接添加内容 + nodes.push(this.schema.text(m.content, allContentMarks)); + } + + // 后缀(带 syntax_marker) + if (syntaxMark) { + const suffixMarks = [...inheritedMarks, syntaxMark, ...contentMarks]; + nodes.push(this.schema.text(m.suffix, suffixMarks)); + } + + pos = m.end; + } + + // 剩余文本 + if (pos < text.length) { + const remainingText = text.slice(pos); + if (inheritedMarks.length > 0) { + nodes.push(this.schema.text(remainingText, inheritedMarks)); + } else { + nodes.push(this.schema.text(remainingText)); + } + } + + return nodes; + } + + /** + * 处理包含转义序列的行内文本 + * 将文本按转义位置分割,非转义片段递归解析,转义部分生成特殊节点 + */ + private parseInlineWithEscapes( + text: string, + inheritedMarks: Mark[], + escapePositions: Array<{ index: number; char: string }> + ): Node[] { + const nodes: Node[] = []; + let pos = 0; + + for (const esc of escapePositions) { + // 转义之前的普通文本片段 → 递归正常解析 + if (esc.index > pos) { + const segment = text.slice(pos, esc.index); + nodes.push(...this.parseInlineWithSyntax(segment, inheritedMarks)); + } + + // `\` 字符 → 带 syntax_marker(escape) 的文本节点 + const syntaxMark = this.schema.marks.syntax_marker?.create({ syntaxType: "escape" }); + if (syntaxMark) { + const backslashMarks = [...inheritedMarks, syntaxMark]; + nodes.push(this.schema.text("\\", backslashMarks)); + } + + // 被转义的字符 → 普通文本节点(只带 inheritedMarks) + if (inheritedMarks.length > 0) { + nodes.push(this.schema.text(esc.char, inheritedMarks)); + } else { + nodes.push(this.schema.text(esc.char)); + } + + pos = esc.index + 2; // 跳过 \X(2个字符) + } + + // 剩余文本 → 递归正常解析 + if (pos < text.length) { + const remaining = text.slice(pos); + nodes.push(...this.parseInlineWithSyntax(remaining, inheritedMarks)); + } + + return nodes; + } + + /** + * 解析代码块 + * 支持嵌套代码围栏:内部带语言标识的 ``` 开启嵌套层,对应的 ``` 关闭嵌套层 + * 如果代码块未闭合(没有找到结束的 ```),返回 null,由调用方当作普通段落处理 + */ + private parseCodeBlock( + lines: string[], + startIndex: number + ): { node: Node; endIndex: number } | null { + const startLine = lines[startIndex]; + const langMatch = startLine.match(BLOCK_PATTERNS.code_block_start); + const language = langMatch ? langMatch[1] || "" : ""; + + let endIndex = startIndex + 1; + const contentLines: string[] = []; + let nestedLevel = 0; + + while (endIndex < lines.length) { + const line = lines[endIndex]; + const isEnd = BLOCK_PATTERNS.code_block_end.test(line); + const isStart = !isEnd && BLOCK_PATTERNS.code_block_start.test(line); + + if (isStart) { + // 内部出现带语言标识的围栏开启,进入嵌套层 + nestedLevel++; + } else if (isEnd) { + if (nestedLevel > 0) { + // 关闭一层嵌套 + nestedLevel--; + } else { + // 当前代码块的真正结束 + break; + } + } + + contentLines.push(line); + endIndex++; + } + + // 如果没有找到结束标记,不创建代码块节点 + if (endIndex >= lines.length) { + return null; + } + + // 代码块节点只包含纯文本内容,不包含语法标记 + const content = contentLines.join("\n"); + const textNode = content ? this.schema.text(content) : null; + + return { + node: this.schema.node("code_block", { language }, textNode ? [textNode] : []), + endIndex, + }; + } + + /** + * 解析数学块 + */ + private parseMathBlock(lines: string[], startIndex: number): { node: Node; endIndex: number } { + let endIndex = startIndex + 1; + const contentLines: string[] = []; + + while (endIndex < lines.length) { + if (BLOCK_PATTERNS.math_block_end.test(lines[endIndex])) { + break; + } + contentLines.push(lines[endIndex]); + endIndex++; + } + + const content = contentLines.join("\n"); + const textNode = content ? this.schema.text(content) : null; + + return { + node: this.schema.node("math_block", {}, textNode ? [textNode] : []), + endIndex, + }; + } + + /** + * 解析容器 + */ + private parseContainer(lines: string[], startIndex: number): { node: Node; endIndex: number } { + const startLine = lines[startIndex]; + const match = startLine.match(BLOCK_PATTERNS.container_start)!; + const type = match[1]; + const title = match[2] || ""; + + let endIndex = startIndex + 1; + const contentLines: string[] = []; + + while (endIndex < lines.length) { + if (BLOCK_PATTERNS.container_end.test(lines[endIndex])) { + break; + } + contentLines.push(lines[endIndex]); + endIndex++; + } + + const innerBlocks = this.parseBlocks(contentLines); + + return { + node: this.schema.node("container", { type, title }, innerBlocks), + endIndex, + }; + } + + /** + * 解析 HTML 块 + * 支持自闭合标签和嵌套标签 + */ + private parseHtmlBlock(lines: string[], startIndex: number): { node: Node; endIndex: number } { + const startLine = lines[startIndex]; + const tagMatch = startLine.match(BLOCK_PATTERNS.html_block_start); + const tagName = tagMatch ? tagMatch[1] : ""; + + // HTML 自闭合标签列表 + const voidElements = new Set([ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr", + ]); + + // 检查是否是自闭合标签(void element 或以 /> 结尾) + if (voidElements.has(tagName.toLowerCase()) || startLine.trimEnd().endsWith("/>")) { + const textNode = startLine ? this.schema.text(startLine) : null; + return { + node: this.schema.node("html_block", {}, textNode ? [textNode] : []), + endIndex: startIndex, + }; + } + + const closePattern = new RegExp(``, "i"); + + // 检查闭合标签是否在起始行(如 text) + if (closePattern.test(startLine)) { + const textNode = startLine ? this.schema.text(startLine) : null; + return { + node: this.schema.node("html_block", {}, textNode ? [textNode] : []), + endIndex: startIndex, + }; + } + + // 多行 HTML 块:收集直到找到匹配的闭合标签 + const contentLines: string[] = [startLine]; + let endIndex = startIndex + 1; + let nestLevel = 1; // 已经有一个开始标签 + + const openPattern = new RegExp(`<${tagName}[\\s>/]`, "i"); + + while (endIndex < lines.length) { + const line = lines[endIndex]; + contentLines.push(line); + + // 检查同名标签的嵌套(简单计数) + if (openPattern.test(line) && endIndex !== startIndex) { + nestLevel++; + } + if (closePattern.test(line)) { + nestLevel--; + if (nestLevel <= 0) { + break; + } + } + + endIndex++; + } + + const content = contentLines.join("\n"); + const textNode = content ? this.schema.text(content) : null; + + return { + node: this.schema.node("html_block", {}, textNode ? [textNode] : []), + endIndex, + }; + } + + /** + * 解析引用块 + */ + private parseBlockquote(lines: string[], startIndex: number): { node: Node; endIndex: number } { + let endIndex = startIndex; + const contentLines: string[] = []; + + while (endIndex < lines.length) { + const line = lines[endIndex]; + // 空行也可以是引用的一部分(如果下一行还是引用) + if (line.trim() === "") { + // 检查下一行是否还是引用 + if (endIndex + 1 < lines.length && BLOCK_PATTERNS.blockquote.test(lines[endIndex + 1])) { + contentLines.push(""); + endIndex++; + continue; + } + break; + } + const match = line.match(BLOCK_PATTERNS.blockquote); + if (!match) break; + // 保留原始内容(不包含 >) + contentLines.push(match[1]); + endIndex++; + } + + const innerBlocks = this.parseBlocks(contentLines); + + // 为每个块级元素添加 > 前缀 + const processedBlocks = innerBlocks.map((block) => { + if (block.type.name === "paragraph") { + const syntaxMark = this.schema.marks.syntax_marker?.create({ + syntaxType: "blockquote", + }); + + const nodes: Node[] = []; + + // 添加 > 符号(带 syntax_marker) + if (syntaxMark) { + nodes.push(this.schema.text("> ", [syntaxMark])); + } + + // 添加原有内容 + block.forEach((child) => { + nodes.push(child); + }); + + return this.schema.node("paragraph", null, nodes); + } + return block; + }); + + return { + node: this.schema.node( + "blockquote", + null, + processedBlocks.length > 0 ? processedBlocks : [this.schema.node("paragraph")] + ), + endIndex: endIndex - 1, + }; + } + + /** + * 解析无序列表 + * 支持列表项中的多行内容(如代码块) + */ + private parseBulletList(lines: string[], startIndex: number): { node: Node; endIndex: number } { + const items: Node[] = []; + let endIndex = startIndex; + let baseIndent = -1; + + while (endIndex < lines.length) { + const line = lines[endIndex]; + + // 空行可能是列表项之间的分隔,检查下一行 + if (line.trim() === "") { + // 检查下一行是否还是列表项 + if (endIndex + 1 < lines.length) { + const nextLine = lines[endIndex + 1]; + const nextMatch = nextLine.match(BLOCK_PATTERNS.bullet_list); + if (nextMatch && (baseIndent === -1 || nextMatch[1].length === baseIndent)) { + endIndex++; + continue; + } + } + break; + } + + const match = line.match(BLOCK_PATTERNS.bullet_list); + if (!match) { + // 检查是否是缩进的内容(属于当前列表项) + if (baseIndent !== -1 && line.match(/^\s+/) && items.length > 0) { + // 这是列表项的续行,需要回溯处理 + break; + } + break; + } + + const indent = match[1].length; + // 记录基础缩进 + if (baseIndent === -1) { + baseIndent = indent; + } + + // 如果缩进大于基础缩进,说明是子列表,跳过(由父列表项处理) + if (indent > baseIndent) { + break; + } + + // 如果缩进小于基础缩进,说明列表结束 + if (indent < baseIndent) { + break; + } + + // 收集这个列表项的所有内容行 + const itemLines: string[] = [match[3]]; + const itemIndent = indent + 2; // 列表标记后的缩进 + let itemEndIndex = endIndex + 1; + + // 收集后续缩进的行(包括代码块等) + // 检查第一行是否是代码块开始 + let inCodeBlock = match[3].trim().startsWith("```"); + while (itemEndIndex < lines.length) { + const nextLine = lines[itemEndIndex]; + + // 跟踪代码块状态(必须在列表项检测之前,避免代码块内容被误判为新列表项) + if (nextLine.trim().startsWith("```")) { + inCodeBlock = !inCodeBlock; + } + + // 空行可能是列表项内容的一部分 + if (nextLine.trim() === "") { + // 检查空行后面是否还有缩进内容 + if (!inCodeBlock && itemEndIndex + 1 < lines.length) { + const afterEmpty = lines[itemEndIndex + 1]; + // 如果后面是新的列表项或没有缩进,则结束 + if (BLOCK_PATTERNS.bullet_list.test(afterEmpty) || !afterEmpty.match(/^\s{2,}/)) { + break; + } + } + itemLines.push(""); + itemEndIndex++; + continue; + } + + // 检查是否是新的列表项(代码块内部不检查) + if (!inCodeBlock && BLOCK_PATTERNS.bullet_list.test(nextLine)) { + break; + } + + // 检查是否有足够的缩进 + const lineIndent = nextLine.match(/^(\s*)/)?.[1].length || 0; + // 在代码块内部,接受缩进较少的行 + if (lineIndent >= itemIndent || inCodeBlock || nextLine.trim().startsWith("```")) { + // 移除缩进 + const trimmedLine = nextLine.slice(Math.min(lineIndent, itemIndent)); + itemLines.push(trimmedLine); + itemEndIndex++; + } else { + break; + } + } + + // 解析列表项内容 + const itemContent = this.parseBlocks(itemLines); + items.push( + this.schema.node( + "list_item", + null, + itemContent.length > 0 ? itemContent : [this.schema.node("paragraph")] + ) + ); + endIndex = itemEndIndex; + } + + return { + node: this.schema.node( + "bullet_list", + null, + items.length > 0 + ? items + : [this.schema.node("list_item", null, [this.schema.node("paragraph")])] + ), + endIndex: endIndex - 1, + }; + } + + /** + * 解析有序列表 + * 支持列表项中的多行内容(如代码块) + */ + private parseOrderedList(lines: string[], startIndex: number): { node: Node; endIndex: number } { + const items: Node[] = []; + let endIndex = startIndex; + let start = 1; + let baseIndent = -1; + + while (endIndex < lines.length) { + const line = lines[endIndex]; + + // 空行可能是列表项之间的分隔 + if (line.trim() === "") { + if (endIndex + 1 < lines.length) { + const nextLine = lines[endIndex + 1]; + const nextMatch = nextLine.match(BLOCK_PATTERNS.ordered_list); + if (nextMatch && (baseIndent === -1 || nextMatch[1].length === baseIndent)) { + endIndex++; + continue; + } + } + break; + } + + const match = line.match(BLOCK_PATTERNS.ordered_list); + if (!match) { + if (baseIndent !== -1 && line.match(/^\s+/) && items.length > 0) { + break; + } + break; + } + + const indent = match[1].length; + if (baseIndent === -1) { + baseIndent = indent; + start = parseInt(match[2], 10); + } + + if (indent > baseIndent) { + break; + } + + if (indent < baseIndent) { + break; + } + + // 收集这个列表项的所有内容行 + const itemLines: string[] = [match[3]]; + const itemIndent = indent + match[2].length + 2; // 数字 + ". " 的长度 + let itemEndIndex = endIndex + 1; + + // 检查第一行是否是代码块开始 + let inCodeBlock = match[3].trim().startsWith("```"); + while (itemEndIndex < lines.length) { + const nextLine = lines[itemEndIndex]; + + // 跟踪代码块状态(必须在列表项检测之前,避免代码块内容被误判为新列表项) + if (nextLine.trim().startsWith("```")) { + inCodeBlock = !inCodeBlock; + } + + if (nextLine.trim() === "") { + if (!inCodeBlock && itemEndIndex + 1 < lines.length) { + const afterEmpty = lines[itemEndIndex + 1]; + if (BLOCK_PATTERNS.ordered_list.test(afterEmpty) || !afterEmpty.match(/^\s{2,}/)) { + break; + } + } + itemLines.push(""); + itemEndIndex++; + continue; + } + + if (!inCodeBlock && BLOCK_PATTERNS.ordered_list.test(nextLine)) { + break; + } + + const lineIndent = nextLine.match(/^(\s*)/)?.[1].length || 0; + // 在代码块内部,接受缩进较少的行 + if (lineIndent >= itemIndent || inCodeBlock || nextLine.trim().startsWith("```")) { + const trimmedLine = nextLine.slice(Math.min(lineIndent, itemIndent)); + itemLines.push(trimmedLine); + itemEndIndex++; + } else { + break; + } + } + + const itemContent = this.parseBlocks(itemLines); + items.push( + this.schema.node( + "list_item", + null, + itemContent.length > 0 ? itemContent : [this.schema.node("paragraph")] + ) + ); + endIndex = itemEndIndex; + } + + return { + node: this.schema.node( + "ordered_list", + { start }, + items.length > 0 + ? items + : [this.schema.node("list_item", null, [this.schema.node("paragraph")])] + ), + endIndex: endIndex - 1, + }; + } + + /** + * 解析任务列表 + */ + private parseTaskList(lines: string[], startIndex: number): { node: Node; endIndex: number } { + const items: Node[] = []; + let endIndex = startIndex; + + while (endIndex < lines.length) { + const line = lines[endIndex]; + + // 空行结束列表 + if (line.trim() === "") { + break; + } + + const match = line.match(BLOCK_PATTERNS.task_item); + if (!match) break; + + const checked = match[2].toLowerCase() === "x"; + const content = match[3]; + const para = this.parseParagraph(content); + items.push(this.schema.node("task_item", { checked }, [para])); + endIndex++; + } + + return { + node: this.schema.node( + "task_list", + null, + items.length > 0 + ? items + : [this.schema.node("task_item", { checked: false }, [this.schema.node("paragraph")])] + ), + endIndex: endIndex - 1, + }; + } + + /** + * 解析表格 + */ + private parseTable(lines: string[], startIndex: number): { node: Node; endIndex: number } | null { + // 查找分隔行,允许跳过一个空行 + let sepIndex = startIndex + 1; + if (sepIndex < lines.length && lines[sepIndex].trim() === "") sepIndex++; + if (sepIndex >= lines.length) return null; + if (!BLOCK_PATTERNS.table_separator.test(lines[sepIndex])) return null; + + const rows: Node[] = []; + let endIndex = startIndex; + + // 从分隔行解析列对齐信息 + const separatorLine = lines[sepIndex].trimEnd(); + const alignments = separatorLine + .slice(1, -1) + .split("|") + .map((col) => { + const trimmed = col.trim(); + const left = trimmed.startsWith(":"); + const right = trimmed.endsWith(":"); + if (left && right) return "center"; + if (right) return "right"; + if (left) return "left"; + return null; + }); + + // 表头 + const headerCells = this.parseTableRow(lines[startIndex], true, alignments); + rows.push(this.schema.node("table_row", null, headerCells)); + endIndex = sepIndex + 1; + + // 数据行(跳过行间空行) + while (endIndex < lines.length) { + if (lines[endIndex].trim() === "") { + endIndex++; + continue; + } + if (!BLOCK_PATTERNS.table_row.test(lines[endIndex])) break; + const cells = this.parseTableRow(lines[endIndex], false, alignments); + rows.push(this.schema.node("table_row", null, cells)); + endIndex++; + } + + return { + node: this.schema.node("table", null, rows), + endIndex: endIndex - 1, + }; + } + + /** + * 解析表格行 + */ + private parseTableRow( + line: string, + isHeader: boolean, + alignments: (string | null)[] = [] + ): Node[] { + const cells: Node[] = []; + const content = line.trimEnd().slice(1, -1); + const cellContents = content.split("|"); + + for (let i = 0; i < cellContents.length; i++) { + const trimmed = cellContents[i].trim(); + const inlineContent = this.parseInlineWithSyntax(trimmed); + const nodeType = isHeader ? "table_header" : "table_cell"; + const align = alignments[i] || null; + cells.push( + this.schema.node( + nodeType, + align ? { align } : null, + inlineContent.length > 0 ? inlineContent : undefined + ) + ); + } + + return cells; + } +} + +/** 默认解析器实例 */ +export const defaultParser = new MarkdownParser(); + +/** + * 解析 Markdown 文本 + */ +export function parseMarkdown(markdown: string): ParseResult { + return defaultParser.parse(markdown); +} diff --git a/src/core/plugins/ai-completion.ts b/src/core/plugins/ai-completion.ts new file mode 100644 index 0000000..4ec018c --- /dev/null +++ b/src/core/plugins/ai-completion.ts @@ -0,0 +1,208 @@ +/** + * Milkup AI 续写插件 + * + * 基于 ProseMirror 的 AI 自动续写功能 + * 在用户停止输入后自动调用 AI 服务生成续写建议 + * 按 Tab 键接受建议 + */ + +import { Plugin, PluginKey } from "prosemirror-state"; +import { Decoration, DecorationSet, EditorView } from "prosemirror-view"; + +/** AI 续写插件状态 */ +export interface AICompletionState { + decoration: DecorationSet; + suggestion: string | null; + loading: boolean; +} + +/** AI 配置 */ +export interface AICompletionConfig { + enabled: boolean; + debounceWait: number; + // AI 服务调用函数 + complete: (context: AICompletionContext) => Promise<{ continuation: string } | null>; +} + +/** AI 续写上下文 */ +export interface AICompletionContext { + fileTitle: string; + previousContent: string; + sectionTitle: string; + subSectionTitle: string; +} + +/** 插件 Key */ +export const aiCompletionPluginKey = new PluginKey("milkup-ai-completion"); + +/** + * 创建 AI 续写插件 + */ +export function createAICompletionPlugin(getConfig: () => AICompletionConfig): Plugin { + let timer: ReturnType | null = null; + + return new Plugin({ + key: aiCompletionPluginKey, + + state: { + init() { + return { decoration: DecorationSet.empty, suggestion: null, loading: false }; + }, + apply(tr, value) { + // 文档变化时清除建议 + if (tr.docChanged) { + return { decoration: DecorationSet.empty, suggestion: null, loading: false }; + } + // 手动更新(如异步获取结果后) + const meta = tr.getMeta(aiCompletionPluginKey); + if (meta) { + return meta; + } + return value; + }, + }, + + props: { + decorations(state) { + return this.getState(state)?.decoration; + }, + + handleKeyDown(view, event) { + if (event.key === "Tab") { + const state = this.getState(view.state); + if (state?.suggestion) { + event.preventDefault(); + const tr = view.state.tr.insertText(state.suggestion, view.state.selection.to); + // 清除建议 + tr.setMeta(aiCompletionPluginKey, { + decoration: DecorationSet.empty, + suggestion: null, + loading: false, + }); + view.dispatch(tr); + return true; + } + } + return false; + }, + }, + + view(_view) { + return { + update: (view: EditorView, prevState) => { + const config = getConfig(); + + // 如果禁用,不做任何事 + if (!config.enabled) return; + + // 如果文档没有变化,忽略 + if (!view.state.doc.eq(prevState.doc)) { + if (timer) clearTimeout(timer); + + timer = setTimeout(async () => { + const { selection, doc } = view.state; + const { to } = selection; + + // 如果有选区(范围选择),不触发 + if (!selection.empty) return; + + // 获取上下文 + const fileTitle = (window as any).__currentFilePath + ? (window as any).__currentFilePath.split(/[\\/]/).pop() + : "未命名文档"; + + // 获取前面的文本 + const start = Math.max(0, to - 200); + const previousContent = doc.textBetween(start, to, "\n"); + + // 提取标题上下文 + let sectionTitle = "未知"; + let subSectionTitle = "未知"; + + const headers: { level: number; text: string }[] = []; + + doc.nodesBetween(0, to, (node, pos) => { + if (node.type.name === "heading") { + if (pos + node.nodeSize <= to) { + headers.push({ level: node.attrs.level, text: node.textContent }); + } + return false; + } + if ( + ["paragraph", "code_block", "blockquote", "bullet_list", "ordered_list"].includes( + node.type.name + ) + ) { + return false; + } + return true; + }); + + if (headers.length > 0) { + const lastHeader = headers[headers.length - 1]; + subSectionTitle = lastHeader.text; + + const parentHeader = headers + .slice(0, -1) + .reverse() + .find((h) => h.level < lastHeader.level); + + if (parentHeader) { + sectionTitle = parentHeader.text; + } else { + const mainHeader = + headers.find((h) => h.level === 1) || headers.find((h) => h.level === 2); + if (mainHeader && mainHeader !== lastHeader) { + sectionTitle = mainHeader.text; + } else if (lastHeader.level <= 2) { + sectionTitle = lastHeader.text; + } + } + } + + // 上下文太短,不触发 + if (previousContent.trim().length < 5) return; + + try { + const result = await config.complete({ + fileTitle, + previousContent, + sectionTitle, + subSectionTitle, + }); + + if (result && result.continuation) { + const widget = document.createElement("span"); + widget.textContent = result.continuation; + widget.style.color = "var(--text-color-light, #999)"; + widget.style.opacity = "0.6"; + widget.style.pointerEvents = "none"; + widget.dataset.suggestion = result.continuation; + + const deco = Decoration.widget(to, widget, { side: 1 }); + const decoSet = DecorationSet.create(view.state.doc, [deco]); + + const tr = view.state.tr.setMeta(aiCompletionPluginKey, { + decoration: decoSet, + suggestion: result.continuation, + loading: false, + }); + view.dispatch(tr); + } + } catch (e) { + console.error("AI Completion failed", e); + } + }, config.debounceWait || 2000); + } + }, + + destroy() { + if (timer) { + clearTimeout(timer); + timer = null; + } + }, + }; + }, + }); +} diff --git a/src/core/plugins/heading-sync.ts b/src/core/plugins/heading-sync.ts new file mode 100644 index 0000000..1034f26 --- /dev/null +++ b/src/core/plugins/heading-sync.ts @@ -0,0 +1,165 @@ +/** + * Milkup 标题同步插件 + * + * 监听标题节点的变化,根据 # 的数量自动更新标题级别 + * 当用户删除或添加 # 时,自动调整标题级别 + */ + +import { Plugin, PluginKey, Transaction } from "prosemirror-state"; +import { Node } from "prosemirror-model"; + +/** 插件 Key */ +export const headingSyncPluginKey = new PluginKey("milkup-heading-sync"); + +/** + * 检查标题节点并返回需要更新的信息 + */ +function checkHeadingLevel( + node: Node, + pos: number +): { pos: number; currentLevel: number; newLevel: number } | null { + if (node.type.name !== "heading") return null; + + const currentLevel = node.attrs.level as number; + + // 查找标题内容中的 # 数量 + let hashCount = 0; + let foundSyntaxMarker = false; + + node.forEach((child) => { + if (child.isText) { + const syntaxMark = child.marks.find((m) => m.type.name === "syntax_marker"); + if (syntaxMark && syntaxMark.attrs.syntaxType === "heading") { + foundSyntaxMarker = true; + // 计算 # 的数量 + const text = child.text || ""; + const match = text.match(/^(#{1,6})\s*$/); + if (match) { + hashCount = match[1].length; + } + } + } + }); + + // 如果没有找到语法标记,或者 # 数量为 0,将标题转换为段落 + if (!foundSyntaxMarker || hashCount === 0) { + return { pos, currentLevel, newLevel: 0 }; // 0 表示转换为段落 + } + + // 如果 # 数量与当前级别不同,需要更新 + if (hashCount !== currentLevel && hashCount >= 1 && hashCount <= 6) { + return { pos, currentLevel, newLevel: hashCount }; + } + + return null; +} + +/** + * 创建标题同步插件 + */ +export function createHeadingSyncPlugin(): Plugin { + return new Plugin({ + key: headingSyncPluginKey, + + appendTransaction(transactions, oldState, newState) { + // 只在文档变化时处理 + const docChanged = transactions.some((tr) => tr.docChanged); + if (!docChanged) return null; + + // 跳过语法插件产生的 transaction + if (transactions.some((tr) => tr.getMeta("syntax-plugin-internal"))) return null; + + const updates: Array<{ pos: number; currentLevel: number; newLevel: number }> = []; + + // 遍历所有标题节点 + newState.doc.descendants((node, pos) => { + if (node.type.name === "heading") { + const update = checkHeadingLevel(node, pos); + if (update) { + updates.push(update); + } + } + return true; + }); + + if (updates.length === 0) return null; + + let tr = newState.tr; + + for (const update of updates) { + if (update.newLevel === 0) { + // 转换为段落:移除语法标记的 marks,将节点类型改为 paragraph + const node = newState.doc.nodeAt(update.pos); + if (node) { + // 收集节点内容(移除 syntax_marker 和紧跟其后的空格) + const content: Node[] = []; + let skipNextSpace = false; + node.forEach((child) => { + if (child.isText) { + const syntaxMark = child.marks.find((m) => m.type.name === "syntax_marker"); + if (syntaxMark && syntaxMark.attrs.syntaxType === "heading") { + skipNextSpace = true; + return; // 跳过语法标记 + } + if (skipNextSpace) { + skipNextSpace = false; + // 如果这个文本节点是空格,跳过它 + if (child.text === " ") { + return; + } + // 如果以空格开头,去掉开头的空格 + if (child.text && child.text.startsWith(" ")) { + const trimmed = child.text.slice(1); + if (trimmed) { + content.push(newState.schema.text(trimmed, child.marks)); + } + return; + } + } + content.push(child); + } else { + skipNextSpace = false; + content.push(child); + } + }); + + // 创建新的段落节点 + const paragraph = newState.schema.nodes.paragraph.create( + null, + content.length > 0 ? content : undefined + ); + + tr = tr.replaceWith(update.pos, update.pos + node.nodeSize, paragraph); + } + } else { + // 更新标题级别 + tr = tr.setNodeMarkup(update.pos, undefined, { + ...newState.doc.nodeAt(update.pos)?.attrs, + level: update.newLevel, + }); + + // 同时更新语法标记的文本 + const node = newState.doc.nodeAt(update.pos); + if (node) { + let offset = update.pos + 1; + node.forEach((child) => { + if (child.isText) { + const syntaxMark = child.marks.find((m) => m.type.name === "syntax_marker"); + if (syntaxMark && syntaxMark.attrs.syntaxType === "heading") { + const oldText = child.text || ""; + const newText = "#".repeat(update.newLevel) + " "; + if (oldText !== newText) { + // 这里不需要更新文本,因为用户已经手动修改了 + } + } + } + offset += child.nodeSize; + }); + } + } + } + + return tr.docChanged ? tr : null; + }, + }); +} diff --git a/src/core/plugins/html-block-sync.ts b/src/core/plugins/html-block-sync.ts new file mode 100644 index 0000000..1c3568e --- /dev/null +++ b/src/core/plugins/html-block-sync.ts @@ -0,0 +1,29 @@ +/** + * Milkup HTML 块状态同步插件 + * + * 监听选区变化,更新 HTML 块的编辑状态 + */ + +import { Plugin, PluginKey } from "prosemirror-state"; +import { updateAllHtmlBlocks } from "../nodeviews/html-block"; + +export const htmlBlockSyncPluginKey = new PluginKey("milkup-html-block-sync"); + +/** + * 创建 HTML 块状态同步插件 + */ +export function createHtmlBlockSyncPlugin(): Plugin { + return new Plugin({ + key: htmlBlockSyncPluginKey, + + view(editorView) { + return { + update(view, prevState) { + if (!prevState.selection.eq(view.state.selection)) { + updateAllHtmlBlocks(view); + } + }, + }; + }, + }); +} diff --git a/src/core/plugins/image-sync.ts b/src/core/plugins/image-sync.ts new file mode 100644 index 0000000..ff87f98 --- /dev/null +++ b/src/core/plugins/image-sync.ts @@ -0,0 +1,30 @@ +/** + * Milkup 图片状态同步插件 + * + * 监听选区变化,更新图片的编辑状态 + */ + +import { Plugin, PluginKey } from "prosemirror-state"; +import { updateAllImages } from "../nodeviews/image"; + +export const imageSyncPluginKey = new PluginKey("milkup-image-sync"); + +/** + * 创建图片状态同步插件 + */ +export function createImageSyncPlugin(): Plugin { + return new Plugin({ + key: imageSyncPluginKey, + + view(editorView) { + return { + update(view, prevState) { + // 当选区变化时,更新所有图片的编辑状态 + if (!prevState.selection.eq(view.state.selection)) { + updateAllImages(view); + } + }, + }; + }, + }); +} diff --git a/src/core/plugins/index.ts b/src/core/plugins/index.ts new file mode 100644 index 0000000..d359f42 --- /dev/null +++ b/src/core/plugins/index.ts @@ -0,0 +1,17 @@ +/** + * Milkup 插件导出 + */ + +export { + createInstantRenderPlugin, + instantRenderPluginKey, + enableInstantRender, + disableInstantRender, + toggleInstantRender, + getInstantRenderState, + getActiveRegionsFromState, + type InstantRenderState, + type InstantRenderConfig, +} from "./instant-render"; + +export { createInputRulesPlugin } from "./input-rules"; diff --git a/src/core/plugins/input-rules.ts b/src/core/plugins/input-rules.ts new file mode 100644 index 0000000..6e3597c --- /dev/null +++ b/src/core/plugins/input-rules.ts @@ -0,0 +1,511 @@ +/** + * Milkup 输入规则插件 + * + * 自动转换 Markdown 语法 + */ + +import { + inputRules, + wrappingInputRule, + textblockTypeInputRule, + InputRule, +} from "prosemirror-inputrules"; +import { NodeType, MarkType, Schema, Fragment } from "prosemirror-model"; +import { Plugin, TextSelection } from "prosemirror-state"; +import { milkupSchema } from "../schema"; +import { decorationPluginKey } from "../decorations"; + +/** + * 创建标题输入规则 + * # heading -> h1 + * ## heading -> h2 + * ... + * 同时添加 syntax_marker 以支持即时渲染 + */ +function headingRule(nodeType: NodeType, maxLevel: number = 6): InputRule { + return new InputRule(new RegExp(`^(#{1,${maxLevel}})\\s$`), (state, match, start, end) => { + const level = match[1].length; + const schema = state.schema; + const syntaxMarkerType = schema.marks.syntax_marker; + + // 检查是否可以转换为标题 + const $start = state.doc.resolve(start); + if (!$start.node(-1).canReplaceWith($start.index(-1), $start.indexAfter(-1), nodeType)) { + return null; + } + + // 创建语法标记文本(# 带 syntax_marker,空格单独作为普通文本) + const hashText = match[1]; + let content; + if (syntaxMarkerType) { + const syntaxMark = syntaxMarkerType.create({ syntaxType: "heading" }); + content = [schema.text(hashText, [syntaxMark]), schema.text(" ")]; + } else { + content = [schema.text(hashText + " ")]; + } + + // 先删除匹配的文本,然后设置块类型,最后插入语法标记 + let tr = state.tr.delete(start, end); + tr = tr.setBlockType(start, start, nodeType, { level }); + // 在标题开头插入语法标记 + for (const node of content) { + tr = tr.insert(start, node); + start += node.nodeSize; + } + + return tr; + }); +} + +/** + * 创建引用块输入规则 + * > quote + */ +function blockquoteRule(nodeType: NodeType): InputRule { + return wrappingInputRule(/^>\s$/, nodeType); +} + +/** + * 创建代码块输入规则 + * ```lang 并按空格时创建代码块 + */ +function codeBlockRule(nodeType: NodeType): InputRule { + return new InputRule(/^```(\w*) $/, (state, match, start, end) => { + // 源码视图模式下不自动创建代码块 + const decorationState = decorationPluginKey.getState(state); + if (decorationState?.sourceView) { + return null; + } + + const language = match[1] || ""; + const $start = state.doc.resolve(start); + + // 检查是否可以创建代码块 + if (!$start.node(-1).canReplaceWith($start.index(-1), $start.indexAfter(-1), nodeType)) { + return null; + } + + // 获取当前段落的起始位置 + const paragraphStart = $start.start(); + + // 创建空的代码块 + const codeBlock = nodeType.create({ language }); + + // 替换整个段落为代码块 + const tr = state.tr.replaceWith(paragraphStart, end, codeBlock); + + // 将光标移动到代码块内部 + tr.setSelection(TextSelection.create(tr.doc, paragraphStart + 1)); + + return tr; + }); +} + +/** + * 创建分隔线输入规则 + * --- 或 *** 或 ___ + */ +function horizontalRuleRule(nodeType: NodeType): InputRule { + return new InputRule(/^([-*_]){3,}\s$/, (state, match, start, end) => { + const tr = state.tr.replaceWith(start - 1, end, nodeType.create()); + return tr; + }); +} + +/** + * 创建无序列表输入规则 + * - item 或 * item + */ +function bulletListRule(listType: NodeType, itemType: NodeType): InputRule { + return wrappingInputRule(/^[-*+]\s$/, listType, null, (_, node) => node.type === itemType); +} + +/** + * 创建有序列表输入规则 + * 1. item + */ +function orderedListRule(listType: NodeType, itemType: NodeType): InputRule { + return wrappingInputRule( + /^(\d+)\.\s$/, + listType, + (match) => ({ start: parseInt(match[1], 10) }), + (match, node) => node.type === itemType && node.childCount + parseInt(match[1], 10) === 1 + ); +} + +/** + * 创建任务列表输入规则 + * 在无序列表项内输入 [] 或 [ ] 或 [x] 后跟空格,转换为任务列表 + */ +function taskListRule(listType: NodeType, itemType: NodeType): InputRule { + return new InputRule(/^\[([ xX]?)\]\s$/, (state, match, start, end) => { + const checked = match[1].toLowerCase() === "x"; + const $start = state.doc.resolve(start); + + // 检查是否在 list_item > paragraph 内 + if ($start.depth < 2) return null; + + const listItemDepth = $start.depth - 1; + const listItem = $start.node(listItemDepth); + const listDepth = listItemDepth - 1; + const list = $start.node(listDepth); + + if (listItem.type.name !== "list_item" || list.type.name !== "bullet_list") return null; + + // 确保是段落的开头 + const paraStart = $start.start($start.depth); + if (start !== paraStart) return null; + + const listPos = $start.before(listDepth); + const matchLen = end - start; + + // 构建新的段落内容:移除匹配的 [] 文本 + const para = $start.node($start.depth); + const newParaContent = para.content.cut(matchLen); + const newPara = para.type.create( + para.attrs, + newParaContent.size > 0 ? newParaContent : undefined + ); + + // 重建列表项内容:替换第一个段落,保留其余子节点 + const itemChildren: any[] = [newPara]; + for (let i = 1; i < listItem.childCount; i++) { + itemChildren.push(listItem.child(i)); + } + + if (list.childCount === 1) { + // 单项列表:一次性替换整个列表 + const newItem = itemType.create({ checked }, itemChildren); + const newList = listType.create(null, newItem); + let tr = state.tr.replaceWith(listPos, listPos + list.nodeSize, newList); + tr = tr.setSelection(TextSelection.near(tr.doc.resolve(listPos + 2))); + return tr; + } + + // 多项列表:拆分列表,将当前项转换为任务列表 + const itemIndex = $start.index(listDepth); + const newItem = itemType.create({ checked }, itemChildren); + const newTaskList = listType.create(null, newItem); + + const beforeItems: any[] = []; + const afterItems: any[] = []; + list.forEach((child, _offset, index) => { + if (index < itemIndex) beforeItems.push(child); + else if (index > itemIndex) afterItems.push(child); + }); + + const fragments: any[] = []; + if (beforeItems.length > 0) { + fragments.push(list.type.create(list.attrs, Fragment.from(beforeItems))); + } + fragments.push(newTaskList); + if (afterItems.length > 0) { + fragments.push(list.type.create(list.attrs, Fragment.from(afterItems))); + } + + let tr = state.tr.replaceWith(listPos, listPos + list.nodeSize, fragments); + let cursorPos = listPos; + if (beforeItems.length > 0) { + cursorPos += beforeItems.reduce((s: number, n: any) => s + n.nodeSize, 0) + 2; + } + cursorPos += 2; // 进入 task_list > task_item > paragraph + tr = tr.setSelection(TextSelection.near(tr.doc.resolve(cursorPos))); + return tr; + }); +} + +/** + * 创建带 syntax_marker 的行内规则 + * 保持与解析器一致的文档结构 + */ +function createInlineRuleWithSyntax( + pattern: RegExp, + markType: MarkType, + prefix: string | ((match: RegExpMatchArray) => string), + suffix: string | ((match: RegExpMatchArray) => string), + contentIndex: number, + syntaxType: string +): InputRule { + return new InputRule(pattern, (state, match, start, end) => { + const schema = state.schema; + const syntaxMarkerType = schema.marks.syntax_marker; + const contentMark = markType.create(); + + const prefixStr = typeof prefix === "function" ? prefix(match) : prefix; + const suffixStr = typeof suffix === "function" ? suffix(match) : suffix; + // 支持多个捕获组的情况(如 strong 的正则有两种模式) + const content = match[contentIndex] || match[contentIndex + 2] || ""; + + if (!prefixStr || !content) return null; + + let tr = state.tr.delete(start, end); + + // 插入前缀(带 syntax_marker + 语义 mark) + tr = tr.insertText(prefixStr, start); + if (syntaxMarkerType) { + const syntaxMark = syntaxMarkerType.create({ syntaxType }); + tr = tr.addMark(start, start + prefixStr.length, syntaxMark); + } + tr = tr.addMark(start, start + prefixStr.length, contentMark); + + // 插入内容(带语义 mark) + const contentStart = start + prefixStr.length; + tr = tr.insertText(content, contentStart); + tr = tr.addMark(contentStart, contentStart + content.length, contentMark); + + // 插入后缀(带 syntax_marker + 语义 mark) + const suffixStart = contentStart + content.length; + tr = tr.insertText(suffixStr, suffixStart); + if (syntaxMarkerType) { + const syntaxMark = syntaxMarkerType.create({ syntaxType }); + tr = tr.addMark(suffixStart, suffixStart + suffixStr.length, syntaxMark); + } + tr = tr.addMark(suffixStart, suffixStart + suffixStr.length, contentMark); + + return tr; + }); +} + +/** + * 创建行内代码输入规则 + * `code` + */ +function inlineCodeRule(markType: MarkType): InputRule { + return createInlineRuleWithSyntax(/`([^`]+)`$/, markType, "`", "`", 1, "code_inline"); +} + +/** + * 创建粗体输入规则 + * **text** 或 __text__ + * 允许内容包含其他语法标记 + */ +function strongRule(markType: MarkType): InputRule { + return createInlineRuleWithSyntax( + /(? m[1] || m[3], + (m) => m[1] || m[3], + 2, + "strong" + ); +} + +/** + * 创建斜体输入规则 + * *text* 或 _text_ + * 注意:下划线在单词中间时不应该被视为斜体标记 + */ +function emphasisRule(markType: MarkType): InputRule { + return createInlineRuleWithSyntax( + /(? m[1] || m[3], + (m) => m[1] || m[3], + 2, + "emphasis" + ); +} + +/** + * 创建删除线输入规则 + * ~~text~~ + */ +function strikethroughRule(markType: MarkType): InputRule { + return createInlineRuleWithSyntax(/~~(.+?)~~$/, markType, "~~", "~~", 1, "strikethrough"); +} + +/** + * 创建高亮输入规则 + * ==text== + */ +function highlightRule(markType: MarkType): InputRule { + return createInlineRuleWithSyntax(/==(.+?)==$/, markType, "==", "==", 1, "highlight"); +} + +/** + * 创建链接输入规则 + * [text](url) - url 可以为空,排除图片语法 + */ +function linkRule(markType: MarkType): InputRule { + return new InputRule( + /(? { + const schema = state.schema; + const syntaxMarkerType = schema.marks.syntax_marker; + const url = match[2] || ""; + const linkMark = markType.create({ href: url, title: "" }); + + const text = match[1]; + + let tr = state.tr.delete(start, end); + + // 构建链接结构 + const prefix = "["; + const suffix = `](${url})`; + + // 插入前缀 [ (syntax_marker + link) + tr = tr.insertText(prefix, start); + if (syntaxMarkerType) { + const syntaxMark = syntaxMarkerType.create({ syntaxType: "link" }); + tr = tr.addMark(start, start + prefix.length, syntaxMark); + } + tr = tr.addMark(start, start + prefix.length, linkMark); + + // 插入链接文本 (link only) + const textStart = start + prefix.length; + tr = tr.insertText(text, textStart); + tr = tr.addMark(textStart, textStart + text.length, linkMark); + + // 插入后缀 ](url) (syntax_marker + link) + const suffixStart = textStart + text.length; + tr = tr.insertText(suffix, suffixStart); + if (syntaxMarkerType) { + const syntaxMark = syntaxMarkerType.create({ syntaxType: "link" }); + tr = tr.addMark(suffixStart, suffixStart + suffix.length, syntaxMark); + } + tr = tr.addMark(suffixStart, suffixStart + suffix.length, linkMark); + + return tr; + } + ); +} + +/** + * 创建图片输入规则 + * ![alt](src) - 行内图片 + */ +function imageRule(nodeType: NodeType): InputRule { + return new InputRule(/!\[([^\]]*)\]\(([^)]+)\)$/, (state, match, start, end) => { + const alt = match[1] || ""; + const src = match[2] || ""; + + const imageNode = nodeType.create({ src, alt, title: "" }); + + return state.tr.replaceWith(start, end, imageNode); + }); +} + +/** + * 创建数学块输入规则 + * $$ 在行首输入时创建数学块 + */ +function mathBlockRule(nodeType: NodeType): InputRule { + return new InputRule(/^\$\$\s$/, (state, match, start, end) => { + const $start = state.doc.resolve(start); + if (!$start.node(-1).canReplaceWith($start.index(-1), $start.indexAfter(-1), nodeType)) { + return null; + } + return state.tr.delete(start, end).setBlockType(start, start, nodeType); + }); +} + +/** + * 创建单行数学块输入规则 + * $$content$$ 创建数学块 + */ +function mathBlockInlineRule(nodeType: NodeType): InputRule { + return new InputRule(/^\$\$(.+)\$\$$/, (state, match, start, end) => { + const $start = state.doc.resolve(start); + if (!$start.node(-1).canReplaceWith($start.index(-1), $start.indexAfter(-1), nodeType)) { + return null; + } + const content = match[1]; + const textNode = content ? state.schema.text(content) : null; + return state.tr + .delete(start, end) + .replaceWith(start, start, nodeType.create({}, textNode ? [textNode] : [])); + }); +} + +/** + * 创建行内数学公式输入规则 + * $content$ + */ +function mathInlineRule(markType: MarkType): InputRule { + return createInlineRuleWithSyntax( + /(? { + const type = match[1]; + const title = match[2] || ""; + const paragraph = state.schema.nodes.paragraph.create(); + return state.tr.replaceWith(start - 1, end, nodeType.create({ type, title }, paragraph)); + }); +} + +/** + * 创建输入规则插件 + */ +export function createInputRulesPlugin(schema: Schema = milkupSchema): Plugin { + const rules: InputRule[] = []; + + // 块级规则 + if (schema.nodes.heading) { + rules.push(headingRule(schema.nodes.heading)); + } + if (schema.nodes.blockquote) { + rules.push(blockquoteRule(schema.nodes.blockquote)); + } + if (schema.nodes.code_block) { + rules.push(codeBlockRule(schema.nodes.code_block)); + } + if (schema.nodes.horizontal_rule) { + rules.push(horizontalRuleRule(schema.nodes.horizontal_rule)); + } + if (schema.nodes.bullet_list && schema.nodes.list_item) { + rules.push(bulletListRule(schema.nodes.bullet_list, schema.nodes.list_item)); + } + if (schema.nodes.ordered_list && schema.nodes.list_item) { + rules.push(orderedListRule(schema.nodes.ordered_list, schema.nodes.list_item)); + } + if (schema.nodes.task_list && schema.nodes.task_item) { + rules.push(taskListRule(schema.nodes.task_list, schema.nodes.task_item)); + } + if (schema.nodes.math_block) { + rules.push(mathBlockRule(schema.nodes.math_block)); + rules.push(mathBlockInlineRule(schema.nodes.math_block)); + } + if (schema.nodes.container) { + rules.push(containerRule(schema.nodes.container)); + } + + // 行内规则 + if (schema.marks.code_inline) { + rules.push(inlineCodeRule(schema.marks.code_inline)); + } + if (schema.marks.strong) { + rules.push(strongRule(schema.marks.strong)); + } + if (schema.marks.emphasis) { + rules.push(emphasisRule(schema.marks.emphasis)); + } + if (schema.marks.strikethrough) { + rules.push(strikethroughRule(schema.marks.strikethrough)); + } + if (schema.marks.highlight) { + rules.push(highlightRule(schema.marks.highlight)); + } + if (schema.marks.link) { + rules.push(linkRule(schema.marks.link)); + } + if (schema.nodes.image) { + rules.push(imageRule(schema.nodes.image)); + } + if (schema.marks.math_inline) { + rules.push(mathInlineRule(schema.marks.math_inline)); + } + + return inputRules({ rules }); +} diff --git a/src/core/plugins/instant-render.ts b/src/core/plugins/instant-render.ts new file mode 100644 index 0000000..221dd4e --- /dev/null +++ b/src/core/plugins/instant-render.ts @@ -0,0 +1,147 @@ +/** + * Milkup 即时渲染插件 + * + * 核心插件,实现 Typora 风格的即时渲染效果 + * - 语法标记是真实的文本内容 + * - 光标可以在语法标记内自由移动 + * - 根据光标位置动态显示/隐藏语法标记 + */ + +import { Plugin, PluginKey, EditorState, Transaction } from "prosemirror-state"; +import { Node } from "prosemirror-model"; +import { + createDecorationPlugin, + findSyntaxMarkerRegions, + type SyntaxMarkerRegion, +} from "../decorations"; + +/** 即时渲染插件状态 */ +export interface InstantRenderState { + enabled: boolean; + activeRegions: SyntaxMarkerRegion[]; + lastCursorPos: number; +} + +/** 即时渲染插件 Key */ +export const instantRenderPluginKey = new PluginKey("milkup-instant-render"); + +/** 插件配置 */ +export interface InstantRenderConfig { + enabled?: boolean; + delay?: number; +} + +const defaultConfig: InstantRenderConfig = { + enabled: true, + delay: 0, +}; + +/** + * 创建即时渲染插件 + */ +export function createInstantRenderPlugin(config: InstantRenderConfig = {}): Plugin[] { + const mergedConfig = { ...defaultConfig, ...config }; + + const decorationPlugin = createDecorationPlugin(false); + + const controlPlugin = new Plugin({ + key: instantRenderPluginKey, + + state: { + init() { + return { + enabled: mergedConfig.enabled!, + activeRegions: [], + lastCursorPos: 0, + }; + }, + + apply(tr, state, _oldEditorState, newEditorState) { + const meta = tr.getMeta(instantRenderPluginKey); + + if (meta?.enabled !== undefined) { + return { + ...state, + enabled: meta.enabled, + }; + } + + const cursorPos = newEditorState.selection.head; + if (cursorPos !== state.lastCursorPos || tr.docChanged) { + const regions = findSyntaxMarkerRegions(newEditorState.doc); + const activeRegions = regions.filter((r) => cursorPos >= r.from && cursorPos <= r.to); + + return { + ...state, + activeRegions, + lastCursorPos: cursorPos, + }; + } + + return state; + }, + }, + }); + + return [decorationPlugin, controlPlugin]; +} + +/** + * 启用即时渲染 + */ +export function enableInstantRender( + state: EditorState, + dispatch?: (tr: Transaction) => void +): boolean { + if (dispatch) { + const tr = state.tr.setMeta(instantRenderPluginKey, { enabled: true }); + dispatch(tr); + } + return true; +} + +/** + * 禁用即时渲染 + */ +export function disableInstantRender( + state: EditorState, + dispatch?: (tr: Transaction) => void +): boolean { + if (dispatch) { + const tr = state.tr.setMeta(instantRenderPluginKey, { enabled: false }); + dispatch(tr); + } + return true; +} + +/** + * 切换即时渲染 + */ +export function toggleInstantRender( + state: EditorState, + dispatch?: (tr: Transaction) => void +): boolean { + const pluginState = instantRenderPluginKey.getState(state); + if (!pluginState) return false; + + if (dispatch) { + const tr = state.tr.setMeta(instantRenderPluginKey, { enabled: !pluginState.enabled }); + dispatch(tr); + } + return true; +} + +/** + * 获取当前即时渲染状态 + */ +export function getInstantRenderState(state: EditorState): InstantRenderState | undefined { + return instantRenderPluginKey.getState(state); +} + +/** + * 获取当前活跃的语法区域 + */ +export function getActiveRegionsFromState(state: EditorState): SyntaxMarkerRegion[] { + const pluginState = instantRenderPluginKey.getState(state); + return pluginState?.activeRegions ?? []; +} diff --git a/src/core/plugins/line-numbers.ts b/src/core/plugins/line-numbers.ts new file mode 100644 index 0000000..36f2908 --- /dev/null +++ b/src/core/plugins/line-numbers.ts @@ -0,0 +1,130 @@ +/** + * Milkup 行号插件 + * + * 在源码模式下显示行号 + */ + +import { Plugin, PluginKey } from "prosemirror-state"; +import { Decoration, DecorationSet } from "prosemirror-view"; +import { decorationPluginKey } from "../decorations"; + +/** 插件 Key */ +export const lineNumbersPluginKey = new PluginKey("milkup-line-numbers"); + +/** + * 创建行号装饰 + * 为顶层块级节点添加行号类 + * 代码块需要特殊处理,计算其行数 + */ +function createLineNumberDecorations(doc: any, sourceView: boolean): DecorationSet { + if (!sourceView) { + return DecorationSet.empty; + } + + const decorations: Decoration[] = []; + + doc.descendants((node: any, pos: number, parent: any) => { + if (node.isBlock) { + const isListContainer = ["bullet_list", "ordered_list", "task_list"].includes(node.type.name); + const isListItem = ["list_item", "task_item"].includes(node.type.name); + const parentIsListItem = parent && ["list_item", "task_item"].includes(parent.type.name); + const parentIsDoc = parent && parent.type.name === "doc"; + const parentIsBlockquote = parent && parent.type.name === "blockquote"; + const parentIsContainer = parent && parent.type.name === "container"; + + // 跳过列表容器本身 + if (isListContainer) { + return true; + } + + // 列表项内部的段落:每个段落独立参与行号计算 + if (parentIsListItem && node.type.name === "paragraph") { + decorations.push( + Decoration.node(pos, pos + node.nodeSize, { + class: "milkup-with-line-number milkup-list-line-number", + }) + ); + return false; + } + + // 跳过列表项本身(行号由其内部段落承担) + if (isListItem) { + return true; + } + + // 跳过列表项内部的其他块级节点 + if (parentIsListItem) { + return true; + } + + // doc 直接子节点、blockquote/container 直接子节点 + if (parentIsDoc || parentIsBlockquote || parentIsContainer) { + if (node.type.name === "code_block") { + // 代码块:计算行数(包括开始和结束的 ```) + const language = node.attrs.language || ""; + const content = node.textContent || ""; + const fullMarkdown = `\`\`\`${language}\n${content}\n\`\`\``; + const lineCount = fullMarkdown.split("\n").length; + + // 为代码块添加装饰,包含行数信息 + decorations.push( + Decoration.node(pos, pos + node.nodeSize, { + class: "milkup-code-block-with-lines", + "data-line-count": lineCount.toString(), + }) + ); + } else { + // 其他块级节点:添加普通行号类 + decorations.push( + Decoration.node(pos, pos + node.nodeSize, { + class: "milkup-with-line-number", + }) + ); + } + } + } + return true; + }); + + return DecorationSet.create(doc, decorations); +} + +/** + * 创建行号插件 + */ +export function createLineNumbersPlugin(): Plugin { + return new Plugin({ + key: lineNumbersPluginKey, + + state: { + init(_, state) { + const decorationState = decorationPluginKey.getState(state); + const sourceView = decorationState?.sourceView ?? false; + return createLineNumberDecorations(state.doc, sourceView); + }, + + apply(tr, oldDecorations, oldState, newState) { + // 检查源码模式状态 + const decorationState = decorationPluginKey.getState(newState); + const sourceView = decorationState?.sourceView ?? false; + + // 如果文档没有变化且源码模式状态没有变化,保持原有装饰 + const oldDecorationState = decorationPluginKey.getState(oldState); + const oldSourceView = oldDecorationState?.sourceView ?? false; + + if (!tr.docChanged && sourceView === oldSourceView) { + return oldDecorations.map(tr.mapping, tr.doc); + } + + // 重新创建装饰 + return createLineNumberDecorations(newState.doc, sourceView); + }, + }, + + props: { + decorations(state) { + return lineNumbersPluginKey.getState(state); + }, + }, + }); +} diff --git a/src/core/plugins/link-tooltip.ts b/src/core/plugins/link-tooltip.ts new file mode 100644 index 0000000..7dbbf56 --- /dev/null +++ b/src/core/plugins/link-tooltip.ts @@ -0,0 +1,202 @@ +/** + * 链接 Tooltip 插件 + * + * 鼠标悬停在链接上时显示 tooltip,提示链接地址和快捷键 + * Ctrl/Cmd + 左击用默认浏览器打开链接 + * 拦截所有链接的默认跳转行为 + */ + +import { Plugin } from "prosemirror-state"; +import { EditorView } from "prosemirror-view"; + +const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform); +const modKey = isMac ? "⌘" : "Ctrl"; + +/** 从 DOM 元素向上查找最近的
标签 */ +function findLinkElement(target: HTMLElement, root: HTMLElement): HTMLAnchorElement | null { + let el: HTMLElement | null = target; + while (el && el !== root) { + if (el.tagName === "A" && el.getAttribute("href")) { + return el as HTMLAnchorElement; + } + el = el.parentElement; + } + return null; +} + +/** 用默认浏览器打开链接 */ +function openLinkExternal(href: string) { + const electronAPI = (window as any).electronAPI; + if (electronAPI?.openExternal) { + electronAPI.openExternal(href); + } else { + window.open(href, "_blank", "noopener,noreferrer"); + } +} + +export function createLinkTooltipPlugin(): Plugin { + let tooltip: HTMLElement | null = null; + let currentLink: HTMLAnchorElement | null = null; + let hideTimer: ReturnType | null = null; + let editorView: EditorView | null = null; + + function getContainer(): HTMLElement { + return editorView?.dom.parentElement || document.body; + } + + function ensureTooltip(): HTMLElement { + if (!tooltip) { + tooltip = document.createElement("div"); + tooltip.className = "milkup-link-tooltip"; + const container = getContainer(); + if (getComputedStyle(container).position === "static") { + container.style.position = "relative"; + } + container.appendChild(tooltip); + } + return tooltip; + } + + function showTooltip(linkEl: HTMLAnchorElement, href: string) { + if (hideTimer) { + clearTimeout(hideTimer); + hideTimer = null; + } + if (currentLink === linkEl && tooltip?.style.display === "block") return; + + const tip = ensureTooltip(); + const displayHref = href.length > 60 ? href.slice(0, 57) + "..." : href; + tip.textContent = `${displayHref} ${modKey}+左击访问`; + tip.style.display = "block"; + + const linkRect = linkEl.getBoundingClientRect(); + const container = getContainer(); + const containerRect = container.getBoundingClientRect(); + + let left = linkRect.left - containerRect.left; + const top = linkRect.bottom - containerRect.top + 4; + + tip.style.top = `${top}px`; + tip.style.left = `${left}px`; + + requestAnimationFrame(() => { + if (!tooltip) return; + const tipRect = tooltip.getBoundingClientRect(); + const cr = container.getBoundingClientRect(); + if (tipRect.right > cr.right - 8) { + left = cr.right - cr.left - tipRect.width - 8; + tooltip.style.left = `${Math.max(0, left)}px`; + } + }); + + currentLink = linkEl; + } + + function hideTooltipDelayed() { + if (hideTimer) clearTimeout(hideTimer); + hideTimer = setTimeout(() => { + if (tooltip) tooltip.style.display = "none"; + currentLink = null; + hideTimer = null; + }, 150); + } + + function hideTooltipImmediate() { + if (hideTimer) { + clearTimeout(hideTimer); + hideTimer = null; + } + if (tooltip) tooltip.style.display = "none"; + currentLink = null; + } + + function destroyTooltip() { + hideTooltipImmediate(); + if (tooltip) { + tooltip.remove(); + tooltip = null; + } + } + + // ---- 事件处理 ---- + + /** mousemove:检测鼠标是否在链接上,显示/隐藏 tooltip */ + function onMouseMove(e: MouseEvent) { + if (!editorView) return; + const target = e.target as HTMLElement; + const linkEl = findLinkElement(target, editorView.dom); + + if (linkEl) { + const href = linkEl.getAttribute("href") || ""; + if (href) { + showTooltip(linkEl, href); + return; + } + } + + // 鼠标不在链接上 + if (currentLink) { + hideTooltipDelayed(); + } + } + + /** mouseleave:鼠标离开编辑器区域时隐藏 tooltip */ + function onMouseLeave() { + hideTooltipDelayed(); + } + + /** + * click(capture 阶段): + * - 拦截所有 标签的默认跳转 + * - Ctrl/Cmd+左击时用默认浏览器打开 + */ + function onClickCapture(e: MouseEvent) { + if (!editorView) return; + const target = e.target as HTMLElement; + const linkEl = findLinkElement(target, editorView.dom); + if (!linkEl) return; + + const href = linkEl.getAttribute("href") || ""; + if (!href) return; + + // 始终阻止 标签的默认跳转行为 + e.preventDefault(); + + // Ctrl/Cmd + 左击:用默认浏览器打开 + const modPressed = isMac ? e.metaKey : e.ctrlKey; + if (modPressed) { + openLinkExternal(href); + e.stopPropagation(); + } + } + + function onScroll() { + hideTooltipImmediate(); + } + + return new Plugin({ + view(view) { + editorView = view; + const dom = view.dom; + + dom.addEventListener("mousemove", onMouseMove); + dom.addEventListener("mouseleave", onMouseLeave); + // capture 阶段拦截,确保在 Electron 导航之前阻止 + dom.addEventListener("click", onClickCapture, true); + + const scrollParent = dom.closest(".scrollView") || dom.parentElement; + scrollParent?.addEventListener("scroll", onScroll, { passive: true }); + + return { + destroy() { + dom.removeEventListener("mousemove", onMouseMove); + dom.removeEventListener("mouseleave", onMouseLeave); + dom.removeEventListener("click", onClickCapture, true); + scrollParent?.removeEventListener("scroll", onScroll); + destroyTooltip(); + editorView = null; + }, + }; + }, + }); +} diff --git a/src/core/plugins/math-block-sync.ts b/src/core/plugins/math-block-sync.ts new file mode 100644 index 0000000..c034094 --- /dev/null +++ b/src/core/plugins/math-block-sync.ts @@ -0,0 +1,30 @@ +/** + * Milkup 数学块状态同步插件 + * + * 监听选区变化,更新数学块的编辑状态 + */ + +import { Plugin, PluginKey } from "prosemirror-state"; +import { updateAllMathBlocks } from "../nodeviews/math-block"; + +export const mathBlockSyncPluginKey = new PluginKey("milkup-math-block-sync"); + +/** + * 创建数学块状态同步插件 + */ +export function createMathBlockSyncPlugin(): Plugin { + return new Plugin({ + key: mathBlockSyncPluginKey, + + view(editorView) { + return { + update(view, prevState) { + // 当选区变化时,更新所有数学块的编辑状态 + if (!prevState.selection.eq(view.state.selection)) { + updateAllMathBlocks(view); + } + }, + }; + }, + }); +} diff --git a/src/core/plugins/paste.ts b/src/core/plugins/paste.ts new file mode 100644 index 0000000..24b6506 --- /dev/null +++ b/src/core/plugins/paste.ts @@ -0,0 +1,322 @@ +/** + * Milkup 粘贴处理插件 + * + * 处理粘贴的 Markdown 文本和图片 + */ + +import { Plugin, PluginKey } from "prosemirror-state"; +import { Node, Schema, Slice, Fragment } from "prosemirror-model"; +import { MarkdownParser } from "../parser"; +import { milkupSchema } from "../schema"; +import { decorationPluginKey } from "../decorations"; + +/** 插件 Key */ +export const pastePluginKey = new PluginKey("milkup-paste"); + +/** 图片粘贴方式 */ +export type ImagePasteMethod = "base64" | "local" | "remote"; + +/** 图片上传函数类型 */ +export type ImageUploader = (file: File) => Promise; + +/** 本地图片保存函数类型 */ +export type LocalImageSaver = (file: File) => Promise; + +/** 粘贴插件配置 */ +export interface PastePluginConfig { + /** 获取图片粘贴方式 */ + getImagePasteMethod?: () => ImagePasteMethod; + /** 图片上传函数(用于 remote 模式) */ + imageUploader?: ImageUploader; + /** 本地图片保存函数(用于 local 模式) */ + localImageSaver?: LocalImageSaver; +} + +/** 默认配置 */ +const defaultConfig: PastePluginConfig = { + getImagePasteMethod: () => { + const method = localStorage.getItem("pasteMethod"); + return (method as ImagePasteMethod) || "base64"; + }, +}; + +/** + * 创建粘贴处理插件 + */ +export function createPastePlugin(config: PastePluginConfig = {}): Plugin { + const parser = new MarkdownParser(milkupSchema); + const mergedConfig = { ...defaultConfig, ...config }; + + return new Plugin({ + key: pastePluginKey, + + props: { + handlePaste(view, event, slice) { + const clipboardData = event.clipboardData; + if (!clipboardData) return false; + + // 检查是否处于源码模式 + const decoState = decorationPluginKey.getState(view.state); + const isSourceView = decoState?.sourceView ?? false; + + // 检查是否有图片 + const files = clipboardData.files; + if (files && files.length > 0) { + const hasImage = Array.from(files).some((file) => file.type.startsWith("image/")); + if (hasImage) { + if (isSourceView) { + // 源码模式下:图片粘贴创建段落而非 image 节点 + handleImagePasteAsText(view, files, mergedConfig); + } else { + // 正常模式:创建 image 节点 + handleImagePaste(view, files, mergedConfig); + } + return true; + } + } + + // 获取粘贴的纯文本 + const text = clipboardData.getData("text/plain"); + if (!text) return false; + + // 源码模式下:所有文本都作为纯文本插入,不解析 Markdown + if (isSourceView) { + return false; // 让默认处理器插入纯文本 + } + + // 检查是否包含 Markdown 语法 + if (!containsMarkdownSyntax(text)) { + return false; // 让默认处理器处理 + } + + // 检查是否来自编辑器内部复制(ProseMirror 会在 HTML 中添加 data-pm-slice 标记) + const html = clipboardData.getData("text/html"); + if (html && html.includes("data-pm-slice")) { + return false; // 内部复制,让 ProseMirror 默认处理 + } + + // 解析 Markdown + const { doc } = parser.parse(text); + + // 获取解析后的内容 + const content = doc.content; + + // 如果内容为空,不处理 + if (content.size === 0) return false; + + // 延迟到下一帧插入,确保 ProseMirror 完成粘贴事件处理后再更新视图 + // 这样装饰系统能正确重新计算所有语法标记的显示/隐藏状态 + requestAnimationFrame(() => { + const pasteSlice = new Slice(content, 1, 1); + const tr = view.state.tr.replaceSelection(pasteSlice); + view.dispatch(tr); + }); + + return true; + }, + }, + }); +} + +/** + * 处理图片粘贴 + */ +async function handleImagePaste( + view: any, + files: FileList, + config: PastePluginConfig +): Promise { + const method = config.getImagePasteMethod?.() || "base64"; + const schema = view.state.schema; + const nodes: Node[] = []; + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (!file.type.startsWith("image/")) continue; + + try { + let src: string; + + switch (method) { + case "base64": + src = await fileToBase64(file); + break; + + case "remote": + if (config.imageUploader) { + src = await config.imageUploader(file); + } else { + console.warn("Image uploader not configured, falling back to base64"); + src = await fileToBase64(file); + } + break; + + case "local": + if (config.localImageSaver) { + src = await config.localImageSaver(file); + } else { + // 尝试使用 Electron API + src = await saveImageLocally(file); + } + break; + + default: + src = await fileToBase64(file); + } + + const imageNode = schema.nodes.image?.createAndFill({ + src, + alt: file.name, + title: "", + }); + + if (imageNode) { + nodes.push(imageNode); + } + } catch (error) { + console.error("Failed to process image:", error); + } + } + + if (nodes.length > 0) { + const { $from } = view.state.selection; + let tr = view.state.tr; + + for (const node of nodes) { + tr = tr.insert($from.pos, node); + } + + view.dispatch(tr); + } +} + +/** + * 源码模式下处理图片粘贴:创建包含 Markdown 文本的段落 + */ +async function handleImagePasteAsText( + view: any, + files: FileList, + config: PastePluginConfig +): Promise { + const method = config.getImagePasteMethod?.() || "base64"; + const schema = view.state.schema; + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (!file.type.startsWith("image/")) continue; + + try { + let src: string; + + switch (method) { + case "base64": + src = await fileToBase64(file); + break; + case "remote": + if (config.imageUploader) { + src = await config.imageUploader(file); + } else { + src = await fileToBase64(file); + } + break; + case "local": + if (config.localImageSaver) { + src = await config.localImageSaver(file); + } else { + src = await saveImageLocally(file); + } + break; + default: + src = await fileToBase64(file); + } + + const alt = file.name; + const markdownText = `![${alt}](${src})`; + const paragraph = schema.nodes.paragraph.create( + { imageAttrs: { src, alt, title: "" } }, + schema.text(markdownText) + ); + + const { $from } = view.state.selection; + const tr = view.state.tr.insert($from.pos, paragraph); + view.dispatch(tr); + } catch (error) { + console.error("Failed to process image:", error); + } + } +} + +/** + * 将文件转换为 base64 + */ +export function fileToBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = (error) => reject(error); + reader.readAsDataURL(file); + }); +} + +/** + * 保存图片到本地 + */ +export async function saveImageLocally(file: File): Promise { + // 检查是否在 Electron 环境中 + if (typeof window !== "undefined" && (window as any).electronAPI) { + const electronAPI = (window as any).electronAPI; + + // 尝试获取剪贴板中的文件路径 + const filePath = await electronAPI.getFilePathInClipboard?.(); + if (filePath) { + return filePath; + } + + // 检查 File 对象是否有 path 属性(Electron 环境) + const absolutePath = (file as any).path; + if (absolutePath) { + return absolutePath; + } + + // 将图片保存到临时目录 + const arrayBuffer = await file.arrayBuffer(); + const buffer = new Uint8Array(arrayBuffer); + const localImagePath = localStorage.getItem("localImagePath") || "/temp"; + const tempPath = await electronAPI.writeTempImage?.(buffer, localImagePath); + + if (tempPath) { + return tempPath; + } + } + + // 如果不在 Electron 环境或保存失败,回退到 base64 + console.warn("Local image saving not available, falling back to base64"); + return fileToBase64(file); +} + +/** + * 检查文本是否包含 Markdown 语法 + */ +function containsMarkdownSyntax(text: string): boolean { + const patterns = [ + /^#{1,6}\s/m, // 标题 + /\*\*[^*]+\*\*/, // 粗体 + /\*[^*]+\*/, // 斜体 + /~~[^~]+~~/, // 删除线 + /`[^`]+`/, // 行内代码 + /^```/m, // 代码块 + /\[[^\]]+\]\([^)]*\)/, // 链接(允许空 URL) + /!\[[^\]]*\]\([^)]+\)/, // 图片 + /^>\s?/m, // 引用 + /^[-*+]\s/m, // 无序列表 + /^\d+\.\s/m, // 有序列表 + /^[-*_]{3,}\s*$/m, // 分隔线 + /==[^=]+==/, // 高亮 + /^\$\$/m, // 数学块 + /\$[^$]+\$/, // 行内数学 + /^- \[[ xX]\]/m, // 任务列表 + /^\|.+\|$/m, // 表格 + ]; + + return patterns.some((pattern) => pattern.test(text)); +} diff --git a/src/core/plugins/placeholder.ts b/src/core/plugins/placeholder.ts new file mode 100644 index 0000000..21a1108 --- /dev/null +++ b/src/core/plugins/placeholder.ts @@ -0,0 +1,65 @@ +/** + * Milkup Placeholder 插件 + * + * 在编辑器为空时显示占位符文本 + */ + +import { Plugin, PluginKey } from "prosemirror-state"; +import { decorationPluginKey } from "../decorations"; + +/** 插件 Key */ +export const placeholderPluginKey = new PluginKey("milkup-placeholder"); + +/** + * 检查文档是否为空 + */ +function isEmpty(doc: any): boolean { + // 文档只有一个子节点 + if (doc.childCount !== 1) return false; + + const firstChild = doc.firstChild; + + // 第一个子节点是段落 + if (firstChild.type.name !== "paragraph") return false; + + // 段落没有内容或只有空白内容 + return firstChild.content.size === 0; +} + +/** + * 创建 placeholder 插件 + */ +export function createPlaceholderPlugin(placeholder: string): Plugin { + return new Plugin({ + key: placeholderPluginKey, + + props: { + attributes(state) { + const doc = state.doc; + const decorationState = decorationPluginKey.getState(state); + const sourceView = decorationState?.sourceView ?? false; + + // 构建类名 + let className = "milkup-editor"; + if (isEmpty(doc)) { + className += " milkup-empty"; + } + if (sourceView) { + className += " source-view"; + } + + // 如果文档为空,添加 data-placeholder 属性 + if (isEmpty(doc)) { + return { + "data-placeholder": placeholder, + class: className, + }; + } + + return { + class: className, + }; + }, + }, + }); +} diff --git a/src/core/plugins/search.ts b/src/core/plugins/search.ts new file mode 100644 index 0000000..09daeb9 --- /dev/null +++ b/src/core/plugins/search.ts @@ -0,0 +1,343 @@ +/** + * Milkup 搜索替换插件 + * + * 提供 VS Code 风格的搜索替换功能: + * - 文本搜索与高亮 + * - 区分大小写 / 全字匹配 / 正则表达式 + * - 选区内搜索 + * - 替换 / 全部替换 + */ + +import { Plugin, PluginKey, TextSelection } from "prosemirror-state"; +import { Decoration, DecorationSet, EditorView } from "prosemirror-view"; +import { Node } from "prosemirror-model"; + +/** 搜索选项 */ +export interface SearchOptions { + caseSensitive: boolean; + wholeWord: boolean; + useRegex: boolean; + searchInSelection: boolean; + selectionRange: { from: number; to: number } | null; +} + +/** 搜索状态 */ +export interface SearchState { + query: string; + caseSensitive: boolean; + wholeWord: boolean; + useRegex: boolean; + searchInSelection: boolean; + selectionRange: { from: number; to: number } | null; + matches: Array<{ from: number; to: number }>; + currentIndex: number; + decorations: DecorationSet; +} + +export const searchPluginKey = new PluginKey("milkup-search"); + +/** 从文档中提取纯文本及位置映射 */ +function getDocText(doc: Node): { text: string; posMap: number[] } { + let text = ""; + const posMap: number[] = []; // posMap[textIndex] = docPos + + doc.descendants((node, pos) => { + if (node.isText) { + for (let i = 0; i < node.text!.length; i++) { + posMap.push(pos + i); + text += node.text![i]; + } + } else if (node.isBlock && text.length > 0 && text[text.length - 1] !== "\n") { + posMap.push(pos); + text += "\n"; + } + return true; + }); + + return { text, posMap }; +} + +/** 转义正则特殊字符 */ +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** 剥离 Markdown 语法,保留纯文本内容 */ +function stripMarkdownSyntax(query: string): string { + let s = query; + // 标题前缀: ### text → text + s = s.replace(/^#{1,6}\s+/, ""); + // 图片: ![alt](url) → alt + s = s.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1"); + // 链接: [text](url) → text + s = s.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1"); + // 粗体: **text** 或 __text__ + s = s.replace(/\*\*(.+?)\*\*/g, "$1"); + s = s.replace(/__(.+?)__/g, "$1"); + // 删除线: ~~text~~ + s = s.replace(/~~(.+?)~~/g, "$1"); + // 行内代码: `text` + s = s.replace(/`(.+?)`/g, "$1"); + // 斜体: *text* 或 _text_ + s = s.replace(/\*(.+?)\*/g, "$1"); + s = s.replace(/_(.+?)_/g, "$1"); + return s; +} + +/** 查找所有匹配 */ +function findMatches( + doc: Node, + query: string, + options: SearchOptions +): Array<{ from: number; to: number }> { + if (!query) return []; + + // 非正则模式下,剥离 Markdown 语法 + if (!options.useRegex) { + const stripped = stripMarkdownSyntax(query); + if (stripped && stripped !== query) { + query = stripped; + } + } + + const { text, posMap } = getDocText(doc); + const matches: Array<{ from: number; to: number }> = []; + + let pattern: string; + let flags = "g"; + if (!options.caseSensitive) flags += "i"; + + try { + if (options.useRegex) { + pattern = query; + } else { + pattern = escapeRegex(query); + } + if (options.wholeWord) { + pattern = `\\b${pattern}\\b`; + } + + const regex = new RegExp(pattern, flags); + let match: RegExpExecArray | null; + + while ((match = regex.exec(text)) !== null) { + if (match[0].length === 0) { + regex.lastIndex++; + continue; + } + + const startIdx = match.index; + const endIdx = match.index + match[0].length; + + if (startIdx >= posMap.length || endIdx - 1 >= posMap.length) continue; + + const from = posMap[startIdx]; + const to = posMap[endIdx - 1] + 1; + + if (options.searchInSelection && options.selectionRange) { + const { from: selFrom, to: selTo } = options.selectionRange; + if (from < selFrom || to > selTo) continue; + } + + matches.push({ from, to }); + } + } catch { + // 无效正则,返回空 + } + + return matches; +} + +/** 构建装饰集 */ +function buildDecorations( + doc: Node, + matches: Array<{ from: number; to: number }>, + currentIndex: number +): DecorationSet { + if (matches.length === 0) return DecorationSet.empty; + + const decorations = matches.map((m, i) => { + const cls = + i === currentIndex + ? "milkup-search-match milkup-search-match-current" + : "milkup-search-match"; + return Decoration.inline(m.from, m.to, { class: cls }); + }); + + return DecorationSet.create(doc, decorations); +} + +/** 空搜索状态 */ +function emptyState(): SearchState { + return { + query: "", + caseSensitive: false, + wholeWord: false, + useRegex: false, + searchInSelection: false, + selectionRange: null, + matches: [], + currentIndex: -1, + decorations: DecorationSet.empty, + }; +} + +/** 创建搜索插件 */ +export function createSearchPlugin(): Plugin { + return new Plugin({ + key: searchPluginKey, + + state: { + init() { + return emptyState(); + }, + + apply(tr, prev, _oldState, newState): SearchState { + // 处理 setSearchQuery meta + const queryMeta = tr.getMeta(searchPluginKey) as + | { type: "setQuery"; query: string; options: SearchOptions } + | { type: "setIndex"; index: number } + | { type: "clear" } + | undefined; + + if (queryMeta) { + if (queryMeta.type === "setQuery") { + const { query, options } = queryMeta; + const matches = findMatches(newState.doc, query, options); + const currentIndex = matches.length > 0 ? 0 : -1; + return { + query, + ...options, + matches, + currentIndex, + decorations: buildDecorations(newState.doc, matches, currentIndex), + }; + } + if (queryMeta.type === "setIndex") { + const currentIndex = queryMeta.index; + return { + ...prev, + currentIndex, + decorations: buildDecorations(newState.doc, prev.matches, currentIndex), + }; + } + if (queryMeta.type === "clear") { + return emptyState(); + } + } + + // 文档变化时重新搜索 + if (tr.docChanged && prev.query) { + const options: SearchOptions = { + caseSensitive: prev.caseSensitive, + wholeWord: prev.wholeWord, + useRegex: prev.useRegex, + searchInSelection: prev.searchInSelection, + selectionRange: prev.selectionRange, + }; + const matches = findMatches(newState.doc, prev.query, options); + let currentIndex = prev.currentIndex; + if (currentIndex >= matches.length) { + currentIndex = matches.length > 0 ? 0 : -1; + } + return { + ...prev, + matches, + currentIndex, + decorations: buildDecorations(newState.doc, matches, currentIndex), + }; + } + + return prev; + }, + }, + + props: { + decorations(state) { + return searchPluginKey.getState(state)?.decorations ?? DecorationSet.empty; + }, + }, + }); +} + +// ========== 导出辅助函数 ========== + +/** 更新搜索 */ +export function updateSearch(view: EditorView, query: string, options: SearchOptions): void { + const tr = view.state.tr.setMeta(searchPluginKey, { + type: "setQuery", + query, + options, + }); + view.dispatch(tr); +} + +/** 下一个匹配 */ +export function findNext(view: EditorView): void { + const state = searchPluginKey.getState(view.state); + if (!state || state.matches.length === 0) return; + + const nextIndex = (state.currentIndex + 1) % state.matches.length; + const match = state.matches[nextIndex]; + const tr = view.state.tr + .setMeta(searchPluginKey, { type: "setIndex", index: nextIndex }) + .setSelection(TextSelection.create(view.state.doc, match.from)) + .scrollIntoView(); + view.dispatch(tr); + scrollCurrentMatchIntoView(view); +} + +/** 上一个匹配 */ +export function findPrev(view: EditorView): void { + const state = searchPluginKey.getState(view.state); + if (!state || state.matches.length === 0) return; + + const prevIndex = (state.currentIndex - 1 + state.matches.length) % state.matches.length; + const match = state.matches[prevIndex]; + const tr = view.state.tr + .setMeta(searchPluginKey, { type: "setIndex", index: prevIndex }) + .setSelection(TextSelection.create(view.state.doc, match.from)) + .scrollIntoView(); + view.dispatch(tr); + scrollCurrentMatchIntoView(view); +} + +/** 滚动当前匹配项到可视区域 */ +function scrollCurrentMatchIntoView(view: EditorView): void { + requestAnimationFrame(() => { + const el = view.dom.querySelector(".milkup-search-match-current"); + if (el) { + el.scrollIntoView({ block: "center", behavior: "smooth" }); + } + }); +} + +/** 替换当前匹配 */ +export function replaceMatch(view: EditorView, replacement: string): void { + const state = searchPluginKey.getState(view.state); + if (!state || state.currentIndex < 0 || state.currentIndex >= state.matches.length) return; + + const match = state.matches[state.currentIndex]; + const tr = view.state.tr.insertText(replacement, match.from, match.to); + view.dispatch(tr); +} + +/** 替换所有匹配 */ +export function replaceAll(view: EditorView, replacement: string): void { + const state = searchPluginKey.getState(view.state); + if (!state || state.matches.length === 0) return; + + // 从后往前替换,避免位置偏移 + let tr = view.state.tr; + for (let i = state.matches.length - 1; i >= 0; i--) { + const match = state.matches[i]; + tr = tr.insertText(replacement, match.from, match.to); + } + view.dispatch(tr); +} + +/** 清除搜索 */ +export function clearSearch(view: EditorView): void { + const tr = view.state.tr.setMeta(searchPluginKey, { type: "clear" }); + view.dispatch(tr); +} diff --git a/src/core/plugins/source-view-transform.ts b/src/core/plugins/source-view-transform.ts new file mode 100644 index 0000000..aa3e667 --- /dev/null +++ b/src/core/plugins/source-view-transform.ts @@ -0,0 +1,727 @@ +/** + * Milkup 源码模式文档转换插件 + * + * 在源码模式下将块级元素(代码块、图片、分割线)拆分/转换为段落节点 + * 在退出源码模式时将段落节点重新组合为对应的块级元素 + */ + +import { Plugin, PluginKey, Transaction } from "prosemirror-state"; +import { Node as ProseMirrorNode, Schema, Fragment, Slice } from "prosemirror-model"; +import { ReplaceStep } from "prosemirror-transform"; +import { decorationPluginKey } from "../decorations"; +import { parseMarkdown } from "../parser"; + +/** 插件 Key */ +export const sourceViewTransformPluginKey = new PluginKey("milkup-source-view-transform"); + +/** 代码块标记属性 */ +interface CodeBlockMarker { + codeBlockId: string; + lineIndex: number; + totalLines: number; + language: string; +} + +/** + * 生成唯一的代码块 ID + */ +function generateCodeBlockId(): string { + return `cb_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; +} + +/** + * 生成唯一的 HTML 块 ID + */ +function generateHtmlBlockId(): string { + return `hb_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; +} + +/** + * 将代码块转换为多个段落节点 + */ +function transformCodeBlockToParagraphs( + codeBlock: ProseMirrorNode, + schema: Schema +): ProseMirrorNode[] { + const language = codeBlock.attrs.language || ""; + let content = codeBlock.textContent; + const codeBlockId = generateCodeBlockId(); + + // 移除内容末尾的换行符,避免产生多余的空行 + // 但保留内容中间的空行 + content = content.replace(/\n+$/, ""); + + // 构建完整的 Markdown 代码块文本 + const fullMarkdown = `\`\`\`${language}\n${content}\n\`\`\``; + const lines = fullMarkdown.split("\n"); + + // 为每一行创建一个段落节点 + const paragraphs: ProseMirrorNode[] = []; + lines.forEach((line, index) => { + // 为空行也创建段落,但内容为空 + const textContent = line.length > 0 ? schema.text(line) : undefined; + const paragraph = schema.nodes.paragraph.create( + { + codeBlockId, + lineIndex: index, + totalLines: lines.length, + language, + }, + textContent + ); + paragraphs.push(paragraph); + }); + + return paragraphs; +} + +/** + * 将连续的代码块段落节点重新组合成代码块 + */ +function transformParagraphsToCodeBlock( + paragraphs: Array<{ node: ProseMirrorNode; pos: number }>, + schema: Schema +): { codeBlock: ProseMirrorNode; language: string } | null { + if (paragraphs.length === 0) return null; + + // 获取代码块信息 + const firstPara = paragraphs[0].node; + const language = firstPara.attrs.language || ""; + + // 提取所有行的文本 + const lines = paragraphs.map((p) => p.node.textContent); + + // 验证是否是完整的代码块格式 + const fullText = lines.join("\n"); + const fenceMatch = fullText.match(/^```([^\n]*?)\n([\s\S]*?)\n```$/); + + if (!fenceMatch) { + // 不是完整的代码块格式,返回 null + return null; + } + + const [, lang, content] = fenceMatch; + + // 创建代码块节点 + const codeBlock = schema.nodes.code_block.create( + { language: lang || "" }, + content ? schema.text(content) : null + ); + + return { codeBlock, language: lang || "" }; +} + +/** + * 将图片节点转换为段落节点 + */ +function transformImageToParagraph(image: ProseMirrorNode, schema: Schema): ProseMirrorNode { + const alt = image.attrs.alt || ""; + const src = image.attrs.src || ""; + const title = image.attrs.title || ""; + const titlePart = title ? ` "${title}"` : ""; + const markdownText = `![${alt}](${src}${titlePart})`; + + return schema.nodes.paragraph.create( + { imageAttrs: { src, alt, title } }, + schema.text(markdownText) + ); +} + +/** + * 将图片段落节点转换回图片节点 + */ +function transformParagraphToImage( + paragraph: ProseMirrorNode, + schema: Schema +): ProseMirrorNode | null { + const imageAttrs = paragraph.attrs.imageAttrs; + if (!imageAttrs) return null; + + // 优先从段落文本中解析最新的图片属性(用户可能编辑了源码) + const text = paragraph.textContent; + const match = text.match(/^!\[([^\]]*)\]\((.+?)(?:\s+"([^"]*)")?\)$/); + + if (match) { + return schema.nodes.image.create({ + alt: match[1] || "", + src: match[2] || "", + title: match[3] || "", + }); + } + + // 文本不再是有效的图片语法,不转换回图片 + return null; +} + +/** + * 将分割线节点转换为段落节点 + */ +function transformHrToParagraph(_hr: ProseMirrorNode, schema: Schema): ProseMirrorNode { + return schema.nodes.paragraph.create({ hrSource: true }, schema.text("---")); +} + +/** + * 将分割线段落节点转换回分割线节点 + */ +function transformParagraphToHr( + paragraph: ProseMirrorNode, + schema: Schema +): ProseMirrorNode | null { + if (!paragraph.attrs.hrSource) return null; + + // 检查文本是否仍然是有效的分割线语法 + const text = paragraph.textContent.trim(); + if (/^[-*_]{3,}$/.test(text)) { + return schema.nodes.horizontal_rule.create(); + } + + // 文本不再是有效的分割线语法,不转换回分割线 + return null; +} + +/** + * 生成唯一的表格 ID + */ +function generateTableId(): string { + return `tb_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; +} + +/** + * 将表格节点转换为多个段落节点 + */ +function transformTableToParagraphs(table: ProseMirrorNode, schema: Schema): ProseMirrorNode[] { + const tableId = generateTableId(); + const lines: string[] = []; + const alignments: (string | null)[] = []; + + table.content.forEach((row, _, rowIndex) => { + const cells: string[] = []; + row.content.forEach((cell) => { + cells.push(cell.textContent); + if (rowIndex === 0) { + alignments.push(cell.attrs.align || null); + } + }); + + lines.push("| " + cells.join(" | ") + " |"); + + // 在表头行后添加分隔行 + if (rowIndex === 0) { + const separators = alignments.map((align) => { + if (align === "center") return ":---:"; + if (align === "right") return "---:"; + if (align === "left") return ":---"; + return "---"; + }); + lines.push("| " + separators.join(" | ") + " |"); + } + }); + + const paragraphs: ProseMirrorNode[] = []; + lines.forEach((line, index) => { + const paragraph = schema.nodes.paragraph.create( + { + tableId, + tableRowIndex: index, + tableTotalRows: lines.length, + }, + schema.text(line) + ); + paragraphs.push(paragraph); + }); + + return paragraphs; +} + +/** + * 将连续的表格段落节点重新组合成表格 + */ +function transformParagraphsToTable( + paragraphs: Array<{ node: ProseMirrorNode; pos: number }>, + schema: Schema +): ProseMirrorNode | null { + if (paragraphs.length < 2) return null; + + const lines = paragraphs.map((p) => p.node.textContent); + const tableMarkdown = lines.join("\n"); + + // 使用解析器重新解析表格 + const result = parseMarkdown(tableMarkdown); + let tableNode: ProseMirrorNode | null = null; + + result.doc.forEach((node) => { + if (node.type.name === "table" && !tableNode) { + tableNode = node; + } + }); + + return tableNode; +} + +/** + * 将 HTML 块节点转换为多个段落节点 + */ +function transformHtmlBlockToParagraphs( + htmlBlock: ProseMirrorNode, + schema: Schema +): ProseMirrorNode[] { + const htmlBlockId = generateHtmlBlockId(); + let content = htmlBlock.textContent; + content = content.replace(/\n+$/, ""); + const lines = content.split("\n"); + + const paragraphs: ProseMirrorNode[] = []; + lines.forEach((line, index) => { + const textContent = line.length > 0 ? schema.text(line) : undefined; + const paragraph = schema.nodes.paragraph.create( + { + htmlBlockId, + htmlBlockLineIndex: index, + htmlBlockTotalLines: lines.length, + }, + textContent + ); + paragraphs.push(paragraph); + }); + + return paragraphs; +} + +/** + * 将连续的 HTML 块段落节点重新组合成 HTML 块 + */ +function transformParagraphsToHtmlBlock( + paragraphs: Array<{ node: ProseMirrorNode; pos: number }>, + schema: Schema +): ProseMirrorNode | null { + if (paragraphs.length === 0) return null; + + const lines = paragraphs.map((p) => p.node.textContent); + const content = lines.join("\n"); + + // 验证内容是否以 HTML 标签开头 + if (!content.match(/^<[a-zA-Z]/)) return null; + + return schema.nodes.html_block.create({}, content ? schema.text(content) : null); +} + +/** + * 递归处理节点,将块级元素转换为段落(用于进入源码模式) + */ +function processNodeForSourceConversion( + node: ProseMirrorNode, + schema: Schema +): ProseMirrorNode | ProseMirrorNode[] { + if (node.type.name === "code_block") { + return transformCodeBlockToParagraphs(node, schema); + } else if (node.type.name === "image") { + return [transformImageToParagraph(node, schema)]; + } else if (node.type.name === "horizontal_rule") { + return [transformHrToParagraph(node, schema)]; + } else if (node.type.name === "table") { + return transformTableToParagraphs(node, schema); + } else if (node.type.name === "html_block") { + return transformHtmlBlockToParagraphs(node, schema); + } + + // 递归处理子节点 + if (node.content.size > 0) { + const newChildren: ProseMirrorNode[] = []; + let changed = false; + + node.content.forEach((child) => { + const processed = processNodeForSourceConversion(child, schema); + if (Array.isArray(processed)) { + newChildren.push(...processed); + changed = true; + } else if (processed !== child) { + newChildren.push(processed); + changed = true; + } else { + newChildren.push(child); + } + }); + + if (changed) { + return node.type.create(node.attrs, Fragment.from(newChildren), node.marks); + } + } + + return node; +} + +/** + * 将文档中的所有块级元素(代码块、图片、分割线、表格、HTML块)转换为段落 + * 使用整体替换文档内容的方式(单次 ReplaceStep),避免逐个节点操作的 O(N²) 开销 + */ +export function convertBlocksToParagraphs(tr: Transaction): Transaction { + const doc = tr.doc; + const schema = doc.type.schema; + const newContent: ProseMirrorNode[] = []; + let changed = false; + + doc.forEach((node) => { + const processed = processNodeForSourceConversion(node, schema); + if (Array.isArray(processed)) { + newContent.push(...processed); + changed = true; + } else { + newContent.push(processed); + if (processed !== node) changed = true; + } + }); + + if (changed && newContent.length > 0) { + const step = new ReplaceStep(0, doc.content.size, new Slice(Fragment.from(newContent), 0, 0)); + tr.step(step); + } + + return tr; +} + +/** + * 递归处理节点,转换代码块段落 + */ +function processNodeForBlockConversion( + node: ProseMirrorNode, + schema: Schema +): ProseMirrorNode | ProseMirrorNode[] { + // 如果节点有子节点,递归处理 + if (node.content.size > 0) { + const newChildren: ProseMirrorNode[] = []; + let codeBlockGroup: ProseMirrorNode[] = []; + let currentCodeBlockId: string | null = null; + let tableGroup: ProseMirrorNode[] = []; + let currentTableId: string | null = null; + let htmlBlockGroup: ProseMirrorNode[] = []; + let currentHtmlBlockId: string | null = null; + + const flushCodeBlockGroup = () => { + if (codeBlockGroup.length === 0) return; + const paragraphs = codeBlockGroup.map((n) => ({ node: n, pos: 0 })); + const result = transformParagraphsToCodeBlock(paragraphs, schema); + if (result) { + newChildren.push(result.codeBlock); + } else { + newChildren.push(...codeBlockGroup); + } + codeBlockGroup = []; + currentCodeBlockId = null; + }; + + const flushTableGroup = () => { + if (tableGroup.length === 0) return; + const paragraphs = tableGroup.map((n) => ({ node: n, pos: 0 })); + const result = transformParagraphsToTable(paragraphs, schema); + if (result) { + newChildren.push(result); + } else { + newChildren.push(...tableGroup); + } + tableGroup = []; + currentTableId = null; + }; + + const flushHtmlBlockGroup = () => { + if (htmlBlockGroup.length === 0) return; + const paragraphs = htmlBlockGroup.map((n) => ({ node: n, pos: 0 })); + const result = transformParagraphsToHtmlBlock(paragraphs, schema); + if (result) { + newChildren.push(result); + } else { + newChildren.push(...htmlBlockGroup); + } + htmlBlockGroup = []; + currentHtmlBlockId = null; + }; + + node.content.forEach((child) => { + if (child.type.name === "paragraph") { + const codeBlockId = child.attrs.codeBlockId; + const tableId = child.attrs.tableId; + const htmlBlockId = child.attrs.htmlBlockId; + + if (codeBlockId) { + // 代码块段落 + flushTableGroup(); + flushHtmlBlockGroup(); + if (currentCodeBlockId && currentCodeBlockId !== codeBlockId) { + flushCodeBlockGroup(); + } + currentCodeBlockId = codeBlockId; + codeBlockGroup.push(child); + return; + } + + if (tableId) { + // 表格段落 + flushCodeBlockGroup(); + flushHtmlBlockGroup(); + if (currentTableId && currentTableId !== tableId) { + flushTableGroup(); + } + currentTableId = tableId; + tableGroup.push(child); + return; + } + + if (htmlBlockId) { + // HTML 块段落 + flushCodeBlockGroup(); + flushTableGroup(); + if (currentHtmlBlockId && currentHtmlBlockId !== htmlBlockId) { + flushHtmlBlockGroup(); + } + currentHtmlBlockId = htmlBlockId; + htmlBlockGroup.push(child); + return; + } + + // 非特殊段落,先刷新之前的组 + flushCodeBlockGroup(); + flushTableGroup(); + flushHtmlBlockGroup(); + + if (child.attrs.imageAttrs) { + const image = transformParagraphToImage(child, schema); + newChildren.push(image || child); + } else if (child.attrs.hrSource) { + const hr = transformParagraphToHr(child, schema); + newChildren.push(hr || child); + } else { + newChildren.push(child); + } + } else { + flushCodeBlockGroup(); + flushTableGroup(); + flushHtmlBlockGroup(); + // 递归处理子节点 + const processed = processNodeForBlockConversion(child, schema); + if (Array.isArray(processed)) { + newChildren.push(...processed); + } else { + newChildren.push(processed); + } + } + }); + + // 刷新最后一组 + flushCodeBlockGroup(); + flushTableGroup(); + flushHtmlBlockGroup(); + + // 如果内容有变化,创建新节点 + const newContent = Fragment.from(newChildren); + if (!newContent.eq(node.content)) { + return node.type.create(node.attrs, newContent, node.marks); + } + } + + return node; +} + +/** + * 将文档中的特殊段落转换回对应的块级元素(代码块、图片、分割线) + * 使用整体替换文档内容的方式,避免逐个节点操作的位置映射问题 + */ +export function convertParagraphsToBlocks(tr: Transaction): Transaction { + const doc = tr.doc; + const schema = doc.type.schema; + const newContent: ProseMirrorNode[] = []; + + // 收集代码块段落组 + let codeBlockGroup: ProseMirrorNode[] = []; + let currentCodeBlockId: string | null = null; + // 收集表格段落组 + let tableGroup: ProseMirrorNode[] = []; + let currentTableId: string | null = null; + // 收集 HTML 块段落组 + let htmlBlockGroup: ProseMirrorNode[] = []; + let currentHtmlBlockId: string | null = null; + + const flushCodeBlockGroup = () => { + if (codeBlockGroup.length === 0) return; + const paragraphs = codeBlockGroup.map((node) => ({ node, pos: 0 })); + const result = transformParagraphsToCodeBlock(paragraphs, schema); + if (result) { + newContent.push(result.codeBlock); + } else { + // 转换失败,保留原始段落 + newContent.push(...codeBlockGroup); + } + codeBlockGroup = []; + currentCodeBlockId = null; + }; + + const flushTableGroup = () => { + if (tableGroup.length === 0) return; + const paragraphs = tableGroup.map((node) => ({ node, pos: 0 })); + const result = transformParagraphsToTable(paragraphs, schema); + if (result) { + newContent.push(result); + } else { + newContent.push(...tableGroup); + } + tableGroup = []; + currentTableId = null; + }; + + const flushHtmlBlockGroup = () => { + if (htmlBlockGroup.length === 0) return; + const paragraphs = htmlBlockGroup.map((node) => ({ node, pos: 0 })); + const result = transformParagraphsToHtmlBlock(paragraphs, schema); + if (result) { + newContent.push(result); + } else { + newContent.push(...htmlBlockGroup); + } + htmlBlockGroup = []; + currentHtmlBlockId = null; + }; + + doc.forEach((node) => { + if (node.type.name === "paragraph") { + const codeBlockId = node.attrs.codeBlockId; + const tableId = node.attrs.tableId; + const htmlBlockId = node.attrs.htmlBlockId; + + if (codeBlockId) { + // 代码块段落 + flushTableGroup(); + flushHtmlBlockGroup(); + if (currentCodeBlockId && currentCodeBlockId !== codeBlockId) { + flushCodeBlockGroup(); + } + currentCodeBlockId = codeBlockId; + codeBlockGroup.push(node); + return; + } + + if (tableId) { + // 表格段落 + flushCodeBlockGroup(); + flushHtmlBlockGroup(); + if (currentTableId && currentTableId !== tableId) { + flushTableGroup(); + } + currentTableId = tableId; + tableGroup.push(node); + return; + } + + if (htmlBlockId) { + // HTML 块段落 + flushCodeBlockGroup(); + flushTableGroup(); + if (currentHtmlBlockId && currentHtmlBlockId !== htmlBlockId) { + flushHtmlBlockGroup(); + } + currentHtmlBlockId = htmlBlockId; + htmlBlockGroup.push(node); + return; + } + + // 非特殊段落,先刷新之前的组 + flushCodeBlockGroup(); + flushTableGroup(); + flushHtmlBlockGroup(); + + if (node.attrs.imageAttrs) { + // 图片段落 + const image = transformParagraphToImage(node, schema); + newContent.push(image || node); + } else if (node.attrs.hrSource) { + // 分割线段落 + const hr = transformParagraphToHr(node, schema); + newContent.push(hr || node); + } else { + newContent.push(node); + } + } else { + flushCodeBlockGroup(); + flushTableGroup(); + flushHtmlBlockGroup(); + // 递归处理子节点 + const processed = processNodeForBlockConversion(node, schema); + if (Array.isArray(processed)) { + newContent.push(...processed); + } else { + newContent.push(processed); + } + } + }); + + // 刷新最后一组 + flushCodeBlockGroup(); + flushTableGroup(); + flushHtmlBlockGroup(); + + if (newContent.length > 0) { + const step = new ReplaceStep(0, doc.content.size, new Slice(Fragment.from(newContent), 0, 0)); + tr.step(step); + } + + return tr; +} + +/** + * 创建源码模式文档转换插件 + */ +export function createSourceViewTransformPlugin(): Plugin { + return new Plugin({ + key: sourceViewTransformPluginKey, + + appendTransaction(transactions, oldState, newState) { + // 检查是否有源码模式切换 + const oldDecorationState = decorationPluginKey.getState(oldState); + const newDecorationState = decorationPluginKey.getState(newState); + + if (!oldDecorationState || !newDecorationState) return null; + + const oldSourceView = oldDecorationState.sourceView; + const newSourceView = newDecorationState.sourceView; + + // 源码模式状态发生变化 + if (oldSourceView !== newSourceView) { + const tr = newState.tr; + + if (newSourceView) { + // 进入源码模式:将块级元素转换为段落 + convertBlocksToParagraphs(tr); + } else { + // 退出源码模式:将段落转换回块级元素 + convertParagraphsToBlocks(tr); + } + + // 如果有变化,返回 transaction + return tr.docChanged ? tr : null; + } + + // 在源码模式下,检查文档中是否有未转换的块级节点 + // (例如通过 setMarkdown 重新加载内容时产生的) + if (newSourceView) { + let hasBlocks = false; + newState.doc.descendants((node) => { + if ( + node.type.name === "code_block" || + node.type.name === "image" || + node.type.name === "horizontal_rule" || + node.type.name === "table" || + node.type.name === "html_block" + ) { + hasBlocks = true; + } + return !hasBlocks; // 找到一个就停止遍历 + }); + + if (hasBlocks) { + const tr = newState.tr; + convertBlocksToParagraphs(tr); + return tr.docChanged ? tr : null; + } + } + + return null; + }, + }); +} diff --git a/src/core/plugins/syntax-detector.ts b/src/core/plugins/syntax-detector.ts new file mode 100644 index 0000000..ea899c0 --- /dev/null +++ b/src/core/plugins/syntax-detector.ts @@ -0,0 +1,693 @@ +/** + * Milkup 语法检测插件 + * + * 监听文档变化,检测并应用 Markdown 语法的 marks + * 支持嵌套语法检测,如 ==**text**== 或 ***text*** + */ + +import { Plugin, PluginKey, Transaction } from "prosemirror-state"; +import { Node, Mark, Schema } from "prosemirror-model"; +import { decorationPluginKey } from "../decorations"; + +/** 插件 Key */ +export const syntaxDetectorPluginKey = new PluginKey("milkup-syntax-detector"); + +/** 行内语法定义 */ +interface InlineSyntax { + type: string; + pattern: RegExp; + prefix: string | ((match: RegExpExecArray) => string); + suffix: string | ((match: RegExpExecArray) => string); + contentIndex: number; + getAttrs?: (match: RegExpExecArray) => Record; + // 对于 strong_emphasis,需要应用多个 marks + multiMarks?: string[]; +} + +/** 行内语法列表 - 按优先级排序 */ +const INLINE_SYNTAXES: InlineSyntax[] = [ + // 粗斜体 ***text*** 或 ___text___ + { + type: "strong_emphasis", + pattern: /(\*\*\*|___)(.+?)\1/g, + prefix: (m) => m[1], + suffix: (m) => m[1], + contentIndex: 2, + multiMarks: ["strong", "emphasis"], + }, + // 粗体 **text** 或 __text__ + { + type: "strong", + pattern: /(? m[1] || m[3], + suffix: (m) => m[1] || m[3], + contentIndex: 2, + getAttrs: (m) => ({}), + }, + // 斜体 *text* 或 _text_ + // 注意:下划线在单词中间时不应该被视为斜体标记 + { + type: "emphasis", + pattern: + /(? m[1] || m[3], + suffix: (m) => m[1] || m[3], + contentIndex: 2, + }, + // 行内代码 `code` + { + type: "code_inline", + pattern: /`([^`]+)`/g, + prefix: "`", + suffix: "`", + contentIndex: 1, + }, + // 删除线 ~~text~~ + { + type: "strikethrough", + pattern: /~~(.+?)~~/g, + prefix: "~~", + suffix: "~~", + contentIndex: 1, + }, + // 高亮 ==text== + { + type: "highlight", + pattern: /==(.+?)==/g, + prefix: "==", + suffix: "==", + contentIndex: 1, + }, + // 链接 [text](url) + { + type: "link", + pattern: /(? `](${m[2] || ""}${m[3] ? ` "${m[3]}"` : ""})`, + contentIndex: 1, + getAttrs: (m) => ({ href: m[2] || "", title: m[3] || "" }), + }, + // 行内数学 $content$ + { + type: "math_inline", + pattern: /(? ({ content: m[1] }), + }, +]; + +/** 转义正则 */ +const ESCAPE_RE = /\\([\\`*_{}[\]()#+\-.!|~=$>])/g; + +/** 匹配信息 */ +interface MatchInfo { + syntax: InlineSyntax; + match: RegExpExecArray; + start: number; + end: number; + prefix: string; + suffix: string; + content: string; + contentStart: number; + contentEnd: number; + attrs?: Record; +} + +/** + * 检测文本中的所有语法匹配 + */ +function detectSyntaxMatches(text: string): MatchInfo[] { + const matches: MatchInfo[] = []; + + // 收集所有转义范围 + const escapeRanges: Array<{ start: number; end: number }> = []; + const escRe = new RegExp(ESCAPE_RE.source, "g"); + let escMatch: RegExpExecArray | null; + while ((escMatch = escRe.exec(text)) !== null) { + escapeRanges.push({ start: escMatch.index, end: escMatch.index + escMatch[0].length }); + } + + for (const syntax of INLINE_SYNTAXES) { + const re = new RegExp(syntax.pattern.source, syntax.pattern.flags); + let match: RegExpExecArray | null; + + while ((match = re.exec(text)) !== null) { + const prefix = typeof syntax.prefix === "function" ? syntax.prefix(match) : syntax.prefix; + const suffix = typeof syntax.suffix === "function" ? syntax.suffix(match) : syntax.suffix; + const content = match[syntax.contentIndex] || match[syntax.contentIndex + 2] || ""; + + const start = match.index; + const end = start + match[0].length; + const contentStart = start + prefix.length; + const contentEnd = end - suffix.length; + + // 跳过与转义范围重叠的匹配 + const overlapsEscape = escapeRanges.some((esc) => esc.start < end && esc.end > start); + if (overlapsEscape) continue; + + matches.push({ + syntax, + match, + start, + end, + prefix, + suffix, + content, + contentStart, + contentEnd, + attrs: syntax.getAttrs?.(match), + }); + } + } + + // 按位置排序,相同起点时更长的优先 + matches.sort((a, b) => { + if (a.start !== b.start) return a.start - b.start; + return b.end - a.end; + }); + + // 过滤完全重叠的匹配(保留外层) + const filtered: MatchInfo[] = []; + let lastEnd = 0; + for (const m of matches) { + if (m.start >= lastEnd) { + filtered.push(m); + lastEnd = m.end; + } + } + + return filtered; +} + +/** + * 检测文本片段中的转义序列,生成区域标记 + */ +function detectEscapeRegions( + text: string, + baseOffset: number, + inheritedTypes: string[], + inheritedAttrs?: Record +): Array<{ + from: number; + to: number; + markTypes: string[]; + isSyntax: boolean; + isEscape?: boolean; + attrs?: Record; +}> { + const results: Array<{ + from: number; + to: number; + markTypes: string[]; + isSyntax: boolean; + isEscape?: boolean; + attrs?: Record; + }> = []; + + const escRe = new RegExp(ESCAPE_RE.source, "g"); + let escMatch: RegExpExecArray | null; + let pos = 0; + let hasAnyEscape = false; + + while ((escMatch = escRe.exec(text)) !== null) { + hasAnyEscape = true; + + // 转义之前的普通文本 + if (escMatch.index > pos) { + results.push({ + from: baseOffset + pos, + to: baseOffset + escMatch.index, + markTypes: inheritedTypes, + isSyntax: false, + attrs: inheritedAttrs, + }); + } + + // `\` 字符 → escape 类型的 syntax_marker + results.push({ + from: baseOffset + escMatch.index, + to: baseOffset + escMatch.index + 1, + markTypes: inheritedTypes, + isSyntax: true, + isEscape: true, + attrs: inheritedAttrs, + }); + + // 被转义的字符 → 普通文本(只带 inheritedTypes) + results.push({ + from: baseOffset + escMatch.index + 1, + to: baseOffset + escMatch.index + 2, + markTypes: inheritedTypes, + isSyntax: false, + attrs: inheritedAttrs, + }); + + pos = escMatch.index + 2; + } + + // 没有转义序列,返回空数组 + if (!hasAnyEscape) return results; + + // 剩余文本 + if (pos < text.length) { + results.push({ + from: baseOffset + pos, + to: baseOffset + text.length, + markTypes: inheritedTypes, + isSyntax: false, + attrs: inheritedAttrs, + }); + } + + return results; +} + +/** + * 递归检测嵌套语法 + */ +function detectNestedSyntax( + text: string, + baseOffset: number, + inheritedTypes: string[], + inheritedAttrs?: Record +): Array<{ + from: number; + to: number; + markTypes: string[]; + isSyntax: boolean; + isEscape?: boolean; + attrs?: Record; +}> { + const results: Array<{ + from: number; + to: number; + markTypes: string[]; + isSyntax: boolean; + isEscape?: boolean; + attrs?: Record; + }> = []; + + const matches = detectSyntaxMatches(text); + + // 检查文本中是否有转义序列 + const hasEscapes = ESCAPE_RE.test(text); + // 重置 lastIndex + ESCAPE_RE.lastIndex = 0; + + if (matches.length === 0) { + // 没有语法匹配,检查是否有转义序列 + if (text.length > 0 && hasEscapes) { + const escRegions = detectEscapeRegions(text, baseOffset, inheritedTypes, inheritedAttrs); + if (escRegions.length > 0) { + results.push(...escRegions); + return results; + } + } + if (text.length > 0 && inheritedTypes.length > 0) { + results.push({ + from: baseOffset, + to: baseOffset + text.length, + markTypes: inheritedTypes, + isSyntax: false, + attrs: inheritedAttrs, + }); + } + return results; + } + + let pos = 0; + for (const m of matches) { + // 前面的纯文本(可能包含转义) + if (m.start > pos) { + const plainText = text.slice(pos, m.start); + const escRegions = detectEscapeRegions( + plainText, + baseOffset + pos, + inheritedTypes, + inheritedAttrs + ); + if (escRegions.length > 0) { + results.push(...escRegions); + } else if (plainText.length > 0 && inheritedTypes.length > 0) { + results.push({ + from: baseOffset + pos, + to: baseOffset + m.start, + markTypes: inheritedTypes, + isSyntax: false, + attrs: inheritedAttrs, + }); + } + } + + // 当前语法的 mark 类型 + const currentTypes = m.syntax.multiMarks || [m.syntax.type]; + const allTypes = [...inheritedTypes, ...currentTypes]; + + // 合并 attrs:继承的 attrs + 当前语法的 attrs + const mergedAttrs = m.attrs + ? inheritedAttrs + ? { ...inheritedAttrs, ...m.attrs } + : m.attrs + : inheritedAttrs; + + // 前缀(语法标记) + results.push({ + from: baseOffset + m.start, + to: baseOffset + m.contentStart, + markTypes: allTypes, + isSyntax: true, + attrs: mergedAttrs, + }); + + // 递归处理内容(传递合并后的 attrs) + const innerResults = detectNestedSyntax( + m.content, + baseOffset + m.contentStart, + allTypes, + mergedAttrs + ); + if (innerResults.length > 0) { + results.push(...innerResults); + } else if (m.content.length > 0) { + // 没有嵌套语法,直接添加内容 + results.push({ + from: baseOffset + m.contentStart, + to: baseOffset + m.contentEnd, + markTypes: allTypes, + isSyntax: false, + attrs: mergedAttrs, + }); + } + + // 后缀(语法标记) + results.push({ + from: baseOffset + m.contentEnd, + to: baseOffset + m.end, + markTypes: allTypes, + isSyntax: true, + attrs: mergedAttrs, + }); + + pos = m.end; + } + + // 剩余文本(可能包含转义) + if (pos < text.length) { + const remainingText = text.slice(pos); + const escRegions = detectEscapeRegions( + remainingText, + baseOffset + pos, + inheritedTypes, + inheritedAttrs + ); + if (escRegions.length > 0) { + results.push(...escRegions); + } else if (remainingText.length > 0 && inheritedTypes.length > 0) { + results.push({ + from: baseOffset + pos, + to: baseOffset + text.length, + markTypes: inheritedTypes, + isSyntax: false, + attrs: inheritedAttrs, + }); + } + } + + return results; +} + +/** + * 检查节点是否已经有正确的 marks + * 改进版:更精确地比较当前 marks 和期望的 marks + */ +function hasCorrectMarks( + node: Node, + basePos: number, + regions: ReturnType +): boolean { + if (regions.length === 0) { + // 如果没有期望的区域,检查是否有任何语义 marks + let hasAnySemanticMarks = false; + node.forEach((child) => { + if (child.isText) { + const semanticMarks = child.marks.filter( + (m) => + m.type.name !== "syntax_marker" && + [ + "strong", + "emphasis", + "code_inline", + "strikethrough", + "highlight", + "link", + "math_inline", + ].includes(m.type.name) + ); + if (semanticMarks.length > 0) { + hasAnySemanticMarks = true; + } + } + }); + return !hasAnySemanticMarks; + } + + // 构建期望的 marks 映射:position -> expected mark types + const expectedMarks = new Map>(); + // 构建期望的 attrs 映射:position -> { markType -> attrs } + const expectedAttrs = new Map>>(); + for (const region of regions) { + for (let pos = region.from; pos < region.to; pos++) { + if (!expectedMarks.has(pos)) { + expectedMarks.set(pos, new Set()); + expectedAttrs.set(pos, new Map()); + } + for (const markType of region.markTypes) { + if (markType !== "strong_emphasis") { + expectedMarks.get(pos)!.add(markType); + // 记录带 attrs 的 mark(如 link 的 href) + if (region.attrs && (markType === "link" || markType === "math_inline")) { + expectedAttrs.get(pos)!.set(markType, region.attrs); + } + } + } + // 添加 syntax_marker + if (region.isSyntax) { + expectedMarks.get(pos)!.add("syntax_marker"); + } + } + } + + // 检查实际的 marks 是否与期望一致 + let offset = 0; + let allMatch = true; + + node.forEach((child) => { + if (child.isText && allMatch) { + const childStart = basePos + offset; + const childEnd = childStart + child.nodeSize; + + for (let pos = childStart; pos < childEnd; pos++) { + const expected = expectedMarks.get(pos) || new Set(); + const actual = new Set( + child.marks + .filter((m) => + [ + "strong", + "emphasis", + "code_inline", + "strikethrough", + "highlight", + "link", + "math_inline", + "syntax_marker", + ].includes(m.type.name) + ) + .map((m) => m.type.name) + ); + + // 比较期望和实际的 marks + if (expected.size !== actual.size) { + allMatch = false; + break; + } + + for (const markType of expected) { + if (!actual.has(markType)) { + allMatch = false; + break; + } + } + + // 检查带 attrs 的 mark(如 link 的 href)是否一致 + if (allMatch) { + const posAttrs = expectedAttrs.get(pos); + if (posAttrs && posAttrs.size > 0) { + for (const [markTypeName, expectedAttr] of posAttrs) { + const actualMark = child.marks.find((m) => m.type.name === markTypeName); + if (actualMark) { + for (const [key, val] of Object.entries(expectedAttr)) { + if (actualMark.attrs[key] !== val) { + allMatch = false; + break; + } + } + } + if (!allMatch) break; + } + } + } + + if (!allMatch) break; + } + } + offset += child.nodeSize; + }); + + return allMatch; +} + +/** + * 创建语法检测插件 + */ +export function createSyntaxDetectorPlugin(): Plugin { + return new Plugin({ + key: syntaxDetectorPluginKey, + + appendTransaction(transactions, oldState, newState) { + // 只在文档变化时处理 + const docChanged = transactions.some((tr) => tr.docChanged); + if (!docChanged) return null; + + // 跳过语法插件自身产生的 transaction,避免循环 + if (transactions.some((tr) => tr.getMeta("syntax-plugin-internal"))) return null; + + const schema = newState.schema; + let tr = newState.tr; + tr = tr.setMeta("syntax-plugin-internal", true); + let hasChanges = false; + + // 遍历所有文本块(跳过代码块/数学块等,其内容不参与语法解析) + newState.doc.descendants((node, pos) => { + if (node.isTextblock && !node.type.spec.code) { + // 跳过源码视图中由代码块拆分出的段落,不做任何语法检测 + if (node.attrs.codeBlockId) return true; + + const textContent = node.textContent; + const basePos = pos + 1; + + // 检测所有语法区域 + const regions = detectNestedSyntax(textContent, basePos, []); + + if (regions.length === 0) return true; + + // 检查是否需要更新 + if (hasCorrectMarks(node, basePos, regions)) return true; + + // 应用 marks + for (const region of regions) { + // 移除该区域的所有语义 marks 和 syntax_marker(重新应用) + const markTypesToRemove = [ + "strong", + "emphasis", + "code_inline", + "strikethrough", + "highlight", + "link", + "math_inline", + "syntax_marker", // 也移除 syntax_marker,避免旧标记残留 + ]; + for (const markTypeName of markTypesToRemove) { + const markType = schema.marks[markTypeName]; + if (markType) { + tr = tr.removeMark(region.from, region.to, markType); + } + } + + // 添加新的 marks + for (const markTypeName of region.markTypes) { + if (markTypeName === "strong_emphasis") continue; // 跳过复合类型 + + const markType = schema.marks[markTypeName]; + if (markType) { + const mark = markType.create(region.attrs); + tr = tr.addMark(region.from, region.to, mark); + } + } + + // 添加 syntax_marker + if (region.isSyntax) { + const syntaxMarkerType = schema.marks.syntax_marker; + if (syntaxMarkerType) { + // escape 类型使用 "escape" 作为 syntaxType + const syntaxType = (region as any).isEscape + ? "escape" + : region.markTypes[region.markTypes.length - 1] || "unknown"; + const syntaxMark = syntaxMarkerType.create({ syntaxType }); + tr = tr.addMark(region.from, region.to, syntaxMark); + } + } + + hasChanges = true; + } + } + return true; + }); + + // 检测图片语法并转换为图片节点(源码模式下跳过,避免与 source-view-transform 插件循环) + const decoState = decorationPluginKey.getState(newState); + const isSourceView = decoState?.sourceView ?? false; + if (!isSourceView) { + const imagePattern = /!\[([^\]]*)\]\((.+?)(?:\s+"([^"]*)")?\)/g; + const imagesToReplace: Array<{ + from: number; + to: number; + alt: string; + src: string; + title: string; + }> = []; + + newState.doc.descendants((node, pos) => { + if (node.isTextblock && !node.type.spec.code) { + const textContent = node.textContent; + const basePos = pos + 1; + + let match; + while ((match = imagePattern.exec(textContent)) !== null) { + const from = basePos + match.index; + const to = from + match[0].length; + const alt = match[1] || ""; + const src = match[2] || ""; + const title = match[3] || ""; + + // 检查这个位置是否已经是图片节点 + const $from = tr.doc.resolve(from); + if ($from.parent.type.name !== "image") { + imagesToReplace.push({ from, to, alt, src, title }); + } + } + } + return true; + }); + + // 从后往前替换,避免位置偏移 + const imageNodeType = schema.nodes.image; + if (imageNodeType && imagesToReplace.length > 0) { + imagesToReplace.sort((a, b) => b.from - a.from); + for (const img of imagesToReplace) { + const imageNode = imageNodeType.create({ + src: img.src, + alt: img.alt, + title: img.title, + }); + tr = tr.replaceWith(img.from, img.to, imageNode); + hasChanges = true; + } + } + } // end if (!isSourceView) + + return hasChanges ? tr : null; + }, + }); +} diff --git a/src/core/plugins/syntax-fixer.ts b/src/core/plugins/syntax-fixer.ts new file mode 100644 index 0000000..8cc24dc --- /dev/null +++ b/src/core/plugins/syntax-fixer.ts @@ -0,0 +1,271 @@ +/** + * Milkup 语法修复插件 + * + * 监听文档变化,检测并修复不完整的语法结构 + * 例如:删除 **a** 的后两个 ** 后,应该移除 strong mark + */ + +import { Plugin, PluginKey, Transaction } from "prosemirror-state"; +import { Node, Mark } from "prosemirror-model"; + +/** 语法定义 */ +interface SyntaxDef { + markType: string; + markers: string[]; // 可能的语法标记,如 ['**', '__'] 对应 strong +} + +/** 支持的语法列表 */ +const SYNTAX_DEFS: SyntaxDef[] = [ + { markType: "strong", markers: ["**", "__"] }, + { markType: "emphasis", markers: ["*", "_"] }, + { markType: "code_inline", markers: ["`"] }, + { markType: "strikethrough", markers: ["~~"] }, + { markType: "highlight", markers: ["=="] }, + { markType: "math_inline", markers: ["$"] }, + // 链接需要特殊处理,因为前后缀不同 +]; + +/** 插件 Key */ +export const syntaxFixerPluginKey = new PluginKey("milkup-syntax-fixer"); + +/** 语法标记信息 */ +interface SyntaxMarkerInfo { + from: number; + to: number; + text: string; + syntaxType: string; + semanticMark: string | null; +} + +/** + * 收集文本块中的所有语法标记 + */ +function collectSyntaxMarkers(node: Node, basePos: number): SyntaxMarkerInfo[] { + const markers: SyntaxMarkerInfo[] = []; + + let offset = 0; + node.forEach((child) => { + if (child.isText) { + const syntaxMark = child.marks.find((m) => m.type.name === "syntax_marker"); + if (syntaxMark) { + // 跳过 escape 类型的 syntax_marker + if (syntaxMark.attrs.syntaxType === "escape") { + offset += child.nodeSize; + return; + } + + // 找到对应的语义 mark + const semanticMark = child.marks.find( + (m) => + m.type.name !== "syntax_marker" && SYNTAX_DEFS.some((s) => s.markType === m.type.name) + ); + + markers.push({ + from: basePos + offset, + to: basePos + offset + child.nodeSize, + text: child.text || "", + syntaxType: syntaxMark.attrs.syntaxType, + semanticMark: semanticMark?.type.name || null, + }); + } + } + offset += child.nodeSize; + }); + + return markers; +} + +/** + * 检查语法标记是否成对 + * 返回需要移除 marks 的范围 + */ +function findUnpairedMarkers( + node: Node, + basePos: number +): Array<{ from: number; to: number; markType: string }> { + const markers = collectSyntaxMarkers(node, basePos); + const invalidRanges: Array<{ from: number; to: number; markType: string }> = []; + + // 按语法类型分组 + const markersByType: Map = new Map(); + for (const marker of markers) { + const key = marker.syntaxType; + if (!markersByType.has(key)) { + markersByType.set(key, []); + } + markersByType.get(key)!.push(marker); + } + + // 检查每种语法类型的标记是否成对 + for (const [syntaxType, typeMarkers] of markersByType) { + const syntaxDef = SYNTAX_DEFS.find((s) => s.markType === syntaxType); + if (!syntaxDef) continue; + + // 按位置排序 + typeMarkers.sort((a, b) => a.from - b.from); + + // 检查是否成对(相同的标记文本) + const stack: SyntaxMarkerInfo[] = []; + const paired: Set = new Set(); + + for (const marker of typeMarkers) { + // 检查是否可以与栈顶配对 + if (stack.length > 0) { + const top = stack[stack.length - 1]; + if (top.text === marker.text && syntaxDef.markers.includes(marker.text)) { + // 配对成功 + paired.add(top); + paired.add(marker); + stack.pop(); + continue; + } + } + // 入栈 + stack.push(marker); + } + + // 未配对的标记需要移除 marks + for (const marker of typeMarkers) { + if (!paired.has(marker)) { + // 找到这个标记所在的整个语义区域 + const region = findSemanticRegion(node, basePos, marker, syntaxType); + if (region) { + invalidRanges.push(region); + } + } + } + } + + return invalidRanges; +} + +/** + * 找到语法标记所在的整个语义区域 + */ +function findSemanticRegion( + node: Node, + basePos: number, + marker: SyntaxMarkerInfo, + markType: string +): { from: number; to: number; markType: string } | null { + let regionStart = -1; + let regionEnd = -1; + let offset = 0; + + node.forEach((child) => { + if (child.isText) { + const hasMark = child.marks.some((m) => m.type.name === markType); + if (hasMark) { + const childStart = basePos + offset; + const childEnd = basePos + offset + child.nodeSize; + + // 检查是否与 marker 相邻或重叠 + if (childEnd >= marker.from && childStart <= marker.to) { + if (regionStart === -1) regionStart = childStart; + regionEnd = childEnd; + } else if (regionStart !== -1 && childStart === regionEnd) { + // 扩展区域 + regionEnd = childEnd; + } + } + } + offset += child.nodeSize; + }); + + // 重新扫描,找到完整的连续区域 + offset = 0; + regionStart = -1; + regionEnd = -1; + let foundMarker = false; + + node.forEach((child) => { + if (child.isText) { + const hasMark = child.marks.some((m) => m.type.name === markType); + const childStart = basePos + offset; + const childEnd = basePos + offset + child.nodeSize; + + if (hasMark) { + if (regionStart === -1) { + regionStart = childStart; + } + regionEnd = childEnd; + + // 检查是否包含目标 marker + if (childStart <= marker.from && childEnd >= marker.to) { + foundMarker = true; + } + } else if (regionStart !== -1 && foundMarker) { + // 区域结束,且已找到 marker + return false; + } else if (regionStart !== -1) { + // 区域结束,但未找到 marker,重置 + regionStart = -1; + regionEnd = -1; + } + } + offset += child.nodeSize; + }); + + if (regionStart !== -1 && regionEnd !== -1 && foundMarker) { + return { from: regionStart, to: regionEnd, markType }; + } + + return null; +} + +/** + * 创建语法修复插件 + */ +export function createSyntaxFixerPlugin(): Plugin { + return new Plugin({ + key: syntaxFixerPluginKey, + + appendTransaction(transactions, oldState, newState) { + // 只在文档变化时处理 + const docChanged = transactions.some((tr) => tr.docChanged); + if (!docChanged) return null; + + // 跳过语法插件自身产生的 transaction,避免循环 + if (transactions.some((tr) => tr.getMeta("syntax-plugin-internal"))) return null; + + const invalidRanges: Array<{ from: number; to: number; markType: string }> = []; + + // 遍历所有文本块 + newState.doc.descendants((node, pos) => { + if (node.isTextblock) { + const ranges = findUnpairedMarkers(node, pos + 1); + invalidRanges.push(...ranges); + } + return true; + }); + + if (invalidRanges.length === 0) return null; + + // 去重 + const uniqueRanges = invalidRanges.filter( + (range, index, self) => + index === + self.findIndex( + (r) => r.from === range.from && r.to === range.to && r.markType === range.markType + ) + ); + + // 创建事务移除无效的 marks + let tr = newState.tr; + tr = tr.setMeta("syntax-plugin-internal", true); + for (const range of uniqueRanges) { + const markType = newState.schema.marks[range.markType]; + if (markType) { + tr = tr.removeMark(range.from, range.to, markType); + } + // 同时移除 syntax_marker + const syntaxMarkerType = newState.schema.marks.syntax_marker; + if (syntaxMarkerType) { + tr = tr.removeMark(range.from, range.to, syntaxMarkerType); + } + } + + return tr.docChanged ? tr : null; + }, + }); +} diff --git a/src/core/schema/index.ts b/src/core/schema/index.ts new file mode 100644 index 0000000..095a3c2 --- /dev/null +++ b/src/core/schema/index.ts @@ -0,0 +1,597 @@ +/** + * Milkup Schema 定义 + * + * 核心设计思路: + * 1. 每个节点/标记都存储其 Markdown 源码标记信息 + * 2. 通过 Decoration 系统控制源码标记的显示/隐藏 + * 3. 光标实际在源码中移动,但显示为渲染后的格式 + */ + +import { Schema, NodeSpec, MarkSpec, DOMOutputSpec } from "prosemirror-model"; + +// ============ 节点定义 ============ + +const doc: NodeSpec = { + content: "block+", +}; + +const paragraph: NodeSpec = { + attrs: { + // 代码块相关属性(仅在源码模式下使用) + codeBlockId: { default: null }, + lineIndex: { default: null }, + totalLines: { default: null }, + language: { default: null }, + // 图片相关属性(仅在源码模式下使用) + imageAttrs: { default: null }, + // 分割线相关属性(仅在源码模式下使用) + hrSource: { default: null }, + // 表格相关属性(仅在源码模式下使用) + tableId: { default: null }, + tableRowIndex: { default: null }, + tableTotalRows: { default: null }, + // HTML 块相关属性(仅在源码模式下使用) + htmlBlockId: { default: null }, + htmlBlockLineIndex: { default: null }, + htmlBlockTotalLines: { default: null }, + }, + content: "inline*", + group: "block", + parseDOM: [{ tag: "p" }], + toDOM(node): DOMOutputSpec { + const attrs: Record = {}; + // 如果是代码块段落,添加数据属性 + if (node.attrs.codeBlockId) { + attrs["data-code-block-id"] = node.attrs.codeBlockId; + attrs["data-line-index"] = node.attrs.lineIndex; + attrs["data-total-lines"] = node.attrs.totalLines; + attrs["data-language"] = node.attrs.language; + } + // 如果是图片段落,添加数据属性 + if (node.attrs.imageAttrs) { + attrs["data-image-source"] = "true"; + } + // 如果是分割线段落,添加数据属性 + if (node.attrs.hrSource) { + attrs["data-hr-source"] = "true"; + } + // 如果是表格段落,添加数据属性 + if (node.attrs.tableId) { + attrs["data-table-id"] = node.attrs.tableId; + attrs["data-table-row-index"] = node.attrs.tableRowIndex; + attrs["data-table-total-rows"] = node.attrs.tableTotalRows; + } + // 如果是 HTML 块段落,添加数据属性 + if (node.attrs.htmlBlockId) { + attrs["data-html-block-id"] = node.attrs.htmlBlockId; + attrs["data-html-block-line-index"] = node.attrs.htmlBlockLineIndex; + attrs["data-html-block-total-lines"] = node.attrs.htmlBlockTotalLines; + } + return ["p", attrs, 0]; + }, +}; + +const heading: NodeSpec = { + attrs: { + level: { default: 1 }, + }, + content: "inline*", + group: "block", + defining: true, + parseDOM: [ + { tag: "h1", attrs: { level: 1 } }, + { tag: "h2", attrs: { level: 2 } }, + { tag: "h3", attrs: { level: 3 } }, + { tag: "h4", attrs: { level: 4 } }, + { tag: "h5", attrs: { level: 5 } }, + { tag: "h6", attrs: { level: 6 } }, + ], + toDOM(node): DOMOutputSpec { + return [`h${node.attrs.level}`, 0]; + }, +}; + +const blockquote: NodeSpec = { + content: "block+", + group: "block", + defining: true, + parseDOM: [{ tag: "blockquote" }], + toDOM(): DOMOutputSpec { + return ["blockquote", 0]; + }, +}; + +const code_block: NodeSpec = { + attrs: { + language: { default: "" }, + }, + content: "text*", + marks: "", + group: "block", + code: true, + defining: true, + parseDOM: [ + { + tag: "pre", + preserveWhitespace: "full" as const, + getAttrs(node) { + const el = node as HTMLElement; + const code = el.querySelector("code"); + const className = code?.className || ""; + const match = className.match(/language-(\w+)/); + return { language: match ? match[1] : "" }; + }, + }, + ], + toDOM(node): DOMOutputSpec { + return [ + "pre", + ["code", { class: node.attrs.language ? `language-${node.attrs.language}` : "" }, 0], + ]; + }, +}; + +const horizontal_rule: NodeSpec = { + group: "block", + parseDOM: [{ tag: "hr" }], + toDOM(): DOMOutputSpec { + return ["hr"]; + }, +}; + +const bullet_list: NodeSpec = { + content: "list_item+", + group: "block", + parseDOM: [{ tag: "ul" }], + toDOM(): DOMOutputSpec { + return ["ul", 0]; + }, +}; + +const ordered_list: NodeSpec = { + attrs: { + start: { default: 1 }, + }, + content: "list_item+", + group: "block", + parseDOM: [ + { + tag: "ol", + getAttrs(node) { + const el = node as HTMLElement; + return { start: el.hasAttribute("start") ? Number(el.getAttribute("start")) : 1 }; + }, + }, + ], + toDOM(node): DOMOutputSpec { + return node.attrs.start === 1 ? ["ol", 0] : ["ol", { start: node.attrs.start }, 0]; + }, +}; + +const list_item: NodeSpec = { + content: "block+", + parseDOM: [{ tag: "li" }], + toDOM(): DOMOutputSpec { + return ["li", 0]; + }, + defining: true, +}; + +const task_list: NodeSpec = { + content: "task_item+", + group: "block", + parseDOM: [{ tag: "ul.task-list" }], + toDOM(): DOMOutputSpec { + return ["ul", { class: "task-list" }, 0]; + }, +}; + +const task_item: NodeSpec = { + attrs: { + checked: { default: false }, + }, + content: "block+", + parseDOM: [ + { + tag: "li.task-item", + getAttrs(node) { + const el = node as HTMLElement; + const checkbox = el.querySelector('input[type="checkbox"]'); + return { checked: checkbox ? (checkbox as HTMLInputElement).checked : false }; + }, + }, + ], + toDOM(node): DOMOutputSpec { + return [ + "li", + { class: "task-item" }, + ["input", { type: "checkbox", checked: node.attrs.checked ? "checked" : null }], + ["span", 0], + ]; + }, + defining: true, +}; + +const table: NodeSpec = { + content: "table_row+", + group: "block", + tableRole: "table", + isolating: true, + parseDOM: [{ tag: "table" }], + toDOM(): DOMOutputSpec { + return ["table", ["tbody", 0]]; + }, +}; + +const table_row: NodeSpec = { + content: "(table_cell | table_header)+", + tableRole: "row", + parseDOM: [{ tag: "tr" }], + toDOM(): DOMOutputSpec { + return ["tr", 0]; + }, +}; + +const table_cell: NodeSpec = { + content: "inline*", + attrs: { + colspan: { default: 1 }, + rowspan: { default: 1 }, + align: { default: null }, + }, + tableRole: "cell", + isolating: true, + parseDOM: [ + { + tag: "td", + getAttrs(node) { + const el = node as HTMLElement; + return { + colspan: Number(el.getAttribute("colspan")) || 1, + rowspan: Number(el.getAttribute("rowspan")) || 1, + align: el.style.textAlign || null, + }; + }, + }, + ], + toDOM(node): DOMOutputSpec { + const attrs: Record = {}; + if (node.attrs.colspan !== 1) attrs.colspan = node.attrs.colspan; + if (node.attrs.rowspan !== 1) attrs.rowspan = node.attrs.rowspan; + if (node.attrs.align) attrs.style = `text-align: ${node.attrs.align}`; + return ["td", attrs, 0]; + }, +}; + +const table_header: NodeSpec = { + content: "inline*", + attrs: { + colspan: { default: 1 }, + rowspan: { default: 1 }, + align: { default: null }, + }, + tableRole: "header_cell", + isolating: true, + parseDOM: [ + { + tag: "th", + getAttrs(node) { + const el = node as HTMLElement; + return { + colspan: Number(el.getAttribute("colspan")) || 1, + rowspan: Number(el.getAttribute("rowspan")) || 1, + align: el.style.textAlign || null, + }; + }, + }, + ], + toDOM(node): DOMOutputSpec { + const attrs: Record = {}; + if (node.attrs.colspan !== 1) attrs.colspan = node.attrs.colspan; + if (node.attrs.rowspan !== 1) attrs.rowspan = node.attrs.rowspan; + if (node.attrs.align) attrs.style = `text-align: ${node.attrs.align}`; + return ["th", attrs, 0]; + }, +}; + +const math_block: NodeSpec = { + attrs: { + language: { default: "latex" }, + }, + content: "text*", + marks: "", + group: "block", + code: true, + defining: true, + parseDOM: [ + { + tag: "div.math-block", + preserveWhitespace: "full" as const, + }, + ], + toDOM(): DOMOutputSpec { + return ["div", { class: "math-block" }, ["pre", 0]]; + }, +}; + +const html_block: NodeSpec = { + content: "text*", + marks: "", + group: "block", + code: true, + defining: true, + parseDOM: [{ tag: "div.html-block", preserveWhitespace: "full" as const }], + toDOM(): DOMOutputSpec { + return ["div", { class: "html-block" }, ["pre", 0]]; + }, +}; + +const container: NodeSpec = { + attrs: { + type: { default: "note" }, + title: { default: "" }, + }, + content: "block+", + group: "block", + defining: true, + parseDOM: [ + { + tag: "div.container", + getAttrs(node) { + const el = node as HTMLElement; + return { + type: el.getAttribute("data-type") || "note", + title: el.getAttribute("data-title") || "", + }; + }, + }, + ], + toDOM(node): DOMOutputSpec { + return [ + "div", + { + class: `container container-${node.attrs.type}`, + "data-type": node.attrs.type, + "data-title": node.attrs.title, + }, + 0, + ]; + }, +}; + +const image: NodeSpec = { + attrs: { + src: { default: "" }, + alt: { default: "" }, + title: { default: "" }, + }, + group: "block", + draggable: true, + parseDOM: [ + { + tag: "img[src]", + getAttrs(node) { + const el = node as HTMLElement; + return { + src: el.getAttribute("src") || "", + alt: el.getAttribute("alt") || "", + title: el.getAttribute("title") || "", + }; + }, + }, + ], + toDOM(node): DOMOutputSpec { + return ["img", { src: node.attrs.src, alt: node.attrs.alt, title: node.attrs.title }]; + }, +}; + +const text: NodeSpec = { + group: "inline", +}; + +const hard_break: NodeSpec = { + inline: true, + group: "inline", + selectable: false, + parseDOM: [{ tag: "br" }], + toDOM(): DOMOutputSpec { + return ["br"]; + }, +}; + +// ============ 标记定义 ============ + +const strong: MarkSpec = { + inclusive: false, // 新输入的文本不自动继承此 mark + parseDOM: [ + { tag: "strong" }, + { tag: "b", getAttrs: (node) => (node as HTMLElement).style.fontWeight !== "normal" && null }, + { + style: "font-weight", + getAttrs: (value) => /^(bold(er)?|[5-9]\d{2,})$/.test(value as string) && null, + }, + ], + toDOM(): DOMOutputSpec { + return ["strong", 0]; + }, +}; + +const emphasis: MarkSpec = { + inclusive: false, // 新输入的文本不自动继承此 mark + parseDOM: [ + { tag: "em" }, + { tag: "i", getAttrs: (node) => (node as HTMLElement).style.fontStyle !== "normal" && null }, + { style: "font-style=italic" }, + ], + toDOM(): DOMOutputSpec { + return ["em", 0]; + }, +}; + +const code_inline: MarkSpec = { + inclusive: false, // 新输入的文本不自动继承此 mark + parseDOM: [{ tag: "code" }], + toDOM(): DOMOutputSpec { + return ["code", 0]; + }, +}; + +const strikethrough: MarkSpec = { + inclusive: false, // 新输入的文本不自动继承此 mark + parseDOM: [ + { tag: "s" }, + { tag: "del" }, + { tag: "strike" }, + { + style: "text-decoration", + getAttrs: (value) => (value as string).includes("line-through") && null, + }, + ], + toDOM(): DOMOutputSpec { + return ["del", 0]; + }, +}; + +const link: MarkSpec = { + attrs: { + href: { default: "" }, + title: { default: "" }, + }, + inclusive: false, + parseDOM: [ + { + tag: "a[href]", + getAttrs(node) { + const el = node as HTMLElement; + return { + href: el.getAttribute("href") || "", + title: el.getAttribute("title") || "", + }; + }, + }, + ], + toDOM(mark): DOMOutputSpec { + return ["a", { href: mark.attrs.href, title: mark.attrs.title || null }, 0]; + }, +}; + +const highlight: MarkSpec = { + inclusive: false, // 新输入的文本不自动继承此 mark + parseDOM: [ + { tag: "mark" }, + { style: "background-color", getAttrs: (value) => (value as string) !== "transparent" && null }, + ], + toDOM(): DOMOutputSpec { + return ["mark", 0]; + }, +}; + +const math_inline: MarkSpec = { + attrs: { + content: { default: "" }, + }, + parseDOM: [ + { + tag: "span.math-inline", + getAttrs(node) { + const el = node as HTMLElement; + return { content: el.getAttribute("data-content") || "" }; + }, + }, + ], + toDOM(mark): DOMOutputSpec { + return ["span", { class: "math-inline", "data-content": mark.attrs.content }, 0]; + }, +}; + +const footnote_ref: MarkSpec = { + attrs: { + id: { default: "" }, + }, + parseDOM: [ + { + tag: "sup.footnote-ref", + getAttrs(node) { + const el = node as HTMLElement; + return { id: el.getAttribute("data-id") || "" }; + }, + }, + ], + toDOM(mark): DOMOutputSpec { + return ["sup", { class: "footnote-ref", "data-id": mark.attrs.id }, 0]; + }, +}; + +/** + * 语法标记 Mark + * 用于标记 Markdown 语法符号(如 **, *, ~~, ` 等) + * 这些文本是真实存在于文档中的,可以被光标选中 + */ +const syntax_marker: MarkSpec = { + attrs: { + syntaxType: { default: "" }, // 语法类型:strong, emphasis, code_inline 等 + }, + excludes: "", // 可以与其他 mark 共存 + inclusive: false, // 新输入的文本不继承此 mark,防止无法逃出语法区域 + parseDOM: [ + { + tag: "span.milkup-syntax", + getAttrs(node) { + const el = node as HTMLElement; + return { syntaxType: el.getAttribute("data-syntax-type") || "" }; + }, + }, + ], + toDOM(mark): DOMOutputSpec { + return [ + "span", + { + class: "milkup-syntax", + "data-syntax-type": mark.attrs.syntaxType, + }, + 0, + ]; + }, +}; + +// ============ Schema 导出 ============ + +export const milkupSchema = new Schema({ + nodes: { + doc, + paragraph, + heading, + blockquote, + code_block, + horizontal_rule, + bullet_list, + ordered_list, + list_item, + task_list, + task_item, + table, + table_row, + table_cell, + table_header, + math_block, + html_block, + container, + image, + text, + hard_break, + }, + marks: { + // 语法标记 mark 放在最前面,优先级最高 + syntax_marker, + strong, + emphasis, + code_inline, + strikethrough, + link, + highlight, + math_inline, + footnote_ref, + }, +}); + +export type MilkupSchema = typeof milkupSchema; diff --git a/src/core/serializer/index.ts b/src/core/serializer/index.ts new file mode 100644 index 0000000..edd4600 --- /dev/null +++ b/src/core/serializer/index.ts @@ -0,0 +1,404 @@ +/** + * Milkup Markdown 序列化器 + * + * 将 ProseMirror 文档序列化为 Markdown 文本 + */ + +import { Node, Mark, Fragment } from "prosemirror-model"; + +/** 序列化选项 */ +export interface SerializeOptions { + /** 是否使用紧凑模式(减少空行) */ + compact?: boolean; + /** 列表缩进字符数 */ + listIndent?: number; + /** 代码块围栏字符 */ + codeFence?: string; +} + +const defaultOptions: SerializeOptions = { + compact: false, + listIndent: 2, + codeFence: "```", +}; + +/** + * Markdown 序列化器类 + */ +export class MarkdownSerializer { + private options: SerializeOptions; + + constructor(options: SerializeOptions = {}) { + this.options = { ...defaultOptions, ...options }; + } + + /** + * 序列化文档 + */ + serialize(doc: Node): string { + const lines: string[] = []; + this.serializeFragment(doc.content, lines, ""); + return lines.join("\n"); + } + + /** + * 序列化 Fragment + */ + private serializeFragment(fragment: Fragment, lines: string[], indent: string): void { + fragment.forEach((node, _, index) => { + this.serializeNode(node, lines, indent, index); + }); + } + + /** + * 序列化节点 + */ + private serializeNode(node: Node, lines: string[], indent: string, index: number): void { + const handler = this.nodeHandlers[node.type.name]; + if (handler) { + handler.call(this, node, lines, indent, index); + } else { + // 默认处理:递归处理子节点 + this.serializeFragment(node.content, lines, indent); + } + } + + /** + * 节点处理器映射 + */ + private nodeHandlers: Record< + string, + (node: Node, lines: string[], indent: string, index: number) => void + > = { + paragraph: (node, lines, indent) => { + // 对于代码块段落,直接输出文本内容(包含围栏符号) + if (node.attrs.codeBlockId) { + const text = node.textContent; + lines.push(indent + text); + const isLastLine = node.attrs.lineIndex === node.attrs.totalLines - 1; + if (isLastLine && !this.options.compact) lines.push(""); + } else if (node.attrs.tableId) { + // 对于表格段落,直接输出文本内容(包含表格语法) + const text = node.textContent; + lines.push(indent + text); + const isLastLine = node.attrs.tableRowIndex === node.attrs.tableTotalRows - 1; + if (isLastLine && !this.options.compact) lines.push(""); + } else if (node.attrs.htmlBlockId) { + // 对于 HTML 块段落,直接输出文本内容 + const text = node.textContent; + lines.push(indent + text); + const isLastLine = node.attrs.htmlBlockLineIndex === node.attrs.htmlBlockTotalLines - 1; + if (isLastLine && !this.options.compact) lines.push(""); + } else { + const text = this.serializeInline(node); + lines.push(indent + text); + if (!this.options.compact) lines.push(""); + } + }, + + heading: (node, lines, indent) => { + const level = node.attrs.level as number; + const hashes = "#".repeat(level); + // serializeInline 会跳过 syntax_marker,所以这里需要手动添加 # + const text = this.serializeInline(node); + lines.push(indent + hashes + " " + text); + if (!this.options.compact) lines.push(""); + }, + + blockquote: (node, lines, indent) => { + const innerLines: string[] = []; + this.serializeFragment(node.content, innerLines, ""); + for (const line of innerLines) { + // 检查行是否已经以 > 开头(来自 syntax_marker) + if (line.startsWith("> ")) { + // 已经有 > 前缀,直接使用 + lines.push(indent + line); + } else if (line === "") { + lines.push(indent + ">"); + } else { + lines.push(indent + "> " + line); + } + } + if (!this.options.compact) lines.push(""); + }, + + code_block: (node, lines, indent) => { + const content = node.textContent; + const lang = node.attrs.language || ""; + const fence = this.options.codeFence!; + + // 总是输出标准的多行格式 + lines.push(indent + fence + lang); + if (content) { + for (const line of content.split("\n")) { + lines.push(indent + line); + } + } + lines.push(indent + fence); + if (!this.options.compact) lines.push(""); + }, + + horizontal_rule: (node, lines, indent) => { + lines.push(indent + "---"); + if (!this.options.compact) lines.push(""); + }, + + bullet_list: (node, lines, indent) => { + node.content.forEach((item) => { + this.serializeListItem(item, lines, indent, "-"); + }); + if (!this.options.compact) lines.push(""); + }, + + ordered_list: (node, lines, indent) => { + const start = (node.attrs.start as number) || 1; + node.content.forEach((item, _, i) => { + this.serializeListItem(item, lines, indent, `${start + i}.`); + }); + if (!this.options.compact) lines.push(""); + }, + + task_list: (node, lines, indent) => { + node.content.forEach((item) => { + const checked = item.attrs.checked ? "x" : " "; + this.serializeListItem(item, lines, indent, `- [${checked}]`); + }); + if (!this.options.compact) lines.push(""); + }, + + table: (node, lines, indent) => { + const rows: string[][] = []; + let headerRow: string[] = []; + + node.content.forEach((row, _, rowIndex) => { + const cells: string[] = []; + row.content.forEach((cell) => { + cells.push(this.serializeInline(cell)); + }); + if (rowIndex === 0) { + headerRow = cells; + } + rows.push(cells); + }); + + if (headerRow.length > 0) { + // 表头 + lines.push(indent + "| " + headerRow.join(" | ") + " |"); + // 分隔行 + lines.push(indent + "| " + headerRow.map(() => "---").join(" | ") + " |"); + // 数据行 + for (let i = 1; i < rows.length; i++) { + lines.push(indent + "| " + rows[i].join(" | ") + " |"); + } + } + if (!this.options.compact) lines.push(""); + }, + + math_block: (node, lines, indent) => { + lines.push(indent + "$$"); + const content = node.textContent || ""; + if (content) { + for (const line of content.split("\n")) { + lines.push(indent + line); + } + } + lines.push(indent + "$$"); + if (!this.options.compact) lines.push(""); + }, + + html_block: (node, lines, indent) => { + const content = node.textContent || ""; + for (const line of content.split("\n")) { + lines.push(indent + line); + } + if (!this.options.compact) lines.push(""); + }, + + container: (node, lines, indent) => { + const type = node.attrs.type || "note"; + const title = node.attrs.title || ""; + lines.push(indent + ":::" + type + (title ? " " + title : "")); + this.serializeFragment(node.content, lines, indent); + lines.push(indent + ":::"); + if (!this.options.compact) lines.push(""); + }, + + image: (node, lines, indent) => { + const alt = node.attrs.alt || ""; + const src = node.attrs.src || ""; + const title = node.attrs.title || ""; + const titlePart = title ? ` "${title}"` : ""; + lines.push(indent + `![${alt}](${src}${titlePart})`); + if (!this.options.compact) lines.push(""); + }, + + hard_break: () => { + // 硬换行在行内处理 + }, + }; + + /** + * 序列化列表项 + */ + private serializeListItem(item: Node, lines: string[], indent: string, marker: string): void { + const innerLines: string[] = []; + this.serializeFragment(item.content, innerLines, ""); + + for (let i = 0; i < innerLines.length; i++) { + const line = innerLines[i]; + if (i === 0) { + lines.push(indent + marker + " " + line); + } else if (line !== "") { + lines.push(indent + " ".repeat(this.options.listIndent!) + line); + } + } + } + + /** + * 序列化行内内容 + */ + private serializeInline(node: Node): string { + let result = ""; + + // 收集连续的同类型 mark 文本,用于正确输出语法 + const segments: Array<{ + text: string; + marks: Mark[]; + isSyntaxMarker: boolean; + }> = []; + + node.content.forEach((child) => { + if (child.isText) { + const syntaxMark = child.marks.find((m) => m.type.name === "syntax_marker"); + const hasSyntaxMarker = !!syntaxMark; + // escape 类型的 syntax_marker 应该保留 `\` 输出 + const isEscapeMarker = syntaxMark?.attrs.syntaxType === "escape"; + segments.push({ + text: child.text || "", + marks: child.marks.filter((m) => m.type.name !== "syntax_marker"), + isSyntaxMarker: hasSyntaxMarker && !isEscapeMarker, + }); + } else if (child.type.name === "hard_break") { + segments.push({ text: " \n", marks: [], isSyntaxMarker: false }); + } else if (child.type.name === "image") { + const alt = child.attrs.alt || ""; + const src = child.attrs.src || ""; + const title = child.attrs.title || ""; + const titlePart = title ? ` "${title}"` : ""; + segments.push({ text: `![${alt}](${src}${titlePart})`, marks: [], isSyntaxMarker: false }); + } + }); + + // 合并相邻的同类型 mark 段落,跳过 syntax_marker 文本 + let i = 0; + while (i < segments.length) { + const seg = segments[i]; + + // 跳过 syntax_marker 文本 + if (seg.isSyntaxMarker) { + i++; + continue; + } + + // 找到连续的相同 mark 的文本 + const markNames = seg.marks + .map((m) => m.type.name) + .sort() + .join(","); + let combinedText = seg.text; + let j = i + 1; + + while (j < segments.length) { + const nextSeg = segments[j]; + // 跳过 syntax_marker + if (nextSeg.isSyntaxMarker) { + j++; + continue; + } + const nextMarkNames = nextSeg.marks + .map((m) => m.type.name) + .sort() + .join(","); + if (nextMarkNames === markNames) { + combinedText += nextSeg.text; + j++; + } else { + break; + } + } + + // 应用 mark 包装 + let output = combinedText; + for (const mark of seg.marks) { + output = this.wrapWithMark(output, mark); + } + result += output; + + i = j; + } + + return result; + } + + /** + * 序列化带 Mark 的文本(已废弃,保留兼容) + */ + private serializeTextWithMarks(node: Node): string { + // 跳过 syntax_marker 文本 + if (node.marks.some((m) => m.type.name === "syntax_marker")) { + return ""; + } + + let text = node.text || ""; + + // 按 Mark 类型包装文本 + for (const mark of node.marks) { + if (mark.type.name !== "syntax_marker") { + text = this.wrapWithMark(text, mark); + } + } + + return text; + } + + /** + * 用 Mark 包装文本 + */ + private wrapWithMark(text: string, mark: Mark): string { + switch (mark.type.name) { + case "strong": + return `**${text}**`; + case "emphasis": + return `*${text}*`; + case "code_inline": + return `\`${text}\``; + case "strikethrough": + return `~~${text}~~`; + case "highlight": + return `==${text}==`; + case "link": { + const href = mark.attrs.href || ""; + const title = mark.attrs.title || ""; + const titlePart = title ? ` "${title}"` : ""; + return `[${text}](${href}${titlePart})`; + } + case "math_inline": + return `$${text}$`; + case "footnote_ref": + return `[^${mark.attrs.id}]`; + default: + return text; + } + } +} + +/** 默认序列化器实例 */ +export const defaultSerializer = new MarkdownSerializer(); + +/** + * 序列化文档为 Markdown + */ +export function serializeMarkdown(doc: Node, options?: SerializeOptions): string { + const serializer = options ? new MarkdownSerializer(options) : defaultSerializer; + return serializer.serialize(doc); +} diff --git a/src/core/styles/milkup.css b/src/core/styles/milkup.css new file mode 100644 index 0000000..a66838e --- /dev/null +++ b/src/core/styles/milkup.css @@ -0,0 +1,1395 @@ +/** + * Milkup 编辑器样式 + */ + +/* 编辑器容器 */ +.milkup-editor { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-size: 16px; + line-height: 1.6; + color: var(--text-color, #333); + background: var(--background-color, #fff); + padding: 20px var(--milkup-editor-padding, 20px); + outline: none; + min-height: 100%; + flex: 1; + display: flex; + flex-direction: column; + position: relative; /* 为 placeholder 提供定位上下文 */ +} + +/* 源码模式下为行号留出空间 */ +.milkup-editor.source-view { + padding-left: calc(var(--milkup-editor-padding, 20px) + 60px); /* 左右边距 + 行号区域 */ +} + +/* 语法标记 - 隐藏 */ +.milkup-syntax-hidden { + font-size: 0; + opacity: 0; + width: 0; + display: inline; + user-select: none; +} + +/* 语法标记 - 显示 */ +.milkup-syntax-visible, +.milkup-syntax { + color: var(--text-color-3, #999); + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 0.9em; +} + +/* 语法标记 - 默认样式 */ +.milkup-syntax-marker { + color: var(--text-color-3, #999); + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 0.9em; +} + +/* 粗体 */ +.milkup-strong { + font-weight: 700; +} + +/* 斜体 */ +.milkup-emphasis { + font-style: italic; +} + +/* 行内代码 */ +.milkup-code-inline { + background: var(--background-color-2, #f5f5f5); + color: var(--primary-color, #e83e8c); + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 0.9em; + padding: 0.2em 0.4em; + border-radius: 3px; +} + +/* 删除线 */ +.milkup-strikethrough { + text-decoration: line-through; + color: var(--text-color-3, #999); +} + +/* 链接 */ +.milkup-editor a, +.milkup-link { + color: var(--primary-color, #0366d6); + text-decoration: none; +} + +.milkup-editor a:hover, +.milkup-link:hover { + text-decoration: underline; +} + +/* 链接语法标记 - 与链接正文样式一致 */ +.milkup-editor a.milkup-syntax-visible, +.milkup-editor a.milkup-syntax, +.milkup-editor a.milkup-syntax-marker, +.milkup-editor a span.milkup-syntax-visible, +.milkup-editor a span.milkup-syntax, +.milkup-editor .milkup-link.milkup-syntax-visible, +.milkup-editor .milkup-link.milkup-syntax, +.milkup-editor .milkup-link.milkup-syntax-marker, +.milkup-editor span.milkup-syntax-visible[data-syntax-type="link"], +.milkup-editor span.milkup-syntax[data-syntax-type="link"] { + color: var(--primary-color, #0366d6); + font-family: inherit; + font-size: inherit; + text-decoration: none; +} + +/* 高亮 */ +.milkup-highlight { + background: var(--active-color, #fff3cd); + padding: 0.1em 0.2em; + border-radius: 2px; +} + +/* 标题 */ +.milkup-editor h1, +.milkup-editor h2, +.milkup-editor h3, +.milkup-editor h4, +.milkup-editor h5, +.milkup-editor h6 { + margin-top: 1.5em; + margin-bottom: 0.5em; + font-weight: 600; + line-height: 1.25; +} + +.milkup-editor h1 { font-size: 2em; } +.milkup-editor h2 { font-size: 1.5em; } +.milkup-editor h3 { font-size: 1.25em; } +.milkup-editor h4 { font-size: 1em; } +.milkup-editor h5 { font-size: 0.875em; } +.milkup-editor h6 { font-size: 0.85em; color: var(--text-color-2, #6a737d); } + +/* 段落 */ +.milkup-editor p { + margin: 0; +} + +/* 引用块 */ +.milkup-editor blockquote { + margin: 1em 0; + padding: 0.5em 1em; + border-left: 4px solid var(--border-color, #dfe2e5); + color: var(--text-color-2, #6a737d); + background: var(--background-color-1, #f8f9fa); +} + +.milkup-editor blockquote > :first-child { + margin-top: 0; +} + +.milkup-editor blockquote > :last-child { + margin-bottom: 0; +} + +/* 代码块 */ +.milkup-code-block { + margin: 1em 0; + border-radius: 6px; + overflow: visible; + background: var(--background-color-1, #f6f8fa); + border: 1px solid var(--border-color, #e1e4e8); +} + +.milkup-code-block-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--background-color-2, #f1f3f5); + border-bottom: 1px solid var(--border-color, #e1e4e8); +} + +/* 代码块复制按钮 */ +.milkup-code-block-copy-btn { + margin-left: auto; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--text-color-3, #999); + cursor: pointer; + opacity: 0; + transition: opacity 0.2s, background 0.2s, color 0.2s; +} + +.milkup-code-block:hover .milkup-code-block-copy-btn { + opacity: 1; +} + +.milkup-code-block-copy-btn:hover { + background: var(--hover-color, rgba(0, 0, 0, 0.06)); + color: var(--text-color-1, #333); +} + +.milkup-code-block-copy-btn.copied { + color: var(--secondary-color, #28a745); +} + +/* 自定义下拉选择器 */ +.milkup-custom-select { + position: relative; + display: inline-block; +} + +.milkup-custom-select-button { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + font-size: 12px; + color: var(--text-color-2, #666); + background: var(--background-color-1, #fff); + border: 1px solid var(--border-color, #e1e4e8); + border-radius: 4px; + cursor: pointer; + transition: all 0.15s ease; + min-width: 80px; + justify-content: space-between; +} + +.milkup-custom-select-button::after { + content: ''; + width: 0; + height: 0; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 4px solid var(--text-color-3, #999); + transition: transform 0.15s ease; +} + +.milkup-custom-select.open .milkup-custom-select-button::after { + transform: rotate(180deg); +} + +.milkup-custom-select-button:hover { + background: var(--hover-background-color, rgba(0, 0, 0, 0.05)); + border-color: var(--border-color-1, #ccc); +} + +.milkup-custom-select-dropdown { + position: absolute; + top: 100%; + left: 0; + z-index: 1000; + min-width: 120px; + max-height: 240px; + overflow-y: auto; + margin-top: 4px; + padding: 4px 0; + background: var(--background-color-1, #fff); + border: 1px solid var(--border-color, #e1e4e8); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + opacity: 0; + visibility: hidden; + transform: translateY(-8px); + transition: all 0.15s ease; +} + +.milkup-custom-select.open .milkup-custom-select-dropdown { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +/* 向上弹出 */ +.milkup-custom-select.dropup .milkup-custom-select-dropdown { + top: auto; + bottom: 100%; + margin-top: 0; + margin-bottom: 4px; + transform: translateY(8px); +} + +.milkup-custom-select.dropup.open .milkup-custom-select-dropdown { + transform: translateY(0); +} + +.milkup-custom-select-item { + padding: 6px 12px; + font-size: 12px; + color: var(--text-color, #333); + cursor: pointer; + transition: background 0.1s ease; +} + +.milkup-custom-select-item:hover { + background: var(--hover-background-color, rgba(0, 0, 0, 0.05)); +} + +.milkup-custom-select-item.selected { + color: var(--primary-color, #0366d6); + background: var(--hover-color, rgba(3, 102, 214, 0.1)); +} + +/* 语言选择器 */ +.milkup-code-block-lang-select .milkup-custom-select-button { + min-width: 100px; +} + +/* 模式选择器 */ +.milkup-code-block-mode-select .milkup-custom-select-button { + min-width: 70px; +} + +.milkup-code-block-editor { + padding: 12px; +} + +.milkup-code-block-editor .cm-editor { + background: transparent; +} + +.milkup-code-block-editor .cm-editor.cm-focused { + outline: none; +} + +.milkup-code-block-editor .cm-editor .cm-scroller { + outline: none; +} + +.milkup-code-block-editor .cm-content { + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 14px; + color: var(--text-color, #333); +} + +/* 代码块底部点击区域 */ +.milkup-code-block-footer { + height: 12px; + cursor: text; + background: transparent; + margin-top: -4px; +} + +.milkup-code-block-footer:hover { + background: var(--hover-background-color, rgba(0, 0, 0, 0.03)); +} + +/* 右键菜单 */ +.milkup-context-menu { + position: fixed; + z-index: 10000; + min-width: 120px; + padding: 4px 0; + background: var(--background-color-1, #fff); + border: 1px solid var(--border-color, #e1e4e8); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.milkup-context-menu-item { + padding: 6px 16px; + font-size: 13px; + color: var(--text-color, #333); + cursor: pointer; + transition: background 0.1s ease; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +/* 菜单项快捷键提示 */ +.milkup-context-menu-shortcut { + font-size: 11px; + color: var(--text-color-3, #999); + flex-shrink: 0; + margin-left: auto; +} + +.milkup-context-menu-item:hover:not(.disabled) { + background: var(--hover-background-color, rgba(0, 0, 0, 0.05)); +} + +.milkup-context-menu-item.disabled { + color: var(--text-color-3, #999); + cursor: not-allowed; +} + +/* 右键菜单分隔线 */ +.milkup-context-menu-separator { + height: 1px; + margin: 4px 8px; + background: var(--border-color, #e1e4e8); +} + +/* 带子菜单的菜单项 */ +.milkup-context-menu-item.has-submenu { + position: relative; +} + +.milkup-context-menu-item.has-submenu::after { + content: ''; + width: 0; + height: 0; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + border-left: 5px solid var(--text-color-3, #999); + margin-left: 12px; + flex-shrink: 0; +} + +/* 子菜单容器 */ +.milkup-context-menu-submenu { + position: fixed; + z-index: 10001; + min-width: 160px; + padding: 4px 0; + background: var(--background-color-1, #fff); + border: 1px solid var(--border-color, #e1e4e8); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + display: none; +} + +.milkup-context-menu-submenu.visible { + display: block; +} + +/* 网格选择器子菜单需要 padding */ +.milkup-context-menu-submenu:has(.milkup-table-grid-picker) { + padding: 8px; +} + +/* 表格网格选择器 */ +.milkup-table-grid-picker { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 4px; +} + +.milkup-table-grid-picker .grid-container { + display: grid; + grid-template-columns: repeat(8, 20px); + grid-template-rows: repeat(8, 20px); + gap: 2px; +} + +.milkup-table-grid-picker .grid-cell { + width: 20px; + height: 20px; + border: 1px solid var(--border-color, #dfe2e5); + border-radius: 2px; + background: var(--background-color-1, #fff); + cursor: pointer; + transition: background 0.05s ease, border-color 0.05s ease; +} + +.milkup-table-grid-picker .grid-cell.active { + background: var(--primary-color, #0366d6); + border-color: var(--primary-color, #0366d6); + opacity: 0.7; +} + +.milkup-table-grid-picker .grid-label { + font-size: 12px; + color: var(--text-color-2, #6a737d); + white-space: nowrap; + min-height: 1.2em; +} + +/* Mermaid 预览 */ +.milkup-mermaid-preview { + padding: 16px; + text-align: center; + border-top: 1px solid var(--border-color, #e1e4e8); + background: var(--background-color-1, #fff); +} + +.milkup-mermaid-error { + color: var(--primary-color, #dc3545); + font-size: 14px; +} + +/* 分隔线 */ +.milkup-editor hr { + height: 2px; + margin: 2em 0; + padding: 0; + background: var(--border-color, #e1e4e8); + border: none; +} + +/* 列表 */ +.milkup-editor ul, +.milkup-editor ol { + margin: 1em 0; + padding-left: 2em; +} + +.milkup-editor li { + margin: 0.25em 0; +} + +/* 任务列表 */ +.milkup-editor ul.task-list { + list-style: none; + padding-left: 0; +} + +.milkup-editor li.task-item { + display: flex; + align-items: flex-start; + gap: 8px; +} + +.milkup-editor li.task-item input[type="checkbox"] { + margin-top: 0.3em; + cursor: pointer; +} + +/* 表格 */ +.milkup-editor table { + width: 100%; + margin: 1em 0; + border-collapse: collapse; + border-spacing: 0; +} + +.milkup-editor th, +.milkup-editor td { + padding: 8px 12px; + border: 1px solid var(--border-color, #dfe2e5); + text-align: left; +} + +.milkup-editor th { + font-weight: 600; + background: var(--background-color-2, #f6f8fa); +} + +.milkup-editor tr:nth-child(even) { + background: var(--background-color-1, #f8f9fa); +} + +/* 图片 */ +.milkup-editor img { + max-width: 100%; + height: auto; + display: block; + margin: 1em auto; + border-radius: 4px; +} + +/* 图片块 NodeView */ +.milkup-editor .milkup-image-block { + margin: 1em 0; + text-align: center; +} + +/* 图片预览区域 */ +.milkup-editor .milkup-image-block .milkup-image-preview { + cursor: pointer; +} + +.milkup-editor .milkup-image-block .milkup-image-preview img { + max-width: 100%; + height: auto; + display: block; + margin: 0 auto; + border-radius: 4px; +} + +/* 图片源码容器 */ +.milkup-editor .milkup-image-block .milkup-image-source-container { + display: none; + text-align: center; + padding: 0.5em 0; +} + +/* 图片源码 - 使用正常文本样式 */ +.milkup-editor .milkup-image-block .milkup-image-source { + display: inline-block; + font-family: inherit; + font-size: inherit; + white-space: pre-wrap; + word-break: break-all; + color: inherit; +} + +/* 编辑模式:同时显示图片和源码 */ +.milkup-editor .milkup-image-block.editing .milkup-image-source-container { + display: block; +} + +/* 图片源码输入框 - 边框淡化 */ +.milkup-editor .milkup-image-block .milkup-image-source-input { + width: 100%; + max-width: 800px; + padding: 0.5em; + font-family: inherit; + font-size: inherit; + color: inherit; + background: transparent; + border: 1px solid transparent; + border-bottom: 1px dashed var(--border-color, #e1e4e8); + border-radius: 0; + text-align: center; + outline: none; + user-select: text; + -webkit-user-drag: none; +} + +.milkup-editor .milkup-image-block .milkup-image-source-container { + -webkit-user-drag: none; + user-select: text; + pointer-events: auto; +} + +/* 编辑模式下禁止图片块拖动 */ +.milkup-editor .milkup-image-block.editing { + -webkit-user-drag: none; +} + +.milkup-editor .milkup-image-block .milkup-image-source-input:focus { + border-bottom-color: var(--primary-color, #0366d6); + border-bottom-style: solid; +} + +/* 源码在图片前面时的间距 */ +.milkup-editor .milkup-image-block.source-before .milkup-image-source-container { + margin-bottom: 0.5em; +} + +/* 源码在图片后面时的间距 */ +.milkup-editor .milkup-image-block.source-after .milkup-image-source-container { + margin-top: 0.5em; +} + +/* 图片占位元素 */ +.milkup-editor .milkup-image-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 120px; + padding: 1.5em; + background: var(--background-color-2, #f5f5f5); + border-radius: 8px; + color: var(--placeholder-color, #adb5bd); + gap: 0.5em; +} + +.milkup-editor .milkup-image-placeholder-icon { + font-size: 2em; + opacity: 0.6; +} + +.milkup-editor .milkup-image-placeholder-text { + font-size: 0.9em; +} + +.milkup-editor .milkup-image-placeholder-src { + font-size: 0.8em; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + word-break: break-all; + max-width: 100%; + opacity: 0.7; +} + +/* 图片加载错误占位 */ +.milkup-editor .milkup-image-error-placeholder { + background: var(--background-color-1, #f8f9fa); + border: 1px dashed var(--primary-color, #dc3545); +} + +.milkup-editor .milkup-image-error-placeholder .milkup-image-placeholder-text { + color: var(--primary-color, #dc3545); +} + +/* 图片加载错误(旧样式,保留兼容) */ +.milkup-editor .milkup-image-error { + color: var(--primary-color, #dc3545); + font-style: italic; +} + +/* 数学块 */ +.milkup-editor .math-block { + margin: 1em 0; + text-align: center; + overflow-x: auto; + color: inherit; +} + +/* 数学块预览区域 */ +.milkup-editor .math-block .math-preview { + padding: 0.5em 0; + cursor: pointer; + color: inherit; +} + +.milkup-editor .math-block .math-preview .katex { + color: inherit !important; +} + +/* 强制 KaTeX 所有元素继承颜色 */ +.milkup-editor .math-block .math-preview .katex *, +.milkup-editor .math-block .math-preview .katex .katex-html, +.milkup-editor .math-block .math-preview .katex .base, +.milkup-editor .math-block .math-preview .katex .strut, +.milkup-editor .math-block .math-preview .katex .mord, +.milkup-editor .math-block .math-preview .katex .mbin, +.milkup-editor .math-block .math-preview .katex .mrel, +.milkup-editor .math-block .math-preview .katex .mopen, +.milkup-editor .math-block .math-preview .katex .mclose, +.milkup-editor .math-block .math-preview .katex .mpunct, +.milkup-editor .math-block .math-preview .katex .minner { + color: inherit !important; +} + +/* 数学块源码容器 */ +.milkup-editor .math-block .math-source-container { + display: none; + text-align: center; + padding: 0.5em 0; + color: inherit; +} + +/* 数学块源码 - 与渲染状态保持一致的大小和颜色 */ +.milkup-editor .math-block .math-source { + display: inline-block; + font-family: KaTeX_Main, 'Times New Roman', serif; + font-size: 1.21em; + white-space: pre-wrap; + text-align: center; + color: inherit; +} + +/* 编辑模式:隐藏预览,显示源码 */ +.milkup-editor .math-block.editing .math-preview { + display: none; +} + +.milkup-editor .math-block.editing .math-source-container { + display: block; +} + +/* ============ HTML 块 ============ */ + +.milkup-html-block { + margin: 1em 0; + border-radius: 6px; + overflow: visible; + background: var(--background-color-1, #f6f8fa); + border: 1px solid var(--border-color, #e1e4e8); +} + +.milkup-html-block-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--background-color-2, #f1f3f5); + border-bottom: 1px solid var(--border-color, #e1e4e8); +} + +.milkup-html-block-label { + font-size: 12px; + font-weight: 600; + color: var(--text-color-2, #656d76); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.milkup-html-block-preview { + padding: 12px; + min-height: 1em; + cursor: pointer; + color: inherit; + overflow: auto; + max-height: 600px; +} + +.milkup-html-block-preview .html-placeholder { + color: var(--text-color-3, #8b949e); + font-style: italic; +} + +.milkup-html-block-editor { + padding: 12px; +} + +.milkup-html-block-editor .cm-editor { + outline: none; +} + +/* 源码模式 */ +.milkup-html-block.source-view { + background: transparent; + border: none; + margin: 0; + border-radius: 0; +} + +/* 行内数学 */ +.milkup-editor .math-inline, +.milkup-math-inline { + /* 由 KaTeX 渲染,无额外样式 */ +} + +/* 行内数学公式的语法标记($ 符号)- 颜色与正文一致 */ +.milkup-editor span.milkup-syntax[data-syntax-type="math_inline"], +.milkup-editor span.milkup-syntax-visible[data-syntax-type="math_inline"] { + color: inherit !important; + font-family: inherit !important; + font-size: inherit !important; +} + +/* 行内数学公式内容 - 颜色与正文一致 */ +.milkup-editor span.math-inline { + color: inherit !important; +} + +/* 行内数学公式源码隐藏 */ +.milkup-math-source-hidden { + font-size: 0; + opacity: 0; + width: 0; + display: inline; + user-select: none; +} + +/* 行内数学公式渲染结果 - 颜色与正文一致 */ +.milkup-editor .milkup-math-rendered { + display: inline; + color: inherit; +} + +.milkup-editor .milkup-math-rendered .katex { + font-size: 1em; + color: inherit !important; +} + +/* 强制行内 KaTeX 所有元素继承颜色 */ +.milkup-editor .milkup-math-rendered .katex *, +.milkup-editor .milkup-math-rendered .katex .katex-html, +.milkup-editor .milkup-math-rendered .katex .base, +.milkup-editor .milkup-math-rendered .katex .strut, +.milkup-editor .milkup-math-rendered .katex .mord, +.milkup-editor .milkup-math-rendered .katex .mbin, +.milkup-editor .milkup-math-rendered .katex .mrel, +.milkup-editor .milkup-math-rendered .katex .mopen, +.milkup-editor .milkup-math-rendered .katex .mclose, +.milkup-editor .milkup-math-rendered .katex .mpunct, +.milkup-editor .milkup-math-rendered .katex .minner { + color: inherit !important; +} + +/* 数学公式错误 */ +.milkup-editor .math-error { + color: var(--primary-color, #dc3545); + font-family: monospace; +} + +/* 数学公式占位符 */ +.milkup-editor .math-placeholder { + color: var(--placeholder-color, #adb5bd); + font-style: italic; +} + +/* 容器 */ +.milkup-editor .container { + margin: 1em 0; + padding: 1em; + border-radius: 6px; + border-left: 4px solid; +} + +.milkup-editor .container-note { + background: #e8f4fd; + border-color: #0366d6; +} + +.milkup-editor .container-tip { + background: #e6f6e6; + border-color: #28a745; +} + +.milkup-editor .container-warning { + background: #fff8e6; + border-color: #ffc107; +} + +.milkup-editor .container-danger { + background: #ffeef0; + border-color: #dc3545; +} + +/* 源码视图模式 */ +.milkup-editor.source-view .milkup-syntax-hidden { + font-size: 0.9em; + opacity: 1; + width: auto; + display: inline; + user-select: auto; + color: var(--text-color-3, #999); + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; +} + +/* 源码模式下隐藏数学公式渲染 widget */ +.milkup-editor.source-view .milkup-math-rendered { + display: none !important; +} + +/* 源码模式下显示数学公式源码 */ +.milkup-editor.source-view .milkup-math-source-hidden { + font-size: inherit; + opacity: 1; + width: auto; + user-select: auto; +} + +/* 源码模式下禁用高亮渲染 */ +.milkup-editor.source-view mark { + background: transparent; + color: inherit; + padding: 0; +} + +/* 源码模式下隐藏分割线(由 source-view-transform 转为段落) */ +.milkup-editor.source-view hr { + display: none; +} + +/* 行号 - 使用 CSS 计数器和绝对定位 */ +.milkup-editor.source-view { + counter-reset: line-number; +} + +.milkup-editor.source-view .milkup-with-line-number { + position: relative; /* 为行号提供定位上下文 */ +} + +.milkup-editor.source-view .milkup-with-line-number::before { + counter-increment: line-number; + content: counter(line-number); + position: absolute; + left: calc(-60px); /* 定位到左侧外部 */ + top: 50%; + transform: translateY(-50%); + width: 40px; + padding-right: 8px; + text-align: right; + color: var(--text-color-3, #999) !important; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace !important; + font-size: 14px !important; + font-weight: normal !important; + font-style: normal !important; + line-height: 1.6 !important; + user-select: none; + pointer-events: none; + border-right: 1px solid var(--border-color-1, #e1e4e8); + margin-right: 12px; +} + +/* 列表项内段落的行号 - 补偿列表标记宽度 */ +.milkup-editor.source-view .milkup-list-item.source-view .milkup-list-item-content > .milkup-list-line-number::before, +.milkup-editor.source-view .milkup-task-item.source-view .milkup-list-item-content > .milkup-list-line-number::before { + left: calc(-60px - var(--marker-width, 20px)); +} + +/* 源码模式下的代码块段落 - 移除段落间距,使用不同颜色区分 */ +.milkup-editor.source-view p[data-code-block-id] { + margin: 0; + padding: 0; + line-height: 1.6; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 14px; + color: var(--secondary-color, #6a9955); + white-space: pre-wrap; +} + +/* 代码块 - 使用 data-line-count 属性来递增计数器 */ +.milkup-editor.source-view .milkup-code-block-lines { + position: relative; +} + +.milkup-editor.source-view .milkup-code-block-lines::before { + counter-increment: line-number attr(data-line-count number, 1); + content: ''; + display: none; +} + +/* 光标样式 */ +.milkup-editor .ProseMirror-cursor { + border-left: 2px solid var(--text-color, #333); +} + +/* 选区样式 */ +.milkup-editor .ProseMirror-selectednode { + outline: 2px solid var(--primary-color, #0366d6); +} + +/* 占位符 */ +.milkup-editor.milkup-empty::before { + content: attr(data-placeholder); + color: var(--placeholder-color, #adb5bd); + pointer-events: none; + user-select: none; + position: absolute; + /* 使用 padding 来定位,与编辑器的 padding 保持一致 */ + left: 0; + top: 0; + padding: 58px calc(var(--milkup-editor-padding, 20px) + 8px); +} + +/* 源码模式下的占位符需要向右偏移以避开行号 */ +.milkup-editor.milkup-empty.source-view::before { + padding-top: 56px; + padding-left: calc(var(--milkup-editor-padding, 20px) + 20px + 48px); /* 左右边距 + 上下边距 + 行号区域 */ +} + +/* ============ 源码模式样式 ============ */ + +/* 引用块源码模式 - 移除渲染样式 */ +.milkup-editor.source-view blockquote { + margin: 0; + padding: 0; + border-left: none; + color: inherit; + background: transparent; +} + +/* 代码块源码模式 */ +.milkup-code-block.source-view { + background: transparent; + border: none; + margin: 0; +} + +.milkup-code-block.source-view .milkup-code-block-header { + display: none; +} + +.milkup-code-block.source-view .milkup-code-block-footer { + display: none; +} + +.milkup-code-block.source-view .milkup-code-block-editor { + padding: 0; +} + +/* 代码块源码内容 */ +.milkup-code-block-source { + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 14px; + line-height: 1.6; + color: inherit; + white-space: pre; + padding: 0; + margin: 0; + outline: none; + min-height: 1.6em; +} + +.milkup-code-block-source:focus { + outline: none; +} + +.milkup-code-block-source:empty::before { + content: '```\n\n```'; + color: var(--text-color-3, #999); + pointer-events: none; +} + +/* 代码块源码模式:使用 data-line-count 属性递增行号计数器 */ +.milkup-editor.source-view .milkup-code-block-source::before { + counter-increment: line-number attr(data-line-count number, 1); + content: ''; + display: none; +} + +/* 图片源码模式 */ +.milkup-image-block.source-view { + text-align: left; +} + +.milkup-image-block.source-view .milkup-image-preview { + display: none !important; +} + +.milkup-image-block.source-view .milkup-image-source-container { + display: none !important; +} + +/* 图片源码文本 */ +.milkup-image-source-text { + font-family: inherit; + font-size: inherit; + color: inherit; + line-height: 1.6; + outline: none; + white-space: pre-wrap; + word-break: break-all; +} + +.milkup-image-source-text:focus { + background: var(--hover-background-color, rgba(0, 0, 0, 0.03)); + border-radius: 2px; +} + +/* 列表源码模式 */ +.milkup-bullet-list.source-view, +.milkup-ordered-list.source-view, +.milkup-task-list.source-view { + list-style: none; + padding-left: 0; + margin-left: 0; +} + +.milkup-bullet-list.source-view .milkup-bullet-list.source-view, +.milkup-bullet-list.source-view .milkup-ordered-list.source-view, +.milkup-ordered-list.source-view .milkup-bullet-list.source-view, +.milkup-ordered-list.source-view .milkup-ordered-list.source-view { + padding-left: 2em; +} + +/* 列表项源码模式 */ +.milkup-list-item { + display: list-item; /* 即时渲染模式使用浏览器默认列表样式 */ +} + +.milkup-list-item-content { + display: inline; +} + +.milkup-list-item-content > p { + display: inline; + margin: 0; +} + +.milkup-list-item-content > p:first-child { + display: inline; +} + +/* 列表标记 */ +.milkup-list-marker { + display: none; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + color: var(--text-color-3, #999); +} + +.milkup-list-item.source-view { + display: flex; + align-items: flex-start; +} + +.milkup-list-item.source-view .milkup-list-marker { + display: inline; + flex-shrink: 0; +} + +.milkup-list-item.source-view .milkup-list-item-content { + flex: 1; +} + +.milkup-list-item.source-view .milkup-list-item-content > p { + display: block; +} + +/* 任务列表 NodeView */ +.milkup-task-list { + list-style: none; + padding-left: 0; +} + +.milkup-task-item { + display: flex; + align-items: flex-start; + gap: 8px; +} + +/* 自定义复选框 */ +.milkup-task-checkbox { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + min-width: 16px; + margin-top: 0.3em; + border: 1.5px solid var(--border-color-1, #d1d5da); + border-radius: 3px; + background: var(--background-color, #fff); + cursor: pointer; + transition: background-color 0.15s ease, border-color 0.15s ease; + box-sizing: border-box; +} + +.milkup-task-checkbox:hover { + border-color: var(--primary-color, #0366d6); + background: var(--hover-color, rgba(3, 102, 214, 0.1)); +} + +.milkup-task-checkbox.checked { + background: var(--primary-color, #0366d6); + border-color: var(--primary-color, #0366d6); +} + +.milkup-task-checkbox.checked:hover { + opacity: 0.85; +} + +.milkup-task-checkbox.checked::after { + content: ""; + display: block; + width: 4px; + height: 8px; + border: solid var(--background-color, #fff); + border-width: 0 2px 2px 0; + transform: rotate(45deg) translate(-0.5px, -0.5px); +} + +/* 任务列表项源码模式 */ +.milkup-task-item.source-view { + display: flex; + align-items: flex-start; + gap: 0; +} + +.milkup-task-item.source-view .milkup-task-checkbox { + display: none; +} + +.milkup-task-item.source-view .milkup-list-marker { + display: inline; + flex-shrink: 0; +} + +.milkup-task-item.source-view .milkup-list-item-content { + flex: 1; +} + +.milkup-task-item.source-view .milkup-list-item-content > p { + display: block; +} + +/* ========== 搜索替换面板 ========== */ + +.milkup-search-wrapper { + position: sticky; + top: 0; + z-index: 100; + height: 0; + pointer-events: none; + user-select: none; + -webkit-user-select: none; + display: none; +} + +.milkup-search-wrapper.visible { + display: block; +} + +.milkup-search-panel { + pointer-events: auto; + position: absolute; + top: 0; + right: 16px; + background: var(--background-color, #fff); + border: 1px solid var(--border-color, #e1e4e8); + border-radius: 0 0 6px 6px; + padding: 6px 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + font-size: 13px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +.milkup-search-row, +.milkup-replace-row { + display: flex; + align-items: center; + gap: 3px; + min-height: 28px; +} + +.milkup-replace-row { + margin-top: 4px; + padding-left: 27px; +} + +.milkup-replace-row.hidden { + display: none; +} + +.milkup-search-panel input { + height: 24px; + padding: 2px 6px; + border: 1px solid var(--border-color, #e1e4e8); + border-radius: 3px; + background: var(--background-color, #fff); + color: var(--text-color, #333); + font-size: 13px; + outline: none; + width: 180px; + box-sizing: border-box; +} + +.milkup-search-panel input:focus { + border-color: var(--primary-color, #0366d6); + box-shadow: 0 0 0 1px var(--primary-color, #0366d6); +} + +.milkup-search-panel button { + width: 24px; + height: 24px; + padding: 0; + border: 1px solid transparent; + border-radius: 3px; + background: transparent; + color: var(--text-color-2, #6a737d); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + line-height: 1; + user-select: none; +} + +.milkup-search-panel button:hover { + background: var(--hover-color, rgba(0, 0, 0, 0.06)); + color: var(--text-color, #333); +} + +.milkup-search-panel button.active { + background: rgba(0, 100, 214, 0.12); + border-color: var(--primary-color, #0366d6); + color: var(--primary-color, #0366d6); +} + +.milkup-search-panel button svg { + width: 14px; + height: 14px; + fill: currentColor; +} + +.milkup-search-panel .match-count { + font-size: 12px; + color: var(--text-color-2, #6a737d); + white-space: nowrap; + min-width: 40px; + text-align: center; + user-select: none; +} + +.milkup-search-panel .replace-btn { + width: auto; + padding: 0 8px; + font-size: 12px; + height: 22px; + border: 1px solid var(--border-color, #e1e4e8); +} + +.milkup-search-panel .replace-btn:hover { + background: var(--hover-color, rgba(0, 0, 0, 0.06)); +} + +/* 搜索匹配高亮 */ +.milkup-search-match { + background: var(--active-color, rgba(255, 237, 0, 0.4)); + border-radius: 2px; +} + +.milkup-search-match-current { + background: rgba(255, 153, 0, 0.6); + outline: 1px solid var(--primary-color, #0366d6); +} + +/* ============ 链接 Tooltip ============ */ +.milkup-link-tooltip { + display: none; + position: absolute; + z-index: 100; + max-width: 400px; + padding: 4px 8px; + font-size: 12px; + line-height: 1.5; + color: var(--text-color-2, #666); + background: var(--background-color-2, #f1f3f5); + border: 1px solid var(--border-color, #e1e4e8); + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + pointer-events: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} diff --git a/src/core/types/index.ts b/src/core/types/index.ts new file mode 100644 index 0000000..b5860cc --- /dev/null +++ b/src/core/types/index.ts @@ -0,0 +1,154 @@ +/** + * Milkup 核心类型定义 + */ + +import type { EditorState, Transaction } from "prosemirror-state"; +import type { EditorView } from "prosemirror-view"; +import type { Node, Schema } from "prosemirror-model"; +import type { PastePluginConfig } from "../plugins/paste"; +import type { AICompletionConfig } from "../plugins/ai-completion"; + +/** 编辑器配置 */ +export interface MilkupConfig { + /** 初始 Markdown 内容 */ + content?: string; + /** 是否只读 */ + readonly?: boolean; + /** 是否启用源码视图(显示所有语法标记) */ + sourceView?: boolean; + /** 占位符文本 */ + placeholder?: string; + /** 图片路径处理器 */ + imagePathProcessor?: ImagePathProcessor; + /** 粘贴插件配置 */ + pasteConfig?: PastePluginConfig; + /** AI 续写配置 */ + aiConfig?: AICompletionConfig; + /** 自定义插件 */ + plugins?: MilkupPlugin[]; +} + +/** 图片路径处理器 */ +export interface ImagePathProcessor { + /** 处理图片路径(渲染时) */ + process: (path: string, basePath: string | null) => string; + /** 反向处理(保存时) */ + reverse: (path: string, basePath: string | null) => string; +} + +/** Milkup 插件 */ +export interface MilkupPlugin { + name: string; + init?: (editor: MilkupEditor) => void; + destroy?: () => void; +} + +/** Milkup 编辑器实例 */ +export interface MilkupEditor { + /** ProseMirror 视图 */ + view: EditorView; + /** 获取 Markdown 内容 */ + getMarkdown: () => string; + /** 设置 Markdown 内容 */ + setMarkdown: (content: string) => void; + /** 获取当前配置 */ + getConfig: () => MilkupConfig; + /** 更新配置 */ + updateConfig: (config: Partial) => void; + /** 销毁编辑器 */ + destroy: () => void; + /** 聚焦编辑器 */ + focus: () => void; + /** 获取光标位置(在源码中的偏移量) */ + getCursorOffset: () => number; + /** 设置光标位置 */ + setCursorOffset: (offset: number) => void; +} + +/** 语法类型 */ +export type SyntaxType = + // 块级 + | "heading" + | "paragraph" + | "blockquote" + | "code_block" + | "horizontal_rule" + | "bullet_list" + | "ordered_list" + | "list_item" + | "task_list" + | "task_item" + | "table" + | "table_row" + | "table_cell" + | "table_header" + | "math_block" + | "container" + // 行内 + | "text" + | "strong" + | "emphasis" + | "code_inline" + | "strikethrough" + | "link" + | "image" + | "math_inline" + | "highlight" + | "footnote_ref" + | "hard_break"; + +/** 语法标记信息 */ +export interface SyntaxMarker { + /** 语法类型 */ + type: SyntaxType; + /** 开始标记 */ + prefix: string; + /** 结束标记 */ + suffix: string; + /** 在源码中的起始位置 */ + sourceStart: number; + /** 在源码中的结束位置 */ + sourceEnd: number; + /** 在文档中的起始位置 */ + docStart: number; + /** 在文档中的结束位置 */ + docEnd: number; +} + +/** 位置映射 - 源码位置到文档位置 */ +export interface PositionMap { + /** 源码位置到文档位置 */ + sourceToDoc: (sourcePos: number) => number; + /** 文档位置到源码位置 */ + docToSource: (docPos: number) => number; + /** 获取指定位置的语法标记 */ + getMarkersAt: (docPos: number) => SyntaxMarker[]; +} + +/** 装饰状态 */ +export interface DecorationState { + /** 当前光标所在的语法区域 */ + activeSyntax: SyntaxMarker[]; + /** 是否处于源码视图模式 */ + sourceView: boolean; +} + +/** 事件类型 */ +export type MilkupEventType = "change" | "selectionChange" | "focus" | "blur"; + +/** 事件处理器 */ +export type MilkupEventHandler = (data: T) => void; + +/** 变更事件数据 */ +export interface ChangeEventData { + markdown: string; + transaction: Transaction; +} + +/** 选区变更事件数据 */ +export interface SelectionChangeEventData { + from: number; + to: number; + sourceFrom: number; + sourceTo: number; +} diff --git a/src/main/fileFormat.ts b/src/main/fileFormat.ts new file mode 100644 index 0000000..4c69a30 --- /dev/null +++ b/src/main/fileFormat.ts @@ -0,0 +1,114 @@ +/** + * 文件格式检测与还原工具 + * 在主进程中使用,确保渲染层不需要关心文件原始格式 + */ + +export interface FileTraits { + hasBOM: boolean; + lineEnding: "crlf" | "lf"; + hasTrailingNewline: boolean; +} + +/** + * 从原始文件内容检测格式特征 + */ +export function detectFileTraits(raw: string): FileTraits { + return { + hasBOM: raw.startsWith("\uFEFF"), + lineEnding: raw.includes("\r\n") ? "crlf" : "lf", + hasTrailingNewline: raw.endsWith("\n"), + }; +} + +/** + * 根据 FileTraits 还原文件原始格式(写入前调用) + */ +export function restoreFileTraits(content: string, traits?: FileTraits): string { + if (!traits) return content; + + let result = content; + + // 还原换行符(编辑器内部统一用 LF,需要还原为 CRLF) + if (traits.lineEnding === "crlf") { + result = result.replace(/\n/g, "\r\n"); + } + + // 还原末尾换行 + if (traits.hasTrailingNewline) { + const eol = traits.lineEnding === "crlf" ? "\r\n" : "\n"; + if (!result.endsWith(eol)) { + result += eol; + } + } else { + // 原文件无末尾换行,移除可能被编辑器添加的 + while (result.endsWith("\r\n")) result = result.slice(0, -2); + while (result.endsWith("\n")) result = result.slice(0, -1); + } + + // 还原 BOM + if (traits.hasBOM) { + result = `\uFEFF${result}`; + } + + return result; +} + +/** + * 归一化 Markdown 文本(读取后调用) + * 移除 BOM,CRLF → LF,编辑器内部统一使用 LF + */ +export function normalizeMarkdown(text: string): string { + return text.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n"); +} + +/** + * 从 milkup:// URL 中提取原始相对路径 + * 旧 URL 格式: base64path/relativePath 或 base64path/./relativePath + */ +function extractRelativePath(urlContent: string): string | null { + // Case 1: base64 以 = 结尾(有 padding),=/relative 或 ==/relative + const paddingMatch = urlContent.match(/^[A-Za-z0-9+/]+=+\/(.+)$/); + if (paddingMatch) return paddingMatch[1]; + + // Case 2: 相对路径以 ./ 开头,找 /./ 边界 + const dotSlashIndex = urlContent.indexOf("/./"); + if (dotSlashIndex !== -1) { + return urlContent.substring(dotSlashIndex + 1); + } + + // Case 3: 回退 — 取最后一个 / 后面的部分(如果看起来像文件名) + const lastSlash = urlContent.lastIndexOf("/"); + if (lastSlash > 0) { + const possibleRelative = urlContent.substring(lastSlash + 1); + if (/\.\w+$/.test(possibleRelative)) { + return possibleRelative; + } + } + + return null; +} + +/** + * 清理文件中残留的 milkup:// 协议 URL(旧版本可能将其写入文件) + * 将 milkup:// URL 还原为原始相对路径 + */ +export function cleanupProtocolUrls(content: string): string { + let result = content; + + // Markdown 图片: ![alt](milkup://...relative) → ![alt](relative) + result = result.replace(/!\[([^\]]*)\]\(milkup:\/\/\/?([^)]+)\)/g, (match, alt, urlContent) => { + const relative = extractRelativePath(urlContent); + return relative ? `![${alt}](${relative})` : match; + }); + + // HTML img: + result = result.replace( + /]*?)src=(["'])milkup:\/\/\/?([^"']+)\2([^>]*)>/gi, + (match, before, quote, urlContent, after) => { + const relative = extractRelativePath(urlContent); + return relative ? `` : match; + } + ); + + return result; +} diff --git a/src/main/index.ts b/src/main/index.ts index 3c43f13..ac4fabd 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,6 +1,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { app, BrowserWindow, globalShortcut, ipcMain, protocol, shell } from "electron"; +import { cleanupProtocolUrls, detectFileTraits, normalizeMarkdown } from "./fileFormat"; import { close, getIsQuitting, @@ -54,6 +55,10 @@ async function createWindow() { // 防止应用内部跳转(直接点击链接) win.webContents.on("will-navigate", (event, url) => { + // 允许 dev server 的 reload + if (process.env.VITE_DEV_SERVER_URL && url.startsWith(process.env.VITE_DEV_SERVER_URL)) { + return; + } if (url.startsWith("https:") || url.startsWith("http:")) { event.preventDefault(); shell.openExternal(url); @@ -152,7 +157,9 @@ function sendFileToRenderer(filePath: string) { } // 读取文件内容 - const content = fs.readFileSync(filePath, "utf-8"); + const raw = fs.readFileSync(filePath, "utf-8"); + const fileTraits = detectFileTraits(raw); + const content = cleanupProtocolUrls(normalizeMarkdown(raw)); // 发送到渲染进程的函数 const sendFile = () => { @@ -160,6 +167,7 @@ function sendFileToRenderer(filePath: string) { win.webContents.send("open-file-at-launch", { filePath, content, + fileTraits, }); } }; @@ -196,36 +204,40 @@ protocol.registerSchemesAsPrivileged([ app.whenReady().then(async () => { registerGlobalIpcHandlers(); - // 注册自定义协议处理器 + // 注册自定义协议处理器(仅用于兼容旧版本残留的 milkup:// URL) + // 新版本使用 file:// 协议直接加载本地图片 protocol.registerFileProtocol("milkup", (request, callback) => { try { - // URL 格式: milkup://// - const url = request.url.substring("milkup:///".length); - const firstSlashIndex = url.indexOf("/"); + const rawUrl = request.url; + + // 提取路径部分 + let urlPath: string; + if (rawUrl.startsWith("milkup:///")) { + urlPath = rawUrl.substring("milkup:///".length); + } else { + urlPath = rawUrl.substring("milkup://".length); + } + // 旧格式:/ + const firstSlashIndex = urlPath.indexOf("/"); if (firstSlashIndex === -1) { - callback({ error: -2 }); // FILE_NOT_FOUND + callback({ error: -2 }); return; } - const base64Path = url.substring(0, firstSlashIndex); - const relativePath = url.substring(firstSlashIndex + 1); + const encodedMdPath = urlPath.substring(0, firstSlashIndex); + const relativePath = urlPath.substring(firstSlashIndex + 1); - // 解码 markdown 文件路径 - const markdownPath = Buffer.from(base64Path, "base64").toString("utf-8"); + const markdownPath = Buffer.from(encodedMdPath, "base64").toString("utf-8"); const markdownDir = path.dirname(markdownPath); - - // 解析相对路径为绝对路径 const absolutePath = path.resolve(markdownDir, decodeURIComponent(relativePath)); - // 检查文件是否存在 if (!fs.existsSync(absolutePath)) { console.error("[milkup protocol] 文件不存在:", absolutePath); - callback({ error: -6 }); // FILE_NOT_FOUND + callback({ error: -6 }); return; } - // 返回文件路径 callback({ path: absolutePath }); } catch (error) { console.error("[milkup protocol] 处理请求失败:", error); diff --git a/src/main/ipcBridge.ts b/src/main/ipcBridge.ts index ad1ebd9..8ec4d7b 100644 --- a/src/main/ipcBridge.ts +++ b/src/main/ipcBridge.ts @@ -2,6 +2,7 @@ import type { FSWatcher } from "chokidar"; import type { Block, ExportPDFOptions } from "./types"; +import type { FileTraits } from "./fileFormat"; import { execSync } from "node:child_process"; import * as fs from "node:fs"; import path from "node:path"; @@ -9,6 +10,12 @@ import chokidar from "chokidar"; import { Document, HeadingLevel, Packer, Paragraph, TextRun } from "docx"; import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } from "electron"; import { getFonts } from "font-list"; +import { + cleanupProtocolUrls, + detectFileTraits, + normalizeMarkdown, + restoreFileTraits, +} from "./fileFormat"; import { createThemeEditorWindow } from "./index"; let isSaved = true; @@ -132,22 +139,36 @@ export function registerIpcHandleHandlers(win: Electron.BrowserWindow) { }); if (canceled) return null; const filePath = filePaths[0]; - const content = fs.readFileSync(filePath, "utf-8"); - return { filePath, content }; + const raw = fs.readFileSync(filePath, "utf-8"); + const fileTraits = detectFileTraits(raw); + const content = cleanupProtocolUrls(normalizeMarkdown(raw)); + return { filePath, content, fileTraits }; }); // 文件保存对话框 - ipcMain.handle("dialog:saveFile", async (_event, { filePath, content }) => { - if (!filePath) { - const { canceled, filePath: savePath } = await dialog.showSaveDialog(win, { - filters: [{ name: "Markdown", extensions: ["md", "markdown"] }], - }); - if (canceled || !savePath) return null; - filePath = savePath; + ipcMain.handle( + "dialog:saveFile", + async ( + _event, + { + filePath, + content, + fileTraits, + }: { filePath: string | null; content: string; fileTraits?: FileTraits } + ) => { + if (!filePath) { + const { canceled, filePath: savePath } = await dialog.showSaveDialog(win, { + filters: [{ name: "Markdown", extensions: ["md", "markdown"] }], + }); + if (canceled || !savePath) return null; + filePath = savePath; + } + // 根据原始文件格式特征还原内容 + const restoredContent = restoreFileTraits(content, fileTraits); + fs.writeFileSync(filePath, restoredContent, "utf-8"); + return filePath; } - fs.writeFileSync(filePath, content, "utf-8"); - return filePath; - }); + ); // 文件另存为对话框 ipcMain.handle("dialog:saveFileAs", async (_event, content) => { const { canceled, filePath } = await dialog.showSaveDialog(win, { @@ -418,8 +439,10 @@ export function registerGlobalIpcHandlers() { const isMd = /\.(?:md|markdown)$/i.test(filePath); if (!isMd) return null; - const content = fs.readFileSync(filePath, "utf-8"); - return { filePath, content }; + const raw = fs.readFileSync(filePath, "utf-8"); + const fileTraits = detectFileTraits(raw); + const content = cleanupProtocolUrls(normalizeMarkdown(raw), filePath); + return { filePath, content, fileTraits }; } catch (error) { console.error("Failed to read file:", error); return null; diff --git a/src/main/update.ts b/src/main/update.ts index 3077981..81e9147 100644 --- a/src/main/update.ts +++ b/src/main/update.ts @@ -4,21 +4,127 @@ import * as path from "node:path"; import { createWriteStream } from "node:fs"; -// 简单的版本比较函数 +/** + * 解析版本号字符串 + * 支持格式: + * - v1.0.0 + * - 1.0.0 + * - Beta-v0.6.0-milkupcore + * - v1.0.0-alpha.1 + * - 2.0.0-rc.1 + */ +interface ParsedVersion { + major: number; + minor: number; + patch: number; + prerelease: string | null; + prereleaseNumber: number; + build: string | null; +} + +function parseVersion(version: string): ParsedVersion | null { + try { + // 移除前缀(如 Beta-v, v, Alpha-v 等) + let cleaned = version.replace(/^(Beta-|Alpha-|RC-)?v?/i, ""); + + // 分离构建元数据(如 -milkupcore) + let build: string | null = null; + const buildMatch = cleaned.match(/[-+]([a-zA-Z][a-zA-Z0-9-]*?)$/); + if (buildMatch && !buildMatch[1].match(/^\d/)) { + build = buildMatch[1]; + cleaned = cleaned.replace(/[-+][a-zA-Z][a-zA-Z0-9-]*?$/, ""); + } + + // 分离预发布版本(如 -alpha.1, -beta.2, -rc.1) + let prerelease: string | null = null; + let prereleaseNumber = 0; + const prereleaseMatch = cleaned.match(/-(alpha|beta|rc)\.?(\d+)?$/i); + if (prereleaseMatch) { + prerelease = prereleaseMatch[1].toLowerCase(); + prereleaseNumber = prereleaseMatch[2] ? parseInt(prereleaseMatch[2], 10) : 0; + cleaned = cleaned.replace(/-(alpha|beta|rc)\.?\d*$/i, ""); + } + + // 解析主版本号 + const parts = cleaned.split(".").map((p) => parseInt(p, 10)); + if (parts.length < 1 || parts.some(isNaN)) { + console.warn(`[parseVersion] Invalid version format: ${version}`); + return null; + } + + return { + major: parts[0] || 0, + minor: parts[1] || 0, + patch: parts[2] || 0, + prerelease, + prereleaseNumber, + build, + }; + } catch (error) { + console.error(`[parseVersion] Failed to parse version: ${version}`, error); + return null; + } +} + +/** + * 比较两个版本号 + * @returns true 如果 latest > current + */ function isNewerVersion(latest: string, current: string): boolean { - // 去掉 v 前缀 - const cleanLatest = latest.replace(/^v/, ""); - const cleanCurrent = current.replace(/^v/, ""); - - const latestParts = cleanLatest.split(".").map(Number); - const currentParts = cleanCurrent.split(".").map(Number); - - for (let i = 0; i < Math.max(latestParts.length, currentParts.length); i++) { - const latestPart = latestParts[i] || 0; - const currentPart = currentParts[i] || 0; - if (latestPart > currentPart) return true; - if (latestPart < currentPart) return false; + console.log(`[isNewerVersion] Comparing: ${latest} vs ${current}`); + + const latestParsed = parseVersion(latest); + const currentParsed = parseVersion(current); + + if (!latestParsed || !currentParsed) { + console.warn("[isNewerVersion] Failed to parse versions, falling back to string comparison"); + return latest > current; + } + + console.log("[isNewerVersion] Parsed latest:", latestParsed); + console.log("[isNewerVersion] Parsed current:", currentParsed); + + // 比较主版本号 + if (latestParsed.major !== currentParsed.major) { + return latestParsed.major > currentParsed.major; } + + // 比较次版本号 + if (latestParsed.minor !== currentParsed.minor) { + return latestParsed.minor > currentParsed.minor; + } + + // 比较修订号 + if (latestParsed.patch !== currentParsed.patch) { + return latestParsed.patch > currentParsed.patch; + } + + // 如果版本号相同,比较预发布版本 + // 规则:正式版 > rc > beta > alpha + // 如果都是预发布版本,比较预发布号 + const prereleaseOrder: Record = { + alpha: 1, + beta: 2, + rc: 3, + }; + + const latestPrereleaseOrder = latestParsed.prerelease + ? prereleaseOrder[latestParsed.prerelease] || 0 + : 999; // 正式版最大 + const currentPrereleaseOrder = currentParsed.prerelease + ? prereleaseOrder[currentParsed.prerelease] || 0 + : 999; + + if (latestPrereleaseOrder !== currentPrereleaseOrder) { + return latestPrereleaseOrder > currentPrereleaseOrder; + } + + // 如果预发布类型相同,比较预发布号 + if (latestParsed.prerelease && currentParsed.prerelease) { + return latestParsed.prereleaseNumber > currentParsed.prereleaseNumber; + } + + // 版本完全相同 return false; } @@ -45,25 +151,32 @@ export function setupUpdateHandlers(win: BrowserWindow) { // 1. 检查更新 ipcMain.handle("update:check", async () => { try { + console.log("[Main] Starting update check..."); win.webContents.send("update:status", { status: "checking" }); const api = "https://api.github.com/repos/auto-plugin/milkup/releases/latest"; + console.log("[Main] Fetching from GitHub API:", api); const response = await net.fetch(api); if (!response.ok) { + console.error("[Main] GitHub API Error:", response.status, response.statusText); throw new Error(`GitHub API Error: ${response.status}`); } const data = await response.json(); const latestVersion = data.tag_name; + const currentVersion = app.getVersion(); + console.log("[Main] Latest version:", latestVersion, "Current version:", currentVersion); // 无论是否是新版本,都尝试寻找对应资源,方便调试(或者逻辑上只在新版本时找) - const isNew = isNewerVersion(latestVersion, app.getVersion()); + const isNew = isNewerVersion(latestVersion, currentVersion); + console.log("[Main] Is new version available:", isNew); if (isNew) { // 寻找对应平台的资源 const ext = getPlatformExtension(); if (!ext) { + console.error("[Main] Unsupported platform:", process.platform); throw new Error("Unsupported platform"); } @@ -102,6 +215,7 @@ export function setupUpdateHandlers(win: BrowserWindow) { } if (asset) { + console.log("[Main] Found asset:", asset.name); const updateInfo = { version: latestVersion, notes: data.body, @@ -116,8 +230,10 @@ export function setupUpdateHandlers(win: BrowserWindow) { currentUpdateInfo = updateInfo; // 缓存 update info 供下载使用 win.webContents.send("update:status", { status: "available", info: updateInfo }); + console.log("[Main] Update available, returning info"); return { updateInfo }; } else { + console.warn("[Main] No suitable asset found for platform:", process.platform); win.webContents.send("update:status", { status: "not-available", info: { reason: "no-asset" }, @@ -125,6 +241,7 @@ export function setupUpdateHandlers(win: BrowserWindow) { return null; } } else { + console.log("[Main] Already on latest version"); win.webContents.send("update:status", { status: "not-available" }); return null; } @@ -281,6 +398,7 @@ export function setupUpdateHandlers(win: BrowserWindow) { return downloadedFilePath; } catch (error: any) { if (error.name === "AbortError") { + console.log("[Main] Download aborted by user"); win.webContents.send("update:status", { status: "idle" }); return; } @@ -296,9 +414,17 @@ export function setupUpdateHandlers(win: BrowserWindow) { // 4. 取消下载 ipcMain.handle("update:cancel", () => { + console.log("[Main] Cancelling download..."); if (downloadAbortController) { downloadAbortController.abort(); downloadAbortController = null; + console.log("[Main] Download cancelled, status will be updated by download handler"); + // 不在这里发送状态更新,让下载函数的 catch 块处理 + // 这样可以避免重复发送事件 + } else { + console.log("[Main] No active download to cancel"); + // 如果没有活动的下载,确保状态是 idle + win.webContents.send("update:status", { status: "idle" }); } }); diff --git a/src/plugins/__internal__/get-id.ts b/src/plugins/__internal__/get-id.ts deleted file mode 100644 index b3b375a..0000000 --- a/src/plugins/__internal__/get-id.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { Node } from '@milkdown/prose/model' -import { customAlphabet } from 'nanoid' - -const nanoid = customAlphabet('abcdefg', 8) - -export const getId = (node?: Node) => node?.attrs?.identity || nanoid() diff --git a/src/plugins/__internal__/index.ts b/src/plugins/__internal__/index.ts deleted file mode 100644 index 9fff7c8..0000000 --- a/src/plugins/__internal__/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { serializeText } from './serialize-text' -export { withMeta } from './with-meta' diff --git a/src/plugins/__internal__/serialize-text.ts b/src/plugins/__internal__/serialize-text.ts deleted file mode 100644 index 6b12ade..0000000 --- a/src/plugins/__internal__/serialize-text.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Node } from '@milkdown/prose/model' -import type { SerializerState } from '@milkdown/transformer' - -import { Fragment } from '@milkdown/prose/model' - -export function serializeText(state: SerializerState, node: Node) { - const lastIsHardBreak - = node.childCount >= 1 && node.lastChild?.type.name === 'hardbreak' - if (!lastIsHardBreak) { - state.next(node.content) - return - } - - const contentArr: Node[] = [] - node.content.forEach((n, _, i) => { - if (i === node.childCount - 1) - return - - contentArr.push(n) - }) - state.next(Fragment.fromArray(contentArr)) -} diff --git a/src/plugins/__internal__/with-meta.ts b/src/plugins/__internal__/with-meta.ts deleted file mode 100644 index 7395182..0000000 --- a/src/plugins/__internal__/with-meta.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { Meta, MilkdownPlugin } from '@milkdown/kit/ctx' - -export function withMeta( - plugin: T, - meta: Partial & Pick, -): T { - Object.assign(plugin, { - meta: { - package: '@milkdown/preset-commonmark', - ...meta, - }, - }) - - return plugin -} diff --git a/src/plugins/customPastePlugin.ts b/src/plugins/customPastePlugin.ts deleted file mode 100644 index 4ec1cef..0000000 --- a/src/plugins/customPastePlugin.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { Uploader } from '@milkdown/kit/plugin/upload' -import type { Node, Schema } from '@milkdown/kit/prose/model' -import { uploadImage } from '@/renderer/services/api' - -export const uploader: Uploader = async (files, schema) => { - const images: File[] = [] - const pasteMethod = localStorage.getItem('pasteMethod') as 'local' | 'base64' | 'remote' - for (let i = 0; i < files.length; i++) { - const file = files.item(i) - if (!file) { - continue - } - - // You can handle whatever the file type you want, we handle image here. - if (!file.type.includes('image')) { - continue - } - - images.push(file) - } - const nodes: Node[] = [] - for (const image of images) { - if (pasteMethod === 'base64') { - const base64 = await turnToBase64(image) - nodes.push(schema.nodes.image.createAndFill({ src: base64, alt: image.name }) as Node) - continue - } - if (pasteMethod === 'remote') { - try { - await upload(image, nodes, schema) - } catch (error) { - console.error('Image upload failed:', error) - continue - } - } - if (pasteMethod === 'local') { - try { - await local(image, nodes, schema) - } catch (error) { - console.error('Local image handling failed:', error) - continue - } - } - } - return nodes -} -function turnToBase64(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onload = () => resolve(reader.result as string) - reader.onerror = error => reject(error) - reader.readAsDataURL(file) - }) -} -async function upload(image: File, nodes: Node[], schema: Schema) { - const src = await uploadImage(image) - nodes.push(schema.nodes.image.createAndFill({ src, alt: image.name }) as Node) -} -async function local(image: File, nodes: Node[], schema: Schema) { - const filePath = await window.electronAPI.getFilePathInClipboard() - if (filePath) { - nodes.push(schema.nodes.image.createAndFill({ src: filePath, alt: image.name }) as Node) - } else { - const arrayBuffer = await image.arrayBuffer() - const buffer = new Uint8Array(arrayBuffer) - // Convert Uint8Array to ArrayBuffer to satisfy the ArrayBufferLike parameter - const tempPath = await window.electronAPI.writeTempImage(buffer, localStorage.getItem('localImagePath') || '/temp') - nodes.push(schema.nodes.image.createAndFill({ src: tempPath, alt: image.name }) as Node) - } -} diff --git a/src/plugins/hybridHtmlPlugin/rawHtmlPlugin.ts b/src/plugins/hybridHtmlPlugin/rawHtmlPlugin.ts deleted file mode 100644 index 81baa16..0000000 --- a/src/plugins/hybridHtmlPlugin/rawHtmlPlugin.ts +++ /dev/null @@ -1,303 +0,0 @@ -import type { MilkdownPlugin } from '@milkdown/kit/ctx' -import type { Node as ProseNode } from '@milkdown/prose/model' -import type { EditorView, NodeViewConstructor } from '@milkdown/prose/view' -import type { RootContent } from 'mdast' -import type { Parent } from 'unist' -import { InputRule } from '@milkdown/prose/inputrules' -import { Plugin, Selection } from '@milkdown/prose/state' - -import { $inputRule, $nodeAttr, $nodeSchema, $prose, $remark } from '@milkdown/utils' -import { visit } from 'unist-util-visit' -import { withMeta } from '../__internal__' -import { processImagePaths, reverseProcessImagePaths } from '../imagePathPlugin' - -// 不转义 HTML 标签,即使输入
也能正常显示 -export const escapeAngleBracketRule = $inputRule( - () => new InputRule( - /<([^>]+)>(.*)<\/([^>]+)>$/, - (state, match, start, end) => { - const [text] = match - if (!text) - return null - - // 将匹配到的 文本转换为 HTML 节点 - const tr = state.tr - const nodeType = state.schema.nodes.html - const attrs = { value: text } - const node = nodeType.create(attrs, undefined, undefined) - tr.replaceWith(start, end, node) - // 设置光标位置在新插入的 HTML 节点后面 - const pos = start + node.nodeSize - tr.setSelection(Selection.near(tr.doc.resolve(pos))) - // 返回修改后的 transaction - return tr - }, - { inCode: false, inCodeMark: false }, // 禁止在代码块中使用 - ), -) - -export function createHtmlNodeView(): NodeViewConstructor { - return (node: ProseNode, view: EditorView, getPos) => { - let editing = false - const dom = document.createElement('div') - dom.classList.add('html-block') - dom.setAttribute('data-type', 'html') - dom.setAttribute('contenteditable', 'true') - - const contentDOM = document.createElement('div') - contentDOM.classList.add('html-source') - contentDOM.setAttribute('contenteditable', 'true') - - const rendered = document.createElement('div') - rendered.classList.add('html-rendered') - rendered.setAttribute('data-rendered', 'true') - rendered.innerHTML = node.attrs.value - - // 获取当前文件路径(用于路径转换) - const getCurrentFilePath = (): string | null => { - // 尝试从 DOM 中获取当前文件路径 - // 这里假设有一个全局的方式获取当前文件路径 - // 如果没有,可能需要通过其他方式传递 - try { - // 从 view 的 state 中尝试获取 - const editorElement = view.dom.closest('[data-file-path]') - if (editorElement) { - return editorElement.getAttribute('data-file-path') - } - // 或者从全局状态获取 - return (window as any).__currentFilePath || null - } catch { - return null - } - } - - // 切换到编辑状态 - const enterEdit = () => { - if (editing) - return - editing = true - - // 将 milkup:// 协议转回相对路径再显示 - const htmlContent = rendered.innerHTML - const filePath = getCurrentFilePath() - const restoredContent = reverseProcessImagePaths(htmlContent, filePath) - - // 更新编辑内容 - contentDOM.textContent = restoredContent - dom.innerHTML = '' - dom.appendChild(contentDOM) - - setTimeout(() => { - view.focus() - // ✅ 将光标移至最后 - const selection = window.getSelection() - const range = document.createRange() - - const lastTextNode = contentDOM.firstChild - if (lastTextNode) { - const len = lastTextNode.textContent?.length ?? 0 - range.setStart(lastTextNode, len) - range.setEnd(lastTextNode, len) - selection?.removeAllRanges() - selection?.addRange(range) - } - }, 0) - } - // 切换到渲染状态 - const exitEdit = () => { - if (!editing) - return - editing = false - - // 获取用户编辑的内容(包含相对路径) - const newValue = contentDOM.textContent - - // 将相对路径转换为 milkup:// 协议用于渲染 - const filePath = getCurrentFilePath() - const processedValue = processImagePaths(newValue || '', filePath) - - // 构造并派发 transaction,更新节点 attrs.value - // 注意:这里保存的是处理后的内容(包含 milkup://) - const tr = view.state.tr - tr.setNodeMarkup( - getPos() as number, // 节点位置 - undefined, // 保持类型不变 - { value: processedValue }, // 更新 attrs(使用处理后的内容) - ) - view.dispatch(tr) - - // 更新预览(使用处理后的内容) - rendered.innerHTML = processedValue || '' - dom.innerHTML = '' - dom.appendChild(rendered) - } - - // 点击进入编辑 - document.addEventListener('selectionchange', handleSelectionChange) - function handleSelectionChange() { - const selection = document.getSelection() - if (!selection || !selection.anchorNode) - return - - const anchorNode = selection.anchorNode - const inThisNode = dom.contains(anchorNode) - - if (inThisNode && !editing) { - requestAnimationFrame(() => enterEdit()) - } else if (!inThisNode && editing) { - requestAnimationFrame(() => exitEdit()) - } - } - // 初始状态是渲染状态 - dom.appendChild(rendered) - - return { - dom, - contentDOM, - update(updatedNode) { - if (updatedNode.type !== node.type) - return false - if (!editing) { - rendered.innerHTML = updatedNode.attrs.value - } - return true - }, - ignoreMutation: () => editing, - stopEvent: () => editing, - destroy() { - document.removeEventListener('selectionchange', handleSelectionChange) - }, - } - } -} - -export const proseHtml = $prose(() => { - return new Plugin({ - props: { - nodeViews: { - html: createHtmlNodeView(), - }, - handleKeyDown(view, event) { - const { selection } = view.state - const node = selection.$anchor.node() - if (node.type.name === 'html') { - if (event.key === 'Enter') { - // 在 html 节点内按下 Enter 键时,阻止默认行为,跳出当前段落,并在后面插入一个新的段落 - event.preventDefault() - const { tr } = view.state - const pos = selection.$anchor.end() - - tr.setSelection(Selection.near(tr.doc.resolve(pos + 1))) - view.dispatch(tr) - return true - } - } - - return false - }, - }, - - }) -}) -// remark AST 会把行内元素标签单独分开即 `text` 会被拆分成三个节点,这里将相邻的 HTML 开始标签、文本节点和结束标签合并为一个 HTML 节点 -export const remarkHtmlMerger = $remark('remarkHtmlMerger', () => () => (tree) => { - visit(tree, (_node, _index, parent: Parent | undefined) => { - if (!parent || !Array.isArray(parent.children)) - return - - const children = parent.children - - for (let i = 0; i < children.length - 2; i++) { - const open = children[i] as any - const text = children[i + 1] as any - const close = children[i + 2] as any - - if ( - open.type === 'html' - && text.type === 'text' - && close.type === 'html' - ) { - const openTagMatch = open.value.match(/^<([a-z][\w\-]*)(?:\s[^<>]*)?>$/i) - const closeTagMatch = close.value.match(/^<\/([a-z][\w\-]*)>$/i) - - if ( - openTagMatch - && closeTagMatch - && openTagMatch[1] === closeTagMatch[1] - ) { - // 合并为一个新的 html 节点 - const combinedNode: RootContent = { - type: 'html', - value: `${open.value}${text.value}${close.value}`, - } - - children.splice(i, 3, combinedNode) - } - } - } - }) -}) -// 将紧邻的 HTML 节点的文本节点拆分开来 -export const remarkHtmlSplitter = $remark('remarkHtmlSplitter', () => () => (tree) => { - visit(tree, (node, index, parent) => { - if (!parent || !('type' in node) || (node as any).type !== 'html') - return - if (!('children' in parent)) - return - - const value = (node as any).value as string - const htmlMatch = value.match(/^<([a-z][a-z0-9]*)\b[^>]*>[\s\S]*<\/\1>/i) - if (!htmlMatch) - return - - const htmlPart = htmlMatch[0] - const rest = value.slice(htmlPart.length).trim() - const newHtmlNode: RootContent = { - type: 'html', - value: htmlPart, - } - const newTextNode: RootContent | null = rest - ? { type: 'text', value: rest } - : null - const children = (parent as any).children - const newNodes: RootContent[] = [newHtmlNode] - if (newTextNode) - newNodes.push(newTextNode) - children.splice(index!, 1, ...newNodes) - }) -}) - -export const htmlAttr = $nodeAttr('html') - -withMeta(htmlAttr, { - displayName: 'Attr', - group: 'Html', -}) -export const htmlSchema = $nodeSchema('html', (ctx) => { - ctx.inject(htmlAttr.key) - return { - inline: true, - code: true, - group: 'inline', - content: 'text*', - attrs: { - value: { - default: '', - validate: 'string', - }, - }, - parseMarkdown: { - match: ({ type }) => Boolean(type === 'html'), - runner: (state, node, type) => { - state.addNode(type, { value: (node.value as string) }) - }, - }, - toMarkdown: { - match: node => node.type.name === 'html', - runner: (state, node) => { - state.addNode('html', undefined, node.attrs.value) - }, - }, - } -}) -export const htmlPlugin: MilkdownPlugin[] = [htmlSchema, remarkHtmlMerger, remarkHtmlSplitter, proseHtml, escapeAngleBracketRule].flat() diff --git a/src/plugins/imagePathPlugin.ts b/src/plugins/imagePathPlugin.ts index 57f4d26..315ed12 100644 --- a/src/plugins/imagePathPlugin.ts +++ b/src/plugins/imagePathPlugin.ts @@ -2,118 +2,3 @@ export function setCurrentMarkdownFilePath(_filePath: string | null) { // 不再需要存储全局路径 } - -/** - * 浏览器兼容的 base64 编码 - */ -function encodeBase64(str: string): string { - // 使用浏览器的 btoa,但需要先处理 Unicode 字符 - return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_, p1) => { - return String.fromCharCode(Number.parseInt(p1, 16)) - })) -} - -/** - * 检查是否为绝对路径(浏览器兼容版本) - */ -function isAbsolutePath(filePath: string): boolean { - // Windows: C:\, D:\, \\server\share - // Unix: /path - return /^(?:[a-z]:[\\/]|\\\\|\/)/i.test(filePath) -} - -/** - * 处理 Markdown 中的图片路径 - * 使用自定义协议 milkup:// 来加载本地图片,避免修改源文件 - * - * @param markdownContent - Markdown 内容 - * @param markdownFilePath - Markdown 文件的绝对路径 - * @returns 处理后的 Markdown 内容(仅在渲染时使用) - */ -export function processImagePaths(markdownContent: string, markdownFilePath: string | null): string { - if (!markdownFilePath) { - return markdownContent - } - - // 将 Markdown 文件路径编码为 base64 - const base64Path = encodeBase64(markdownFilePath) - - let processedContent = markdownContent - - // 1. 处理 Markdown 图片语法: ![alt](path) - const markdownImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g - processedContent = processedContent.replace(markdownImageRegex, (match, alt, imagePath) => { - const convertedPath = convertToProtocolUrl(imagePath, base64Path) - return convertedPath ? `![${alt}](${convertedPath})` : match - }) - - // 2. 处理 HTML img 标签: - // 修复:保留原始引号风格 - const htmlImageRegex = /]*?)src=(["'])([^"']+)\2([^>]*)>/gi - processedContent = processedContent.replace(htmlImageRegex, (match, before, quote, imagePath, after) => { - const convertedPath = convertToProtocolUrl(imagePath, base64Path) - if (!convertedPath) - return match - // 保持原有的引号风格 - return `` - }) - - return processedContent -} - -/** - * 将相对路径转换为自定义协议 URL - * @param imagePath - 图片路径 - * @param base64Path - base64 编码的 markdown 文件路径 - * @returns 转换后的协议 URL,如果不需要转换则返回 null - */ -function convertToProtocolUrl(imagePath: string, base64Path: string): string | null { - // 只处理相对路径 - // 跳过: HTTP(S) URL, file:// 协议, data: URI, milkup:// 协议, 绝对路径 - if ( - imagePath.startsWith('http://') - || imagePath.startsWith('https://') - || imagePath.startsWith('file://') - || imagePath.startsWith('data:') - || imagePath.startsWith('milkup://') - || isAbsolutePath(imagePath) - ) { - return null - } - - // 相对路径转换为自定义协议 URL - // 格式: milkup://// - return `milkup:///${base64Path}/${imagePath}` -} - -/** - * 反向处理:将 milkup:// 协议 URL 转回相对路径 - * 用于保存文件时恢复原始路径格式 - * - * @param content - 包含 milkup:// 协议的内容 - * @param markdownFilePath - Markdown 文件的绝对路径 - * @returns 恢复相对路径后的内容 - */ -export function reverseProcessImagePaths(content: string, markdownFilePath: string | null): string { - if (!markdownFilePath) { - return content - } - - const base64Path = encodeBase64(markdownFilePath) - let restoredContent = content - - // 1. 恢复 Markdown 图片语法中的路径 - const markdownProtocolRegex = new RegExp(`!\\[([^\\]]*)\\]\\(milkup:///${base64Path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/([^)]+)\\)`, 'g') - restoredContent = restoredContent.replace(markdownProtocolRegex, (_, alt, relativePath) => { - return `![${alt}](${relativePath})` - }) - - // 2. 恢复 HTML img 标签中的路径 - // 修复:支持恢复任意引号风格 - const htmlProtocolRegex = new RegExp(`]*?)src=(["'])milkup:///${base64Path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/([^"']+)\\2([^>]*?)>`, 'gi') - restoredContent = restoredContent.replace(htmlProtocolRegex, (_, before, quote, relativePath, after) => { - return `` - }) - - return restoredContent -} diff --git a/src/plugins/laxImagePlugin.ts b/src/plugins/laxImagePlugin.ts deleted file mode 100644 index af4f5dd..0000000 --- a/src/plugins/laxImagePlugin.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { InputRule, inputRules } from "@milkdown/prose/inputrules"; -import { Plugin } from "@milkdown/prose/state"; -import { $prose } from "@milkdown/utils"; - -// 匹配Markdown图片语法的正则:![alt](src) -// 允许 src 中包含空格 -const wrappingImageRegex = /!\[([^\]]*)\]\(([^)]+)\)$/; - -export const laxImageInputRule = $prose((_ctx) => { - return inputRules({ - rules: [ - new InputRule(wrappingImageRegex, (state, match, start, end) => { - console.log("[Debug] laxImageInputRule triggered", match); - const [_, alt, src] = match; - const { tr } = state; - - if (!src) return null; - - // 将 src 中的空格替换为 %20,确保 Milkdown 能正确解析 - const encodedSrc = src.replace(/ /g, "%20"); - console.log("[Debug] Encoded src:", encodedSrc); - - // 创建图片节点 - const node = state.schema.nodes.image.create({ - src: encodedSrc, - alt, - }); - - tr.replaceWith(start, end, node); - return tr; - }), - ], - }); -}); - -export const laxImagePastePlugin = $prose((_ctx) => { - return new Plugin({ - props: { - handlePaste: (view, event, _slice) => { - console.log("[Debug] handlePaste triggered"); - const text = event.clipboardData?.getData("text/plain"); - if (!text) { - console.log("[Debug] No text/plain data in clipboard"); - return false; - } - - console.log("[Debug] handlePaste text:", text); - - // 放宽正则:使用 g 标志匹配所有 - const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; - - // 检查是否有匹配项,并且这些匹配项中是否存在带空格的路径 - let match; - let hasMatch = false; - - // 我们需要决定是否拦截。如果全是图片且包含带空格的,就拦截。 - // 为了简单起见,只要文本看起来主要是图片语法,我们就尝试接管。 - // 或者,由于我们只想修复空格问题,我们检测是否存在带空格的图片语法。 - - if (imageRegex.test(text)) { - // 重置正则索引 - imageRegex.lastIndex = 0; - - // 构建新的内容插入 - // 注意:这里我们只能处理纯文本插入。如果不完全匹配(例如包含其他文字),简单的替换可能不够。 - // 但如果用户粘贴的是纯图片语法,我们可以直接构造节点。 - - // 简单策略:如果整个粘贴内容就是一张图片,且带空格,我们接管。 - const singleImageRegex = /^!\[([^\]]*)\]\(([^)]+)\)$/; - const singleMatch = text.trim().match(singleImageRegex); - - if (singleMatch) { - const [_, alt, src] = singleMatch; - if (src && src.includes(" ")) { - console.log("[Debug] Intercepting single image paste with space:", src); - const encodedSrc = src.replace(/ /g, "%20"); - const node = view.state.schema.nodes.image.create({ - src: encodedSrc, - alt, - }); - const tr = view.state.tr.replaceSelectionWith(node); - view.dispatch(tr); - return true; - } - // 如果不带空格,也可以接管以确保一致性,或者放行 - console.log( - "[Debug] Single image paste without space, letting default handler work (or intercepting for consistency)" - ); - // 这里我们也接管,防止其他插件处理不当 - const encodedSrc = src.replace(/ /g, "%20"); - const node = view.state.schema.nodes.image.create({ - src: encodedSrc, - alt, - }); - const tr = view.state.tr.replaceSelectionWith(node); - view.dispatch(tr); - return true; - } - } - - console.log("[Debug] Not intercepted by laxImagePastePlugin"); - return false; - }, - }, - }); -}); diff --git a/src/plugins/mermaidPlugin/index.ts b/src/plugins/mermaidPlugin/index.ts deleted file mode 100644 index dcaee8b..0000000 --- a/src/plugins/mermaidPlugin/index.ts +++ /dev/null @@ -1,230 +0,0 @@ -import type { MilkdownPlugin } from "@milkdown/kit/ctx"; -import type { MermaidConfig as MermaidLibConfig } from "mermaid"; -import "./style.css"; // Import styles - -import { LanguageDescription } from "@codemirror/language"; -import { codeBlockConfig } from "@milkdown/kit/component/code-block"; -import { commandsCtx } from "@milkdown/kit/core"; -import { $command, $ctx } from "@milkdown/kit/utils"; -import { - addBlockTypeCommand, - clearTextInCurrentBlockCommand, - codeBlockSchema, -} from "@milkdown/preset-commonmark"; -import mermaid from "mermaid"; -import { mermaid as mermaidLang } from "codemirror-lang-mermaid"; - -// ============ 类型定义 ============ - -/** 插件配置 */ -export interface MermaidConfig { - /** mermaid 初始化配置 */ - mermaidOptions: MermaidLibConfig; - /** 渲染中占位符文本 */ - placeholder: string; - /** 错误时显示的前缀 */ - errorPrefix: string; -} - -/** 默认配置 */ -const defaultMermaidConfig: MermaidConfig = { - mermaidOptions: { - startOnLoad: false, - theme: "default", - securityLevel: "loose", - }, - placeholder: "渲染中...", - errorPrefix: "Mermaid Error: ", -}; - -// ============ 配置 Slice ============ - -export const mermaidConfig = $ctx(defaultMermaidConfig, "mermaidConfigCtx"); - -/** - * 合并配置的工具函数 - */ -export function mergeMermaidConfig( - options: Partial -): (prev: MermaidConfig) => MermaidConfig { - return (prev) => ({ - ...prev, - ...options, - mermaidOptions: options.mermaidOptions - ? { ...prev.mermaidOptions, ...options.mermaidOptions } - : prev.mermaidOptions, - }); -} - -// ============ 图标 ============ - -export const mermaidIcon = ``; - -// ============ 渲染函数 ============ - -/** - * 渲染 Mermaid 图表 - * 返回带占位符的 HTML,异步更新为 SVG - */ -function renderMermaid(content: string, config: MermaidConfig): string { - try { - const id = `mermaid-${Date.now()}-${Math.random().toString(36).slice(2)}`; - - mermaid - .render(id, content) - .then(({ svg }) => { - const previewEl = document.querySelector(`[data-mermaid-id="${id}"]`); - if (previewEl) { - previewEl.innerHTML = svg; - } - }) - .catch((err) => { - const previewEl = document.querySelector(`[data-mermaid-id="${id}"]`); - if (previewEl) { - previewEl.innerHTML = `
${config.errorPrefix}${err.message}
`; - } - }) - .finally(() => { - // 清理 mermaid 渲染时创建的临时元素 - // mermaid.render 会在 body 中创建 id 为 "d" + id 的容器 - const tempContainer = document.getElementById(`d${id}`); - if (tempContainer) { - tempContainer.remove(); - } - // 也清理可能残留的 SVG 元素 - const tempSvg = document.getElementById(id); - if (tempSvg && !tempSvg.closest(`[data-mermaid-id="${id}"]`)) { - tempSvg.remove(); - } - }); - - return `
${config.placeholder}
`; - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - return `
${config.errorPrefix}${message}
`; - } -} - -// ============ CodeMirror 语言 ============ - -/** - * 创建 Mermaid 语言描述 - */ -function createMermaidLanguage(): LanguageDescription { - return LanguageDescription.of({ - name: "Mermaid", - alias: ["mermaid", "diagram", "flowchart"], - extensions: [".mmd"], - load: async () => { - return mermaidLang(); - }, - }); -} - -// ============ 命令 ============ - -/** - * 插入 Mermaid 代码块命令 - */ -export const insertMermaidCommand = $command("insertMermaid", (ctx) => () => { - return (_state, _dispatch) => { - const commands = ctx.get(commandsCtx); - commands.call(clearTextInCurrentBlockCommand.key); - const codeBlock = codeBlockSchema.type(ctx); - commands.call(addBlockTypeCommand.key, { - nodeType: codeBlock, - attrs: { language: "mermaid" }, - }); - return true; - }; -}); - -// ============ 斜杠菜单项配置 ============ - -/** - * Mermaid 斜杠菜单项配置 - * 可直接用于 registry.registerItem() - */ -export const mermaidSlashMenuItem = { - id: "mermaid", - label: "流程图", - icon: mermaidIcon, - keywords: [ - "mermaid", - "diagram", - "chart", - "flow", - "flowchart", - "sequence", - "gantt", - "流程图", - "图表", - "时序图", - ], - action: (ctx: unknown) => { - const commands = (ctx as { get: (key: unknown) => { call: (key: unknown) => void } }).get( - commandsCtx - ); - commands.call(insertMermaidCommand.key); - }, -}; - -// ============ 初始化插件 ============ - -/** - * 清理残留的 mermaid 临时元素 - */ -function cleanupMermaidElements() { - // 清理所有以 "dmermaid-" 开头的容器(mermaid 渲染时创建的) - document.querySelectorAll('[id^="dmermaid-"]').forEach((el) => el.remove()); - // 清理所有不在预览容器内的 mermaid SVG - document.querySelectorAll('svg[id^="mermaid-"]').forEach((el) => { - if (!el.closest("[data-mermaid-id]")) { - el.remove(); - } - }); -} - -/** - * 初始化插件 - * 配置 mermaid 和 codeBlockConfig - */ -const mermaidInitPlugin: MilkdownPlugin = (ctx) => async () => { - await Promise.resolve(); - - const config = ctx.get(mermaidConfig.key); - - // 清理可能残留的元素 - cleanupMermaidElements(); - - // 初始化 mermaid - mermaid.initialize(config.mermaidOptions); - - // 配置代码块 - ctx.update(codeBlockConfig.key, (prev) => ({ - ...prev, - languages: [...(prev.languages || []), createMermaidLanguage()], - renderPreview: (language: string, content: string, applyPreview: unknown) => { - if ( - typeof language === "string" && - language.toLowerCase() === "mermaid" && - content.length > 0 - ) { - return renderMermaid(content, config); - } - const renderPreview = prev.renderPreview as - | ((lang: string, content: string, apply: unknown) => string | undefined) - | undefined; - return renderPreview?.(language, content, applyPreview); - }, - })); -}; - -// ============ 导出插件 ============ - -/** - * Mermaid 插件 (Renamed to 'diagram' for compatibility with existing imports) - */ -export const diagram: MilkdownPlugin[] = [mermaidConfig, insertMermaidCommand, mermaidInitPlugin]; - -export default diagram; diff --git a/src/plugins/mermaidPlugin/node.ts b/src/plugins/mermaidPlugin/node.ts deleted file mode 100644 index 76006c8..0000000 --- a/src/plugins/mermaidPlugin/node.ts +++ /dev/null @@ -1,157 +0,0 @@ -import type { MermaidConfig } from 'mermaid' -import { expectDomTypeError } from '@milkdown/exception' -import { setBlockType } from '@milkdown/prose/commands' -import { InputRule } from '@milkdown/prose/inputrules' -import { - $command, - $ctx, - $inputRule, - $nodeSchema, - $remark, -} from '@milkdown/utils' -import mermaid from 'mermaid' - -import { nextTick } from 'vue' -import { getId } from '../__internal__/get-id' -import { withMeta } from '../__internal__/with-meta' -import { remarkMermaid } from './remark-mermaid' - -/// A slice that contains [options for mermaid](https://mermaid.js.org/config/setup/modules/config.html). -/// You can configure mermaid here. -/// ```ts -/// import { mermaidConfigCtx } from '@milkdown/plugin-diagram' -/// -/// Editor.make() -/// .config((ctx) => { -/// ctx.set(mermaidConfigCtx.key, { /* some options */ }); -/// }) -/// ``` -export const mermaidConfigCtx = $ctx( - { startOnLoad: true }, - 'mermaidConfig', -) - -withMeta(mermaidConfigCtx, { - displayName: 'Ctx', -}) - -const id = 'diagram' -/// Schema for diagram node. -export const diagramSchema = $nodeSchema(id, (ctx) => { - mermaid.initialize({ - ...ctx.get(mermaidConfigCtx.key), - }) - return { - content: 'text*', - group: 'block', - marks: '', - defining: true, - atom: true, - isolating: true, - attrs: { - value: { - default: '', - }, - identity: { - default: '', - }, - }, - parseDOM: [ - { - tag: `div[data-type="${id}"]`, - preserveWhitespace: 'full', - getAttrs: (dom) => { - if (!(dom instanceof HTMLElement)) - throw expectDomTypeError(dom) - - return { - value: dom.dataset.value, - identity: dom.dataset.id, - } - }, - }, - ], - toDOM: (node) => { - const identity = getId(node) - const code = node.attrs.value as string - - const dom = document.createElement('div') - dom.dataset.type = id - dom.dataset.id = identity - dom.dataset.value = code - dom.textContent = code - dom.className = 'mermaid' - nextTick(() => { - mermaid.run() - }) - - return dom - }, - parseMarkdown: { - match: ({ type }) => type === id, - runner: (state, node, type) => { - const value = node.value as string - state.addNode(type, { value, identity: getId() }) - }, - }, - toMarkdown: { - match: node => node.type.name === id, - runner: (state, node) => { - state.addNode('code', undefined, node.attrs.value || '', { - lang: 'mermaid', - }) - }, - }, - } -}) - -withMeta(diagramSchema.node, { - displayName: 'NodeSchema', -}) -withMeta(diagramSchema.ctx, { - displayName: 'NodeSchemaCtx', -}) - -/// A input rule that will insert a diagram node when you type ` ```mermaid `. -export const insertDiagramInputRules = $inputRule( - ctx => - new InputRule(/^```mermaid$/, (state, _match, start, end) => { - const nodeType = diagramSchema.type(ctx) - const $start = state.doc.resolve(start) - if ( - !$start - .node(-1) - .canReplaceWith($start.index(-1), $start.indexAfter(-1), nodeType) - ) { - return null - } - return state.tr - .delete(start, end) - .setBlockType(start, start, nodeType, { identity: getId() }) - }), -) - -withMeta(insertDiagramInputRules, { - displayName: 'InputRule', -}) - -/// A remark plugin that will parse mermaid code block. -export const remarkDiagramPlugin = $remark('remarkMermaid', () => remarkMermaid) - -withMeta(remarkDiagramPlugin.plugin, { - displayName: 'Remark', -}) - -withMeta(remarkDiagramPlugin.options, { - displayName: 'RemarkConfig', -}) - -/// A command that will insert a diagram node. -export const insertDiagramCommand = $command( - 'InsertDiagramCommand', - ctx => () => setBlockType(diagramSchema.type(ctx), { identity: getId() }), -) - -withMeta(insertDiagramCommand, { - displayName: 'Command', -}) diff --git a/src/plugins/mermaidPlugin/remark-mermaid.ts b/src/plugins/mermaidPlugin/remark-mermaid.ts deleted file mode 100644 index c912106..0000000 --- a/src/plugins/mermaidPlugin/remark-mermaid.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { Node } from '@milkdown/transformer' -import { visit } from 'unist-util-visit' - -function createMermaidDiv(contents: string) { - return { - type: 'diagram', - value: contents, - } -} - -function visitCodeBlock(ast: Node) { - return visit( - ast, - 'code', - (node, index, parent: Node & { children: Node[] }) => { - const { lang, value } = node - - // If this codeblock is not mermaid, bail. - if (lang !== 'mermaid') - return node - - const newNode = createMermaidDiv(value) - - if (parent && index != null) - parent.children.splice(index, 1, newNode) - - return node - }, - ) -} - -export function remarkMermaid() { - function transformer(tree: Node) { - visitCodeBlock(tree) - } - return transformer -} diff --git a/src/plugins/mermaidPlugin/style.css b/src/plugins/mermaidPlugin/style.css deleted file mode 100644 index 0cbfc2e..0000000 --- a/src/plugins/mermaidPlugin/style.css +++ /dev/null @@ -1,41 +0,0 @@ -/* Mermaid 预览样式 */ -.mermaid-preview { - display: flex; - justify-content: center; - align-items: center; - padding: 16px; - min-height: 100px; - color: #6b7280; - font-size: 14px; -} - -.mermaid-preview svg { - max-width: 100%; - height: auto; -} - -/* 错误提示样式 */ -.mermaid-error { - color: #ef4444; - font-size: 12px; - padding: 8px 12px; - background: #fef2f2; - border: 1px solid #fecaca; - border-radius: 4px; - white-space: pre-wrap; - word-break: break-word; - max-width: 100%; -} - -/* 暗色主题支持 */ -@media (prefers-color-scheme: dark) { - .mermaid-preview { - color: #9ca3af; - } - - .mermaid-error { - background: #451a1a; - border-color: #7f1d1d; - color: #fca5a5; - } -} diff --git a/src/preload.ts b/src/preload.ts index d9c6658..58c3a59 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -4,8 +4,8 @@ import { contextBridge, ipcRenderer, webUtils } from "electron"; contextBridge.exposeInMainWorld("electronAPI", { openFile: () => ipcRenderer.invoke("dialog:openFile"), getIsReadOnly: (filePath: string) => ipcRenderer.invoke("file:isReadOnly", filePath), - saveFile: (filePath: string | null, content: string) => - ipcRenderer.invoke("dialog:saveFile", { filePath, content }), + saveFile: (filePath: string | null, content: string, fileTraits?: any) => + ipcRenderer.invoke("dialog:saveFile", { filePath, content, fileTraits }), saveFileAs: (content: string) => ipcRenderer.invoke("dialog:saveFileAs", content), on: (channel: string, listener: (...args: any[]) => void) => ipcRenderer.on(channel, (_event, ...args) => listener(...args)), @@ -16,7 +16,9 @@ contextBridge.exposeInMainWorld("electronAPI", { windowControl: (action: "minimize" | "maximize" | "close") => ipcRenderer.send("window-control", action), closeDiscard: () => ipcRenderer.send("close:discard"), - onOpenFileAtLaunch: (cb: (payload: { filePath: string; content: string }) => void) => { + onOpenFileAtLaunch: ( + cb: (payload: { filePath: string; content: string; fileTraits?: any }) => void + ) => { ipcRenderer.on("open-file-at-launch", (_event, payload) => { cb(payload); }); diff --git a/src/renderer/App.vue b/src/renderer/App.vue index 6289b01..d0120bc 100644 --- a/src/renderer/App.vue +++ b/src/renderer/App.vue @@ -1,5 +1,4 @@ @@ -14,21 +20,16 @@ function handleRealod() {
-

- 请确保所有工作已经保存! -

+

请确保所有工作已经保存!

更新语言设置需要重启应用后生效
diff --git a/src/renderer/components/dialogs/UpdateConfirmDialog.vue b/src/renderer/components/dialogs/UpdateConfirmDialog.vue index 6a682d4..8ab44f9 100644 --- a/src/renderer/components/dialogs/UpdateConfirmDialog.vue +++ b/src/renderer/components/dialogs/UpdateConfirmDialog.vue @@ -1,6 +1,9 @@ - - - - diff --git a/src/renderer/components/editor/MilkupEditor.vue b/src/renderer/components/editor/MilkupEditor.vue new file mode 100644 index 0000000..11267f1 --- /dev/null +++ b/src/renderer/components/editor/MilkupEditor.vue @@ -0,0 +1,407 @@ + + + + + + + diff --git a/src/renderer/components/menu/MenuBar.vue b/src/renderer/components/menu/MenuBar.vue index 3718981..5b9408b 100644 --- a/src/renderer/components/menu/MenuBar.vue +++ b/src/renderer/components/menu/MenuBar.vue @@ -1,44 +1,69 @@ - diff --git a/src/renderer/components/settings/SpellCheckSetter.vue b/src/renderer/components/settings/SpellCheckSetter.vue index e415832..20ab1fb 100644 --- a/src/renderer/components/settings/SpellCheckSetter.vue +++ b/src/renderer/components/settings/SpellCheckSetter.vue @@ -1,28 +1,22 @@ diff --git a/src/renderer/components/settings/ThemeEditor.vue b/src/renderer/components/settings/ThemeEditor.vue index 8ba6485..3f8edca 100644 --- a/src/renderer/components/settings/ThemeEditor.vue +++ b/src/renderer/components/settings/ThemeEditor.vue @@ -1,133 +1,140 @@