feat(web): 协作编辑走 collab 通道
Yjs sync/awareness 协议直接跑在 /ws/collab 上,服务端哑转发。 内容源是学生:学生端先把编辑器内容写进 ytext 再挂 yCollab, 教师端 seedContent 恒为 null —— 这条根治了老实现里谁的代码活下来看运气的问题。 订正 brief 里的一处疏漏:SyncCodeEditor.vue 挂在每个用户的题目页上,教师也不例外 (ProblemEditor.vue 只按语言是不是 Flowchart 分支,不看角色)。教师接单同样会让 collabStore.room 非空,若不按角色收窄,教师自己停在某道题页面上接单时,这个组件 会把教师自己的编辑器内容当成种子插入文档,还会跟 CollabModal 抢 setBinaryHandler 这个单例槽位。改为 `room && !collabStore.isTeacher` 才起协作, 教师端的协作只归 CollabModal 管。 另外两处修正: - editorView 用 shallowRef 而非 ref —— CodeMirror 的 EditorView 是带 getter 的类 实例,ref() 的深度 UnwrapRef 会把它拆成丢了原型方法的假类型,vue-tsc 报错。 - Header.vue 挂 CollabModal 放进根 n-flex 内部而不是同级 —— 组件一旦变成多根 fragment,default.vue 里 `<Header class="header" />` 的 class 就没有单一根节点 可以落地,header 行会丢掉居中样式(实测触发了 Vue 的 Extraneous non-props attributes 警告)。n-modal 默认 teleport 到 body,塞在 这里不影响其实际渲染位置。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
This commit is contained in:
159
apps/web/src/shared/composables/collabDoc.ts
Normal file
159
apps/web/src/shared/composables/collabDoc.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Compartment } from "@codemirror/state"
|
||||
import type { EditorView } from "@codemirror/view"
|
||||
import { useCollabStore } from "shared/store/collab"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
|
||||
/** y-websocket 那套消息头,服务端不解析,只有两端认 */
|
||||
const MESSAGE_SYNC = 0
|
||||
const MESSAGE_AWARENESS = 1
|
||||
|
||||
const TEACHER_COLOR = "#ff6b6b"
|
||||
const STUDENT_COLOR = "#4dabf7"
|
||||
|
||||
interface StartOptions {
|
||||
editorView: EditorView
|
||||
/**
|
||||
* 文档的初始内容。
|
||||
*
|
||||
* **学生端传当前编辑器内容,教师端必须传 null。** 这是硬规则:
|
||||
* 求助是学生发起的,学生的代码是唯一内容源。老师端插入任何初始内容都会
|
||||
* 与学生的内容合并,结果就是两份代码拼在一起 —— 老实现的竞态就是这么来的。
|
||||
*/
|
||||
seedContent: string | null
|
||||
}
|
||||
|
||||
export function useCollabDoc() {
|
||||
const collabStore = useCollabStore()
|
||||
const userStore = useUserStore()
|
||||
const compartment = new Compartment()
|
||||
|
||||
let doc: any = null
|
||||
let awareness: any = null
|
||||
let view: EditorView | null = null
|
||||
let detachDocUpdate: (() => void) | null = null
|
||||
let detachAwarenessUpdate: (() => void) | null = null
|
||||
|
||||
async function start({ editorView, seedContent }: StartOptions) {
|
||||
const [Y, awarenessProtocol, syncProtocol, encoding, decoding, { yCollab }] =
|
||||
await Promise.all([
|
||||
import("yjs"),
|
||||
import("y-protocols/awareness"),
|
||||
import("y-protocols/sync"),
|
||||
import("lib0/encoding"),
|
||||
import("lib0/decoding"),
|
||||
import("y-codemirror.next"),
|
||||
])
|
||||
|
||||
view = editorView
|
||||
doc = new Y.Doc()
|
||||
const ytext = doc.getText("codemirror")
|
||||
awareness = new awarenessProtocol.Awareness(doc)
|
||||
|
||||
// ★ 顺序不能反:先把内容写进 ytext,再挂 yCollab。
|
||||
// yCollab 挂上去时会用 ytext 覆盖编辑器内容,先挂就会把学生的代码清空。
|
||||
if (seedContent) ytext.insert(0, seedContent)
|
||||
|
||||
const send = (build: (encoder: any) => void) => {
|
||||
const encoder = encoding.createEncoder()
|
||||
build(encoder)
|
||||
collabStore.sendBinary(encoding.toUint8Array(encoder))
|
||||
}
|
||||
|
||||
collabStore.setBinaryHandler((data) => {
|
||||
const decoder = decoding.createDecoder(new Uint8Array(data))
|
||||
const messageType = decoding.readVarUint(decoder)
|
||||
if (messageType === MESSAGE_SYNC) {
|
||||
const encoder = encoding.createEncoder()
|
||||
encoding.writeVarUint(encoder, MESSAGE_SYNC)
|
||||
syncProtocol.readSyncMessage(decoder, encoder, doc, "remote")
|
||||
// 只有需要回话时才发(readSyncMessage 可能什么都没写)
|
||||
if (encoding.length(encoder) > 1) {
|
||||
collabStore.sendBinary(encoding.toUint8Array(encoder))
|
||||
}
|
||||
} else if (messageType === MESSAGE_AWARENESS) {
|
||||
awarenessProtocol.applyAwarenessUpdate(
|
||||
awareness,
|
||||
decoding.readVarUint8Array(decoder),
|
||||
"remote",
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const onDocUpdate = (update: Uint8Array, origin: any) => {
|
||||
if (origin === "remote") return
|
||||
send((encoder) => {
|
||||
encoding.writeVarUint(encoder, MESSAGE_SYNC)
|
||||
syncProtocol.writeUpdate(encoder, update)
|
||||
})
|
||||
}
|
||||
doc.on("update", onDocUpdate)
|
||||
detachDocUpdate = () => doc?.off("update", onDocUpdate)
|
||||
|
||||
const onAwarenessUpdate = (
|
||||
{ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
|
||||
origin: any,
|
||||
) => {
|
||||
if (origin === "remote") return
|
||||
const changed = added.concat(updated, removed)
|
||||
send((encoder) => {
|
||||
encoding.writeVarUint(encoder, MESSAGE_AWARENESS)
|
||||
encoding.writeVarUint8Array(
|
||||
encoder,
|
||||
awarenessProtocol.encodeAwarenessUpdate(awareness, changed),
|
||||
)
|
||||
})
|
||||
}
|
||||
awareness.on("update", onAwarenessUpdate)
|
||||
detachAwarenessUpdate = () => awareness?.off("update", onAwarenessUpdate)
|
||||
|
||||
awareness.setLocalStateField("user", {
|
||||
name: userStore.user?.username ?? "匿名",
|
||||
color: userStore.isTeacherOrAbove ? TEACHER_COLOR : STUDENT_COLOR,
|
||||
})
|
||||
|
||||
editorView.dispatch({
|
||||
effects: compartment.reconfigure(yCollab(ytext, awareness)),
|
||||
})
|
||||
|
||||
// 握手:双方都发 SyncStep1,各自回 Step2,两边收敛。
|
||||
// 服务端是哑转发,不参与同步,所以这一步必须由两端对称完成
|
||||
send((encoder) => {
|
||||
encoding.writeVarUint(encoder, MESSAGE_SYNC)
|
||||
syncProtocol.writeSyncStep1(encoder, doc)
|
||||
})
|
||||
send((encoder) => {
|
||||
encoding.writeVarUint(encoder, MESSAGE_AWARENESS)
|
||||
encoding.writeVarUint8Array(
|
||||
encoder,
|
||||
awarenessProtocol.encodeAwarenessUpdate(awareness, [doc.clientID]),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function stop() {
|
||||
collabStore.setBinaryHandler(null)
|
||||
detachDocUpdate?.()
|
||||
detachAwarenessUpdate?.()
|
||||
detachDocUpdate = null
|
||||
detachAwarenessUpdate = null
|
||||
|
||||
if (view) {
|
||||
try {
|
||||
view.dispatch({ effects: compartment.reconfigure([]) })
|
||||
} catch (error) {
|
||||
console.warn("移除协同编辑扩展失败:", error)
|
||||
}
|
||||
view = null
|
||||
}
|
||||
awareness?.destroy()
|
||||
doc?.destroy()
|
||||
awareness = null
|
||||
doc = null
|
||||
}
|
||||
|
||||
function getInitialExtension() {
|
||||
return compartment.of([])
|
||||
}
|
||||
|
||||
return { start, stop, getInitialExtension }
|
||||
}
|
||||
Reference in New Issue
Block a user