11 KiB
AI 写作 · 右侧文本编辑器(滑词 / 段落菜单)
说明 AI 写作页、开放画布中文稿右侧编辑器的实现方式:编辑器本体基于 Tiptap v3;选中文本浮层(「滑词」)与段落悬停按钮为自研 React 组件,未使用
@tiptap/extension-bubble-menu官方 BubbleMenu。
一、功能概览
| 交互 | 用户感知 | 组件 | 触发方式 |
|---|---|---|---|
| 滑词 | 拖选一段文字后,选区上方出现工具条(AI 改写、加粗、斜体、下划线、H1、复制) | WritingBubbleMenu |
Tiptap selectionUpdate + 浏览器 window.getSelection() |
| 段落菜单 | 鼠标移入某段/标题/引用块时,段落左侧出现 ✨ 按钮,点击可 AI 改写整段 | WritingParagraphMenu |
编辑器 DOM mousemove + ProseMirror posAtCoords |
| AI 改写面板 | 点击「AI 改写」后弹出润色/扩写等操作与预览 | WritingAiMenu / WritingRewritePreview |
由 TextArtifactRenderer + useWritingRewrite 串联 |
画布线程页(/page/canvas/:thread_id)与 AI 写作页(/page/workspace/ai-writing)右侧草稿共用同一套 TextArtifactRenderer 与菜单组件。
二、在 AI 写作中的挂载路径
flowchart TB
AIWritingPage --> CanvasResizableLayout
CanvasResizableLayout --> AIWritingDraftPanel
AIWritingDraftPanel --> DraftStreamingPreview
AIWritingDraftPanel --> TextArtifactRenderer
DraftStreamingPreview["DraftStreamingPreview<br/>(流式只读 Markdown)"]
TextArtifactRenderer --> WritingBubbleMenu
TextArtifactRenderer --> WritingParagraphMenu
TextArtifactRenderer --> TiptapEditor
TextArtifactRenderer --> WritingAiMenu
| 阶段 | 右侧展示 | 滑词 / 段落菜单 |
|---|---|---|
草稿流式生成中(writing_draft / revising 且有 section 流) |
DraftStreamingPreview(ReactMarkdown 只读) |
不可用 |
成稿可编辑(isDraftEditable === true) |
TextArtifactRenderer + Tiptap |
可用 |
| 流式结束但尚未 editable | TextArtifactRenderer 且 isStreaming={true} |
不可用(editable: false) |
相关状态由 CanvasUIContext.tsx 的 isDraftStreaming、isDraftEditable 控制;注释中「禁用编辑/润色/滑词」即指生成完成前不进入可编辑渲染器。
入口文件:
- AIWritingDraftPanel.tsx — 决定流式预览 vs
TextArtifactRenderer - TextArtifactRenderer.tsx — 挂载编辑器与两套浮层菜单
三、技术栈:编辑器 vs 浮层
3.1 Tiptap 编辑器(有使用)
| 包 | 用途 |
|---|---|
@tiptap/react |
useEditor、EditorContent |
@tiptap/starter-kit |
段落、标题、加粗、斜体、下划线等 |
@tiptap/extension-placeholder |
空文档占位符 |
tiptap-markdown |
Markdown ↔ 文档互转 |
自研 AiHighlightExtension |
AI 改写前高亮选区(ProseMirror Decoration) |
扩展注册见 TiptapExtensions.ts;实例创建见 useWritingEditor.ts。
3.2 浮层菜单(自研,非 Tiptap 官方插件)
| 包 | 是否用于滑词/段落菜单 |
|---|---|
@tiptap/extension-bubble-menu |
未使用(package.json 中有依赖,代码未 import) |
@tiptap/react 的 <BubbleMenu> |
未使用 |
两套菜单均采用:
- 监听 Tiptap / DOM 事件更新位置与选区;
createPortal(..., document.body)挂到document.body,避免被右侧滚动容器overflow裁切;position: fixed+getBoundingClientRect()定位。
命名上的 WritingBubbleMenu 仅表示「气泡式选区工具条」,实现上与 Tiptap 官方 Bubble Menu 扩展无关。
四、核心文件索引
| 文件 | 职责 |
|---|---|
| TiptapEditor.tsx | EditorContent 渲染容器 |
| TiptapExtensions.ts | StarterKit、Placeholder、Markdown、AiHighlight |
| AiHighlightExtension.ts | setAiHighlight / clearAiHighlight 命令 |
| useWritingEditor.ts | 创建 editor、同步外部 markdown、editable |
| WritingBubbleMenu.tsx | 滑词 选区浮层 |
| WritingParagraphMenu.tsx | 段落 左侧 ✨ 按钮 |
| WritingAiMenu.tsx | AI 改写操作菜单 |
| WritingRewritePreview.tsx | 改写结果预览 |
| useWritingRewrite.ts | 调用润色 API、写回编辑器 |
| apply-rewrite.ts | 将改写结果应用到 ProseMirror 选区 |
| CanvasUIContext.tsx | writingSelection 全局状态 |
| selection.ts | WritingSelection 类型定义 |
五、滑词:WritingBubbleMenu
5.1 行为
- 用户在编辑器内拖选非空文本时显示浮层;选区为空或位于
codeBlock内时隐藏。 - 浮层按钮:
AI 改写、B/I/U、H1、复制。 - 按钮
onMouseDown使用e.preventDefault(),避免点击时编辑器失焦导致选区丢失。
5.2 实现要点
editor.on("selectionUpdate")
→ 读取 editor.state.selection (from, to, empty)
→ window.getSelection() + Range.getBoundingClientRect() 算屏幕坐标
→ setWritingSelection({ from, to, text }) 写入 CanvasUIContext
→ createPortal 渲染工具条,useLayoutEffect 做贴边 / 上方空间不足时翻到选区下方
关键代码位置:WritingBubbleMenu.tsx 中 useEffect 监听 selectionUpdate 与 blur。
点击「AI 改写」时:
editor.commands.setAiHighlight(from, to)高亮选区;- 调用父组件传入的
onAiRewrite(),由TextArtifactRenderer打开WritingAiMenu。
六、段落菜单:WritingParagraphMenu
6.1 行为
- 鼠标在编辑器内移动时,检测光标下的块级节点;
- 仅对
paragraph、heading、blockquote且文本非空时,在块左侧显示 ✨ 按钮; - 鼠标离开编辑器或移出可改写块后,延迟约 380ms 隐藏(
HIDE_DELAY),并在按钮与正文之间留有 hover 桥接区,避免移向按钮时闪烁消失。
6.2 实现要点
editor.view.dom mousemove
→ editor.view.posAtCoords({ left, top })
→ doc.resolve(pos).before(depth) 定位块起点
→ editor.view.nodeDOM(nodeStart) 取 DOM 矩形
→ createPortal 固定定位按钮在块左侧 (BTN_SIZE=24, BTN_GAP=6)
点击 ✨ 时:
setTextSelection选中整段 ProseMirror 范围(nodeFrom~nodeTo);setAiHighlight+setWritingSelection;- 触发与滑词相同的
onAiRewrite()流程。
关键代码位置:WritingParagraphMenu.tsx。
七、AI 改写数据流
sequenceDiagram
participant User
participant Menu as BubbleMenu / ParagraphMenu
participant Ctx as CanvasUIContext
participant Renderer as TextArtifactRenderer
participant API as rewrite API
User->>Menu: 选中文本或点段落 ✨
Menu->>Ctx: setWritingSelection
Menu->>Renderer: onAiRewrite()
Renderer->>Renderer: phase = menu, WritingAiMenu
User->>Renderer: 选择润色/扩写等
Renderer->>API: useWritingRewrite.rewrite
API-->>Renderer: 流式结果
Renderer->>Renderer: WritingRewritePreview
User->>Renderer: 接受改写
Renderer->>Renderer: applyReplace / applyInsert
- 选区信息类型:
WritingSelection(from、to、text),见 selection.ts。 - 与画布聊天侧选区
CanvasTextSelection分离;后者用于快捷操作栏等,不用于 Tiptap 改写。
八、与顶部工具栏、目录栏的关系
同一 TextArtifactRenderer 编辑模式下还包含:
| 组件 | 作用 |
|---|---|
| WritingToolbar.tsx | 顶部固定格式栏、导出等 |
| DraftOutlineSidebar.tsx | 左侧标题目录,点击滚动到对应 heading |
滑词 / 段落菜单与工具栏互不替代:工具栏面向全局格式;浮层面向当前选区或当前段落的快捷操作与 AI 改写入口。
九、开发与改造建议
9.1 若要改用官方 Bubble Menu
- 在 TiptapExtensions.ts 或组件层引入
@tiptap/react的BubbleMenu(或注册@tiptap/extension-bubble-menu); - 将 WritingBubbleMenu.tsx 内手写
selectionUpdate+ Portal 逻辑替换为官方组件的shouldShow/tippyOptions(v3 API 以官方文档为准); - 段落左侧按钮仍建议保留自研
WritingParagraphMenu(官方 Bubble Menu 只覆盖选区,不覆盖 hover 段落场景)。
9.2 新增浮层按钮
- 滑词:在
WritingBubbleMenu的BubbleButton列表中增加,使用editor.chain().focus()....run()。 - 段落:在
WritingParagraphMenu的handleClick或拆出多按钮布局;注意REWRITABLE_TYPES与 hover 延迟逻辑。
9.3 流式阶段禁止交互
不要在 DraftStreamingPreview 中挂载 Tiptap 菜单:流式阶段 token 高频更新,挂载 useWritingEditor 易引发更新风暴(见 DraftStreamingPreview.tsx 文件头注释)。应等 isDraftEditable 后再走 TextArtifactRenderer。
十、相关文档
- 前端开发.md §25.4 — Canvas / AI 写作路由与组件总表
- ai-writing-用户自带大纲-测试用例.md — AI 写作业务流程测试(含右侧草稿层级校验)
十一、依赖版本(摘录)
以 package.json 为准,编辑器相关主要为:
@tiptap/core、@tiptap/react、@tiptap/starter-kit:^3.23.4@tiptap/extension-bubble-menu:已安装但未接入滑词实现tiptap-markdown:Markdown 扩展
升级 Tiptap 大版本前,请对照官方迁移指南,并重点回归:WritingBubbleMenu 选区坐标、WritingParagraphMenu 的 posAtCoords、以及 AiHighlightExtension 与 StarterKit 扩展名冲突(勿重复注册 underline)。