diff --git a/apps/web/package.json b/apps/web/package.json index 4702fde..20053ee 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -53,6 +53,7 @@ "vue-codemirror": "^6.1.1", "vue-router": "^5.2.0", "y-codemirror.next": "^0.3.6", + "y-protocols": "1.0.7", "y-webrtc": "^10.3.0", "yjs": "^13.6.32" }, diff --git a/apps/web/src/shared/components/CollabModal.vue b/apps/web/src/shared/components/CollabModal.vue new file mode 100644 index 0000000..179a8ab --- /dev/null +++ b/apps/web/src/shared/components/CollabModal.vue @@ -0,0 +1,93 @@ + + + diff --git a/apps/web/src/shared/components/Header.vue b/apps/web/src/shared/components/Header.vue index 3a9d6c2..4924de5 100644 --- a/apps/web/src/shared/components/Header.vue +++ b/apps/web/src/shared/components/Header.vue @@ -6,6 +6,7 @@ import { useLearnProgress } from "shared/composables/learnProgress" import { useAuthModalStore } from "shared/store/authModal" import { useScreenModeStore } from "shared/store/screenMode" import { logout } from "../api" +import CollabModal from "./CollabModal.vue" import HelpRequestList from "./HelpRequestList.vue" import { useConfigStore } from "../store/config" import { useUserStore } from "../store/user" @@ -363,6 +364,14 @@ function handleMenuSelect(key: string) { + + diff --git a/apps/web/src/shared/components/SyncCodeEditor.vue b/apps/web/src/shared/components/SyncCodeEditor.vue index 752888c..8c34441 100644 --- a/apps/web/src/shared/components/SyncCodeEditor.vue +++ b/apps/web/src/shared/components/SyncCodeEditor.vue @@ -10,13 +10,18 @@ import { completeAnyWord, } from "@codemirror/autocomplete" import type { Extension } from "@codemirror/state" +import type { EditorView } from "@codemirror/view" import type { LANGUAGE } from "utils/types" import { oneDark } from "../themes/oneDark" import { smoothy } from "../themes/smoothy" import { styleTheme } from "shared/extensions/baseTheme" import { enhanceCompletion } from "shared/extensions/autocompletion" +import { useCollabDoc } from "../composables/collabDoc" +import { useCollabStore } from "shared/store/collab" const isDark = useDark() +const collabStore = useCollabStore() +const { start, stop, getInitialExtension } = useCollabDoc() interface Props { problem: string @@ -51,8 +56,46 @@ const extensions = computed(() => [ autocompletion({ override: [enhanceCompletion(language), completeAnyWord], }), + getInitialExtension(), ]) +interface EditorReadyPayload { + view: EditorView +} + +// shallowRef,不是 ref:CodeMirror 的 EditorView 是带 getter 的类实例, +// Vue 的 UnwrapRef 深度展开会把它结构化成一个丢了原型方法的假类型, +// vue-tsc 会报 "missing dispatchTransactions/_root/..." 这类莫名其妙的错。 +// 项目里旧的 sync.ts 用的是裸变量,同一个道理,这里换成 shallowRef 规避。 +const editorView = shallowRef(null) + +const handleEditorReady = (payload: EditorReadyPayload) => { + editorView.value = payload.view +} + +// 房间开了才建文档。学生点求助时什么都不做 —— 老师没来之前不该动他的编辑器。 +// 只在学生端启用:这个组件挂在每个用户的题目页上(教师也不例外, +// ProblemEditor.vue 只按语言是不是 Flowchart 分支,不看角色), +// 教师接单同样会让 collabStore.room 非空 —— 如果这里不按角色收窄, +// 教师自己停在某道题的页面上接单时,这个组件会把**教师自己的编辑器内容** +// 当成种子插入文档,还会跟 CollabModal 抢 setBinaryHandler 这个单例槽位, +// 两者谁后调用谁把对方顶掉。教师端的协作只归 CollabModal 管。 +watch( + () => collabStore.room, + (room) => { + if (room && !collabStore.isTeacher && editorView.value) { + // 学生端:当前编辑器内容就是内容源 + start({ + editorView: editorView.value, + seedContent: editorView.value.state.doc.toString(), + }) + } else { + stop() + } + }, +) + +onUnmounted(stop) diff --git a/apps/web/src/shared/composables/collabDoc.ts b/apps/web/src/shared/composables/collabDoc.ts new file mode 100644 index 0000000..27be8a3 --- /dev/null +++ b/apps/web/src/shared/composables/collabDoc.ts @@ -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 } +} diff --git a/bun.lock b/bun.lock index fcde3ef..a53939a 100644 --- a/bun.lock +++ b/bun.lock @@ -80,6 +80,7 @@ "vue-codemirror": "^6.1.1", "vue-router": "^5.2.0", "y-codemirror.next": "^0.3.6", + "y-protocols": "1.0.7", "y-webrtc": "^10.3.0", "yjs": "^13.6.32", }, @@ -1366,7 +1367,7 @@ "tree-sitter-c": ["tree-sitter-c@0.24.1", "https://registry.npmjs.com/tree-sitter-c/-/tree-sitter-c-0.24.1.tgz", { "dependencies": { "node-addon-api": "^8.3.1", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.22.4" }, "optionalPeers": ["tree-sitter"] }, "sha512-lkYwWN3SRecpvaeqmFKkuPNR3ZbtnvHU+4XAEEkJdrp3JfSp2pBrhXOtvfsENUneye76g889Y0ddF2DM0gEDpA=="], - "tree-sitter-cpp": ["tree-sitter-cpp@0.23.4", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2", "tree-sitter-c": "^0.23.1" }, "peerDependencies": { "tree-sitter": "^0.21.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-qR5qUDyhZ5jJ6V8/umiBxokRbe89bCGmcq/dk94wI4kN86qfdV8k0GHIUEKaqWgcu42wKal5E97LKpLeVW8sKw=="], + "tree-sitter-cpp": ["tree-sitter-cpp@0.23.4", "https://registry.npmjs.com/tree-sitter-cpp/-/tree-sitter-cpp-0.23.4.tgz", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2", "tree-sitter-c": "^0.23.1" }, "peerDependencies": { "tree-sitter": "^0.21.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-qR5qUDyhZ5jJ6V8/umiBxokRbe89bCGmcq/dk94wI4kN86qfdV8k0GHIUEKaqWgcu42wKal5E97LKpLeVW8sKw=="], "tree-sitter-python": ["tree-sitter-python@0.25.0", "https://registry.npmjs.com/tree-sitter-python/-/tree-sitter-python-0.25.0.tgz", { "dependencies": { "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-eCmJx6zQa35GxaCtQD+wXHOhYqBxEL+bp71W/s3fcDMu06MrtzkVXR437dRrCrbrDbyLuUDJpAgycs7ncngLXw=="], @@ -1540,7 +1541,7 @@ "strip-literal/js-tokens": ["js-tokens@10.0.0", "https://registry.npmjs.com/js-tokens/-/js-tokens-10.0.0.tgz", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], - "tree-sitter-cpp/tree-sitter-c": ["tree-sitter-c@0.23.6", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.22.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-0dxXKznVyUA0s6PjNolJNs2yF87O5aL538A/eR6njA5oqX3C3vH4vnx3QdOKwuUdpKEcFdHuiDpRKLLCA/tjvQ=="], + "tree-sitter-cpp/tree-sitter-c": ["tree-sitter-c@0.23.6", "https://registry.npmjs.com/tree-sitter-c/-/tree-sitter-c-0.23.6.tgz", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.22.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-0dxXKznVyUA0s6PjNolJNs2yF87O5aL538A/eR6njA5oqX3C3vH4vnx3QdOKwuUdpKEcFdHuiDpRKLLCA/tjvQ=="], "tsx/esbuild": ["esbuild@0.28.1", "https://registry.npmjs.com/esbuild/-/esbuild-0.28.1.tgz", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],