|
75 | 75 |
|
76 | 76 | - **评论定位**:使用Yjs相对位置系统(Y.createRelativePositionFromTypeIndex)解决文档编辑导致的**评论漂移**问题 |
77 | 77 |
|
78 | | - ```javascript |
79 | | - // 添加评论方法 |
80 | | - const addComment = useCallback((startIndex, endIndex, content, author) => { |
81 | | - if (!yCommentsRef.current || !yTextRef.current || !editorRef.current) { |
82 | | - console.error('Comments, text reference or editor not initialized'); |
83 | | - return; |
84 | | - } |
85 | | - |
86 | | - try { |
87 | | - const editor = editorRef.current; |
88 | | - |
89 | | - // 保存当前选区 |
90 | | - const savedSelection = editor.selection; |
91 | | - |
92 | | - // 使用全局索引找到对应的 Slate 范围 |
93 | | - let count = 0; |
94 | | - let anchor = null; |
95 | | - let focus = null; |
96 | | - |
97 | | - for (const [node, path] of Node.texts(editor)) { |
98 | | - const len = Node.string(node).length; |
99 | | - if (anchor === null && count + len >= startIndex) { |
100 | | - anchor = { path, offset: startIndex - count }; |
101 | | - } |
102 | | - if (focus === null && count + len >= endIndex) { |
103 | | - focus = { path, offset: endIndex - count }; |
104 | | - break; |
105 | | - } |
106 | | - count += len; |
107 | | - } |
108 | | - |
109 | | - if (!anchor || !focus) { |
110 | | - console.error('Failed to find text range for comment'); |
111 | | - return; |
112 | | - } |
113 | | - |
114 | | - // 设置选区到要评论的文本 |
115 | | - const commentRange = { anchor, focus }; |
116 | | - Transforms.select(editor, commentRange); |
117 | | - |
118 | | - // 生成唯一的评论ID |
119 | | - const commentId = Date.now().toString(); |
120 | | - |
121 | | - // 直接添加评论标记到选中的文本 |
122 | | - Editor.addMark(editor, 'comment', { |
123 | | - id: commentId, |
124 | | - content, |
125 | | - author, |
126 | | - time: Date.now(), |
127 | | - }); |
128 | | - |
129 | | - // 恢复原始选区 |
130 | | - if (savedSelection) { |
131 | | - Transforms.select(editor, savedSelection); |
132 | | - } |
133 | | - |
134 | | - // 清除编辑器的活动标记状态,防止影响后续输入 |
135 | | - // 注意:这里只清除活动标记,不影响已经应用到文本节点上的标记 |
136 | | - editor.marks = null; |
137 | | - |
138 | | - // 延迟清除活动标记,确保不影响后续输入 |
139 | | - setTimeout(() => { |
140 | | - editor.marks = null; |
141 | | - }, 0); |
142 | | - |
143 | | - console.log('Comment added successfully'); |
144 | | - |
145 | | - // 同时也保存到 Yjs 数组中以便协同 |
146 | | - if (startIndex !== undefined && endIndex !== undefined) { |
147 | | - // 确保索引在有效范围内 |
148 | | - const validStartIndex = Math.max( |
149 | | - 0, |
150 | | - Math.min(startIndex, yTextRef.current.length), |
151 | | - ); |
152 | | - const validEndIndex = Math.max( |
153 | | - validStartIndex, |
154 | | - Math.min(endIndex, yTextRef.current.length), |
155 | | - ); |
156 | | - |
157 | | - // 创建相对位置 |
158 | | - const start = Y.createRelativePositionFromTypeIndex( |
159 | | - yTextRef.current, |
160 | | - validStartIndex, |
161 | | - ); |
162 | | - const end = Y.createRelativePositionFromTypeIndex( |
163 | | - yTextRef.current, |
164 | | - validEndIndex, |
165 | | - ); |
166 | | - |
167 | | - if (start && end) { |
168 | | - const startJSON = JSON.stringify(start); |
169 | | - const endJSON = JSON.stringify(end); |
170 | | - |
171 | | - // 添加评论到 Yjs 数组 |
172 | | - yCommentsRef.current.push([ |
173 | | - { |
174 | | - id: commentId, |
175 | | - start: startJSON, |
176 | | - end: endJSON, |
177 | | - content, |
178 | | - author, |
179 | | - time: Date.now(), |
180 | | - }, |
181 | | - ]); |
182 | | - } |
183 | | - } |
184 | | - } catch (error) { |
185 | | - console.error('Error adding comment:', error); |
186 | | - } |
187 | | - }, []); |
188 | | - ``` |
| 78 | + 下面是协同评论功能核心的代码实现: |
189 | 79 |
|
190 | 80 | ```javascript |
191 | | - // 删除评论方法 |
192 | | - const removeComment = useCallback( |
193 | | - commentId => { |
194 | | - try { |
195 | | - if (!editor || !commentId) { |
196 | | - console.error('Editor or commentId is missing'); |
197 | | - return; |
198 | | - } |
| 81 | + // 1. 评论数据结构定义 (存储在Yjs中) |
| 82 | + const commentStructure = { |
| 83 | + id: "unique_id", // 唯一标识 |
| 84 | + start: "relative_pos", // 起始位置(JSON序列化的Y.RelativePosition) |
| 85 | + end: "relative_pos", // 结束位置 |
| 86 | + content: "评论内容", |
| 87 | + author: "作者", |
| 88 | + time: 1672531200000 // 时间戳 |
| 89 | + }; |
| 90 | + |
| 91 | + // 2. 核心协同逻辑 (useCollaborativeEditor.jsx) |
| 92 | + // 初始化Yjs数据结构 |
| 93 | + useEffect(() => { |
| 94 | + if (!docRef.current) return; |
| 95 | + yCommentsRef.current = docRef.current.getArray('comments'); // Yjs共享数组 |
| 96 | + yTextRef.current = docRef.current.get('content', Y.XmlText); // 共享文本 |
| 97 | + }, [docRef]); |
| 98 | + |
| 99 | + // 3. 添加评论的核心方法 |
| 100 | + const addComment = useCallback((startIndex, endIndex, content, author) => { |
| 101 | + // 生成相对位置(解决编辑漂移问题) |
| 102 | + const startPos = Y.createRelativePositionFromTypeIndex( |
| 103 | + yTextRef.current, |
| 104 | + startIndex |
| 105 | + ); |
| 106 | + const endPos = Y.createRelativePositionFromTypeIndex( |
| 107 | + yTextRef.current, |
| 108 | + endIndex |
| 109 | + ); |
199 | 110 |
|
200 | | - console.log('Removing comment:', commentId); |
| 111 | + // 存储到Yjs共享数组 |
| 112 | + yCommentsRef.current.push([{ |
| 113 | + id: Date.now().toString(), |
| 114 | + start: JSON.stringify(startPos), |
| 115 | + end: JSON.stringify(endPos), |
| 116 | + content, |
| 117 | + author, |
| 118 | + time: Date.now() |
| 119 | + }]); |
| 120 | + |
| 121 | + // 添加Slate标记(本地渲染) |
| 122 | + Editor.addMark(editor, 'comment', { |
| 123 | + id: commentId, |
| 124 | + content, |
| 125 | + author |
| 126 | + }); |
| 127 | + }, []); |
| 128 | + |
| 129 | + // 4. 删除评论的核心方法 |
| 130 | + const removeComment = useCallback(commentId => { |
| 131 | + // 从Yjs数组中删除 |
| 132 | + yCommentsRef.current.forEach((comment, index) => { |
| 133 | + if (comment[0].id === commentId) { |
| 134 | + yCommentsRef.current.delete(index, 1); |
| 135 | + } |
| 136 | + }); |
201 | 137 |
|
202 | | - // 保存当前选区 |
203 | | - const { selection } = editor; |
| 138 | + // 从Slate编辑器中删除标记 |
| 139 | + Editor.removeMark(editor, 'comment'); |
| 140 | + }, []); |
| 141 | + ``` |
204 | 142 |
|
205 | | - // 1. 从 Slate 编辑器中移除评论标记 |
206 | | - // 遍历所有文本节点,找到包含指定评论ID的节点并移除标记 |
207 | | - const nodesToUpdate = []; |
208 | | - for (const [node, path] of Node.texts(editor)) { |
209 | | - if (node.comment && node.comment.id === commentId) { |
210 | | - nodesToUpdate.push(path); |
211 | | - } |
212 | | - } |
| 143 | + 关键设计亮点: |
213 | 144 |
|
214 | | - // 批量移除评论标记 |
215 | | - for (const path of nodesToUpdate) { |
216 | | - try { |
217 | | - // 方法1: 使用 Transforms.unsetNodes 移除 comment 属性 |
218 | | - Transforms.unsetNodes(editor, 'comment', { |
219 | | - at: path, |
220 | | - match: n => |
221 | | - Text.isText(n) && n.comment && n.comment.id === commentId, |
222 | | - }); |
| 145 | + 1. **双重数据同步机制**: |
223 | 146 |
|
224 | | - // 方法2: 直接操作节点属性(备用方案) |
225 | | - const node = Node.get(editor, path); |
226 | | - if ( |
227 | | - Text.isText(node) && |
228 | | - node.comment && |
229 | | - node.comment.id === commentId |
230 | | - ) { |
231 | | - // 创建新的节点,不包含 comment 属性 |
232 | | - const { comment, ...nodeWithoutComment } = node; |
233 | | - Transforms.setNodes(editor, nodeWithoutComment, { at: path }); |
234 | | - } |
235 | | - } catch (error) { |
236 | | - console.warn( |
237 | | - 'Failed to remove comment from node at path:', |
238 | | - path, |
239 | | - error, |
240 | | - ); |
241 | | - } |
242 | | - } |
| 147 | + ```javascript |
| 148 | + // Yjs数据层 (协同) |
| 149 | + yCommentsRef.current.push([commentData]); |
243 | 150 | |
244 | | - // 2. 清除编辑器中的活动标记,防止影响后续输入 |
245 | | - // 这是关键步骤:确保光标位置不会继承评论标记 |
246 | | - if (editor.marks && editor.marks.comment) { |
247 | | - delete editor.marks.comment; |
248 | | - } |
| 151 | + // Slate表现层 (渲染) |
| 152 | + Editor.addMark(editor, 'comment', commentData); |
| 153 | + ``` |
249 | 154 |
|
250 | | - // 强制移除所有评论相关的活动标记 |
251 | | - Editor.removeMark(editor, 'comment'); |
| 155 | + 1. **位置漂移解决方案**: |
252 | 156 |
|
253 | | - // 3. 从 Yjs 评论数组中移除评论数据 |
254 | | - // 注意:Yjs 数组中的评论数据结构可能不同,需要检查多种可能的 ID 字段 |
255 | | - if (yCommentsRef.current) { |
256 | | - const yCommentsArray = yCommentsRef.current.toArray(); |
257 | | - for (let i = yCommentsArray.length - 1; i >= 0; i--) { |
258 | | - const comment = yCommentsArray[i]; |
259 | | - // 检查不同可能的 ID 字段和数据结构 |
260 | | - const commentToCheck = Array.isArray(comment) |
261 | | - ? comment[0] |
262 | | - : comment; |
263 | | - if ( |
264 | | - commentToCheck && |
265 | | - (commentToCheck.id === commentId || |
266 | | - (commentToCheck.content && |
267 | | - commentToCheck.author && |
268 | | - JSON.stringify(commentToCheck).includes(commentId))) |
269 | | - ) { |
270 | | - yCommentsRef.current.delete(i, 1); |
271 | | - console.log('Removed comment from Yjs array at index:', i); |
272 | | - break; |
273 | | - } |
274 | | - } |
275 | | - } |
| 157 | + ```javascript |
| 158 | + // 创建相对位置 |
| 159 | + const pos = Y.createRelativePositionFromTypeIndex( |
| 160 | + yTextRef.current, // 关联的Yjs文本类型 |
| 161 | + charIndex // 原始字符索引 |
| 162 | + ); |
276 | 163 | |
277 | | - // 4. 恢复原始选区并确保清除标记状态 |
278 | | - if (selection) { |
279 | | - Transforms.select(editor, selection); |
280 | | - // 再次确保当前选区没有评论标记 |
281 | | - Editor.removeMark(editor, 'comment'); |
282 | | - } |
| 164 | + // 恢复绝对位置 |
| 165 | + const absPos = Y.createAbsolutePositionFromRelativePosition( |
| 166 | + JSON.parse(relativePos), |
| 167 | + docRef.current |
| 168 | + ); |
| 169 | + ``` |
283 | 170 |
|
284 | | - // 5. 强制重新规范化编辑器,确保所有标记都被清除 |
285 | | - Editor.normalize(editor, { force: true }); |
| 171 | + 1. **高效协同更新**: |
286 | 172 |
|
287 | | - // 6. 强制触发编辑器重新渲染 |
288 | | - editor.onChange(); |
| 173 | + ```javascript |
| 174 | + // 监听Yjs数组变化 |
| 175 | + yCommentsRef.current.observe(() => { |
| 176 | + // 触发编辑器重新渲染 |
| 177 | + setValue(v => [...v]); |
| 178 | + }); |
| 179 | + ``` |
289 | 180 |
|
290 | | - // 7. 延迟再次清除活动标记,确保彻底清除 |
291 | | - setTimeout(() => { |
292 | | - if (editor.marks && editor.marks.comment) { |
293 | | - delete editor.marks.comment; |
294 | | - } |
295 | | - Editor.removeMark(editor, 'comment'); |
296 | | - }, 0); |
| 181 | + 这套实现确保了评论功能在多人实时协作时的稳定性和一致性。 |
297 | 182 |
|
298 | | - console.log('Comment removed successfully'); |
299 | | - } catch (error) { |
300 | | - console.error('Error removing comment:', error); |
301 | | - } |
302 | | - }, |
303 | | - [editor], |
304 | | - ); |
305 | | - ``` |
306 | | - |
307 | 183 | 关键实现说明: |
308 | | - |
| 184 | + |
309 | 185 | 1. **双重定位系统**: |
310 | | - |
| 186 | + |
311 | 187 | - Slate路径定位:用于当前编辑器实例中的快速渲染 |
312 | | - - Yjs相对位置:用于跨客户端同步和文档修改时的位置保持 |
313 | | - |
314 | | - 1. **漂移问题解决**: |
315 | | - |
316 | | - ```javascript |
317 | | - // 当文档修改后,通过相对位置可以正确找到评论位置 |
318 | | - const absPos = Y.createAbsolutePositionFromRelativePosition( |
319 | | - JSON.parse(comment.start), |
320 | | - docRef.current |
321 | | - ); |
322 | | - // absPos包含最新的正确位置信息 |
323 | | - ``` |
324 | | - |
325 | | - **主要方法**: |
326 | | - |
| 188 | + - Yjs相对位置:用于跨客户端同步和文档修改时的位置保持主要方法**: |
| 189 | + |
327 | 190 | - `addComment`:添加评论到指定文本范围 |
328 | 191 | - `removeComment`:删除指定ID的评论 |
329 | 192 | - 评论数据实时同步到所有客户端 |
330 | 193 |
|
331 | | - |
332 | 194 | **doc-docs(项目介绍文档):** |
333 | 195 |
|
334 | | -- 介绍文档具体内容撰写 |
| 196 | +- 侧边栏目录自动化,介绍文档具体内容撰写 |
335 | 197 | - AI理解文档能力增强:集成 LLM(大语言模型)友好的文档格式(傻瓜ai也能快速读懂的文档) |
336 | 198 |
|
337 | 199 | **doc-web(项目前端):** |
|
0 commit comments