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)
@@ -64,5 +107,6 @@ const extensions = computed(() => [
:tab-size="4"
:placeholder="placeholder"
:style="{ height, fontSize: `${fontSize}px` }"
+ @ready="handleEditorReady"
/>
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=="],